Adds Project CLI commands

Adding cli commands associated with /projects api.

Closes-Bug: 1659237

Change-Id: Ic3375176ddf85d283f784d59307be434cefac9e0
This commit is contained in:
Thomas Maddox 2017-02-03 19:20:13 +00:00
parent 3912b47732
commit 24de28869d
6 changed files with 549 additions and 0 deletions

View File

@ -0,0 +1,108 @@
# -*- 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.
"""Projects 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 projects
@cliutils.arg('id',
metavar='<project>',
help='ID of the project.')
def do_project_show(cc, args):
"""Show detailed information about a project."""
project = cc.projects.get(args.id)
data = {f: getattr(project, f, '') for f in projects.PROJECT_FIELDS}
cliutils.print_dict(data, wrap=72)
@cliutils.arg('-n', '--name',
metavar='<name>',
help='Name of the project.')
@cliutils.arg('--detail',
action='store_true',
default=False,
help='Show detailed information about the projects.')
@cliutils.arg('--limit',
metavar='<limit>',
type=int,
help='Maximum number of projects to return.')
@cliutils.arg('--fields',
nargs='+',
metavar='<fields>',
default=[],
help='Comma-separated list of fields to display. '
'Only these fields will be fetched from the server. '
'Can not be used when "--detail" is specified')
def do_project_list(cc, args):
"""Print list of projects which are registered with the Craton service."""
params = {}
default_fields = ['id', 'name']
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.fields and args.detail:
raise exc.CommandError('Cannot specify both --fields and --detail.')
if args.name:
params['name'] = args.name
if args.detail:
fields = projects.PROJECT_FIELDS
elif args.fields:
try:
fields = {x: projects.PROJECT_FIELDS[x] for x in args.fields}
except KeyError as keyerr:
raise exc.CommandError('Invalid field "{}"'.format(keyerr.args[0]))
else:
fields = {x: projects.PROJECT_FIELDS[x] for x in default_fields}
listed_projects = cc.projects.list(**params)
cliutils.print_list(listed_projects, list(fields))
@cliutils.arg('-n', '--name',
metavar='<name>',
required=True,
help='Name of the project.')
def do_project_create(cc, args):
"""Register a new project with the Craton service."""
fields = {k: v for (k, v) in vars(args).items()
if k in projects.PROJECT_FIELDS and not (v is None)}
project = cc.projects.create(**fields)
data = {f: getattr(project, f, '') for f in projects.PROJECT_FIELDS}
cliutils.print_dict(data, wrap=72)
@cliutils.arg('id',
metavar='<project>',
help='ID of the project.')
def do_project_delete(cc, args):
"""Delete a project that is registered with the Craton service."""
try:
response = cc.projects.delete(args.id)
except exc.ClientException as client_exc:
raise exc.CommandError(
'Failed to delete project {} due to "{}:{}"'.format(
args.id, client_exc.__class__, str(client_exc)
)
)
else:
print("Project {0} was {1} deleted.".
format(args.id, 'successfully' if response else 'not'))

View File

@ -12,11 +12,13 @@
"""Command-line interface to the OpenStack Craton API V1."""
from cratonclient.shell.v1 import cells_shell
from cratonclient.shell.v1 import hosts_shell
from cratonclient.shell.v1 import projects_shell
from cratonclient.shell.v1 import regions_shell
COMMAND_MODULES = [
# TODO(cmspence): project_shell, cell_shell, device_shell, user_shell, etc.
projects_shell,
regions_shell,
hosts_shell,
cells_shell,

View File

@ -0,0 +1,178 @@
# 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.projects_shell` module."""
import mock
import re
from argparse import Namespace
from testtools import matchers
from cratonclient import exceptions as exc
from cratonclient.shell.v1 import projects_shell
from cratonclient.tests.integration import base
from cratonclient.v1 import projects
class TestProjectsShell(base.ShellTestCase):
"""Test our craton projects shell commands."""
re_options = re.DOTALL | re.MULTILINE
project_valid_fields = None
project_invalid_field = None
def setUp(self):
"""Setup required test fixtures."""
super(TestProjectsShell, self).setUp()
self.project_valid_fields = Namespace(name='mock_project')
self.project_invalid_field = Namespace(name='mock_project',
invalid_foo='ignored')
@mock.patch('cratonclient.v1.projects.ProjectManager.list')
def test_project_list_success(self, mock_list):
"""Verify that no arguments prints out all project projects."""
self.shell('project-list')
self.assertTrue(mock_list.called)
@mock.patch('cratonclient.v1.projects.ProjectManager.list')
def test_project_list_parse_param_success(self, mock_list):
"""Verify that success of parsing a subcommand argument."""
self.shell('project-list --limit 0')
self.assertTrue(mock_list.called)
@mock.patch('cratonclient.v1.projects.ProjectManager.list')
def test_project_list_limit_0_success(self, mock_list):
"""Verify that --limit 0 prints out all project projects."""
self.shell('project-list --limit 0')
mock_list.assert_called_once_with(
limit=0
)
@mock.patch('cratonclient.v1.projects.ProjectManager.list')
def test_project_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 projects.
"""
self.shell('project-list --limit 1')
mock_list.assert_called_once_with(
limit=1
)
def test_project_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,
'project-list --limit -1')
@mock.patch('cratonclient.v1.projects.ProjectManager.list')
def test_project_list_detail_success(self, mock_list):
"""Verify --detail argument successfully pass detail to Client."""
self.shell('project-list --detail')
mock_list.assert_called_once_with()
@mock.patch('cratonclient.v1.projects.ProjectManager.list')
@mock.patch('cratonclient.common.cliutils.print_list')
def test_project_list_fields_success(self, mock_printlist, mock_list):
"""Verify --fields argument successfully passed to Client."""
self.shell('project-list --fields id name')
mock_list.assert_called_once_with()
mock_printlist.assert_called_once_with(mock.ANY,
list({'id': 'ID',
'name': 'Name'}))
def test_project_create_missing_required_args(self):
"""Verify that missing required args results in error message."""
expected_responses = [
'.*?^usage: craton project-create',
'.*?^craton project-create: error:.*$'
]
stdout, stderr = self.shell('project-create')
actual_output = stdout + stderr
for r in expected_responses:
self.assertThat(actual_output,
matchers.MatchesRegex(r, self.re_options))
@mock.patch('cratonclient.v1.projects.ProjectManager.create')
def test_do_project_create_calls_project_manager_with_fields(self,
mock_create):
"""Verify that do project create calls ProjectManager create."""
client = mock.Mock()
client.projects = projects.ProjectManager(
mock.ANY, 'http://127.0.0.1/',
region_id=mock.ANY,
)
projects_shell.do_project_create(client, self.project_valid_fields)
mock_create.assert_called_once_with(**vars(self.project_valid_fields))
@mock.patch('cratonclient.v1.projects.ProjectManager.create')
def test_do_project_create_ignores_unknown_fields(self, mock_create):
"""Verify that do project create ignores unknown field."""
client = mock.Mock()
client.projects = projects.ProjectManager(
mock.ANY, 'http://127.0.0.1/',
region_id=mock.ANY,
)
projects_shell.do_project_create(client, self.project_invalid_field)
mock_create.assert_called_once_with(**vars(self.project_valid_fields))
def test_project_show_missing_required_args(self):
"""Verify that missing required args results in error message."""
expected_responses = [
'.*?^usage: craton project-show',
'.*?^craton project-show: error:.*$',
]
stdout, stderr = self.shell('project-show')
actual_output = stdout + stderr
for r in expected_responses:
self.assertThat(actual_output,
matchers.MatchesRegex(r, self.re_options))
@mock.patch('cratonclient.v1.projects.ProjectManager.get')
def test_do_project_show_calls_project_manager_with_fields(self, mock_get):
"""Verify that do project show calls ProjectManager get."""
client = mock.Mock()
client.projects = projects.ProjectManager(
mock.ANY, 'http://127.0.0.1/',
region_id=mock.ANY,
)
test_args = Namespace(id=1)
projects_shell.do_project_show(client, test_args)
mock_get.assert_called_once_with(vars(test_args)['id'])
def test_project_delete_missing_required_args(self):
"""Verify that missing required args results in error message."""
expected_responses = [
'.*?^usage: craton project-delete',
'.*?^craton project-delete: error:.*$',
]
stdout, stderr = self.shell('project-delete')
for r in expected_responses:
self.assertThat((stdout + stderr),
matchers.MatchesRegex(r, self.re_options))
@mock.patch('cratonclient.v1.projects.ProjectManager.delete')
def test_do_project_delete_calls_project_manager_with_fields(self,
mock_delete):
"""Verify that do project delete calls ProjectManager delete."""
client = mock.Mock()
client.projects = projects.ProjectManager(
mock.ANY, 'http://127.0.0.1/',
region_id=mock.ANY,
)
test_args = Namespace(id=1, region=1)
projects_shell.do_project_delete(client, test_args)
mock_delete.assert_called_once_with(vars(test_args)['id'])

View File

@ -0,0 +1,222 @@
# -*- 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 projects resource."""
import uuid
import mock
from cratonclient import exceptions
from cratonclient.shell.v1 import projects_shell
from cratonclient.tests.unit.shell import base
from cratonclient.v1 import projects
class TestDoShellShow(base.TestShellCommandUsingPrintDict):
"""Unit tests for the project show command."""
def test_simple_usage(self):
"""Verify the behaviour of do_project_show."""
args = self.args_for(
region=123,
id=456,
)
projects_shell.do_project_show(self.craton_client, args)
self.craton_client.projects.get.assert_called_once_with(456)
self.print_dict.assert_called_once_with(
{f: mock.ANY for f in projects.PROJECT_FIELDS},
wrap=72,
)
class TestDoProjectList(base.TestShellCommandUsingPrintList):
"""Unit tests for the project list command."""
def assertNothingWasCalled(self):
"""Assert inventory, list, and print_list were not called."""
super(TestDoProjectList, self).assertNothingWasCalled()
self.assertFalse(self.print_list.called)
def args_for(self, **kwargs):
"""Generate the default argument list for project-list."""
kwargs.setdefault('name', None)
kwargs.setdefault('limit', None)
kwargs.setdefault('detail', False)
kwargs.setdefault('fields', [])
return super(TestDoProjectList, self).args_for(**kwargs)
def test_with_defaults(self):
"""Verify behaviour of do_project_list with mostly default values."""
args = self.args_for()
projects_shell.do_project_list(self.craton_client, args)
self.craton_client.projects.list.assert_called_once_with()
self.assertTrue(self.print_list.called)
self.assertEqual(['id', 'name'],
sorted(self.print_list.call_args[0][-1]))
def test_negative_limit(self):
"""Ensure we raise an exception for negative limits."""
args = self.args_for(limit=-1)
self.assertRaisesCommandErrorWith(projects_shell.do_project_list,
args)
self.assertNothingWasCalled()
def test_positive_limit(self):
"""Verify that we pass positive limits to the call to list."""
args = self.args_for(limit=5)
projects_shell.do_project_list(self.craton_client, args)
self.craton_client.projects.list.assert_called_once_with(
limit=5,
)
self.assertTrue(self.print_list.called)
self.assertEqual(['id', 'name'],
sorted(self.print_list.call_args[0][-1]))
def test_detail(self):
"""Verify the behaviour of specifying --detail."""
args = self.args_for(detail=True)
projects_shell.do_project_list(self.craton_client, args)
self.craton_client.projects.list.assert_called_once_with()
self.assertEqual(sorted(list(projects.PROJECT_FIELDS)),
sorted(self.print_list.call_args[0][-1]))
def test_list_name(self):
"""Verify the behaviour of specifying --detail."""
args = self.args_for(name='project_1')
projects_shell.do_project_list(self.craton_client, args)
self.craton_client.projects.list.assert_called_once_with(
name='project_1'
)
self.assertEqual(['id', 'name'],
sorted(self.print_list.call_args[0][-1]))
def test_raises_exception_with_detail_and_fields(self):
"""Verify that we fail when users specify --detail and --fields."""
args = self.args_for(
detail=True,
fields=['id', 'name'],
)
self.assertRaisesCommandErrorWith(projects_shell.do_project_list, args)
self.assertNothingWasCalled()
def test_fields(self):
"""Verify that we print out specific fields."""
args = self.args_for(fields=['id', 'name'])
projects_shell.do_project_list(self.craton_client, args)
self.craton_client.projects.list.assert_called_once_with()
self.assertEqual(['id', 'name'],
sorted(self.print_list.call_args[0][-1]))
def test_invalid_fields(self):
"""Verify that we error out with invalid fields."""
args = self.args_for(fields=['uuid', 'not-name', 'nate'])
self.assertRaisesCommandErrorWith(projects_shell.do_project_list, args)
self.assertNothingWasCalled()
class TestDoProjectCreate(base.TestShellCommandUsingPrintDict):
"""Unit tests for the project create command."""
def args_for(self, **kwargs):
"""Generate the default args for project-create."""
kwargs.setdefault('name', 'New Project')
return super(TestDoProjectCreate, self).args_for(**kwargs)
def test_create(self):
"""Verify our parameters to projects.create."""
args = self.args_for()
projects_shell.do_project_create(self.craton_client, args)
self.craton_client.projects.create.assert_called_once_with(
name='New Project'
)
self.print_dict.assert_called_once_with(mock.ANY, wrap=72)
class TestDoProjectDelete(base.TestShellCommand):
"""Tests for the do_project_delete command."""
def setUp(self):
"""Initialize our print mock."""
super(TestDoProjectDelete, self).setUp()
self.print_func_mock = mock.patch(
'cratonclient.shell.v1.projects_shell.print'
)
self.print_func = self.print_func_mock.start()
self.project_id = uuid.uuid4()
def tearDown(self):
"""Clean up our print mock."""
super(TestDoProjectDelete, self).tearDown()
self.print_func_mock.stop()
def test_successful(self):
"""Verify the message we print when successful."""
self.craton_client.projects.delete.return_value = True
args = self.args_for(
id=self.project_id,
)
projects_shell.do_project_delete(self.craton_client, args)
self.craton_client.projects.delete.assert_called_once_with(
self.project_id)
self.print_func.assert_called_once_with(
'Project {} was successfully deleted.'.format(self.project_id)
)
def test_failed(self):
"""Verify the message we print when deletion fails."""
self.craton_client.projects.delete.return_value = False
args = self.args_for(
id=self.project_id,
)
projects_shell.do_project_delete(self.craton_client, args)
self.craton_client.projects.delete.assert_called_once_with(
self.project_id)
self.print_func.assert_called_once_with(
'Project {} was not deleted.'.format(self.project_id)
)
def test_failed_with_exception(self):
"""Verify the message we print when deletion fails."""
self.craton_client.projects.delete.side_effect = exceptions.NotFound
args = self.args_for(
region=123,
id=self.project_id,
)
self.assertRaisesCommandErrorWith(projects_shell.do_project_delete,
args)
self.craton_client.projects.delete.assert_called_once_with(
self.project_id)
self.assertFalse(self.print_func.called)

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 hosts
from cratonclient.v1 import projects
from cratonclient.v1 import regions
@ -40,4 +41,5 @@ class Client(object):
manager_kwargs = {'session': self._session, 'url': self._url}
self.hosts = hosts.HostManager(**manager_kwargs)
self.cells = cells.CellManager(**manager_kwargs)
self.projects = projects.ProjectManager(**manager_kwargs)
self.regions = regions.RegionManager(**manager_kwargs)

View File

@ -0,0 +1,37 @@
# -*- 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.
"""Regions manager code."""
from cratonclient import crud
class Project(crud.Resource):
"""Representation of a Project."""
pass
class ProjectManager(crud.CRUDClient):
"""A manager for projects."""
key = 'project'
base_path = '/projects'
resource_class = Project
PROJECT_FIELDS = {
'id': 'ID',
'name': 'Name',
'created_at': 'Created At',
'update_at': 'Updated At'
}