From 24de28869db858a9fbf8e38763e607c384a38f0e Mon Sep 17 00:00:00 2001 From: Thomas Maddox Date: Fri, 3 Feb 2017 19:20:13 +0000 Subject: [PATCH] Adds Project CLI commands Adding cli commands associated with /projects api. Closes-Bug: 1659237 Change-Id: Ic3375176ddf85d283f784d59307be434cefac9e0 --- cratonclient/shell/v1/projects_shell.py | 108 +++++++++ cratonclient/shell/v1/shell.py | 2 + .../tests/integration/test_projects_shell.py | 178 ++++++++++++++ .../unit/shell/v1/test_projects_shell.py | 222 ++++++++++++++++++ cratonclient/v1/client.py | 2 + cratonclient/v1/projects.py | 37 +++ 6 files changed, 549 insertions(+) create mode 100644 cratonclient/shell/v1/projects_shell.py create mode 100644 cratonclient/tests/integration/test_projects_shell.py create mode 100644 cratonclient/tests/unit/shell/v1/test_projects_shell.py create mode 100644 cratonclient/v1/projects.py diff --git a/cratonclient/shell/v1/projects_shell.py b/cratonclient/shell/v1/projects_shell.py new file mode 100644 index 0000000..6bda22e --- /dev/null +++ b/cratonclient/shell/v1/projects_shell.py @@ -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='', + 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='', + help='Name of the project.') +@cliutils.arg('--detail', + action='store_true', + default=False, + help='Show detailed information about the projects.') +@cliutils.arg('--limit', + metavar='', + type=int, + help='Maximum number of projects to return.') +@cliutils.arg('--fields', + nargs='+', + metavar='', + 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='', + 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='', + 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')) diff --git a/cratonclient/shell/v1/shell.py b/cratonclient/shell/v1/shell.py index edeee1d..39136cb 100644 --- a/cratonclient/shell/v1/shell.py +++ b/cratonclient/shell/v1/shell.py @@ -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, diff --git a/cratonclient/tests/integration/test_projects_shell.py b/cratonclient/tests/integration/test_projects_shell.py new file mode 100644 index 0000000..0a1334f --- /dev/null +++ b/cratonclient/tests/integration/test_projects_shell.py @@ -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']) diff --git a/cratonclient/tests/unit/shell/v1/test_projects_shell.py b/cratonclient/tests/unit/shell/v1/test_projects_shell.py new file mode 100644 index 0000000..f9728de --- /dev/null +++ b/cratonclient/tests/unit/shell/v1/test_projects_shell.py @@ -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) diff --git a/cratonclient/v1/client.py b/cratonclient/v1/client.py index f75ce7f..085bd64 100644 --- a/cratonclient/v1/client.py +++ b/cratonclient/v1/client.py @@ -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) diff --git a/cratonclient/v1/projects.py b/cratonclient/v1/projects.py new file mode 100644 index 0000000..0df920b --- /dev/null +++ b/cratonclient/v1/projects.py @@ -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' +}