diff --git a/cratonclient/formatters/__init__.py b/cratonclient/formatters/__init__.py new file mode 100644 index 0000000..51a1b17 --- /dev/null +++ b/cratonclient/formatters/__init__.py @@ -0,0 +1,12 @@ +# 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. +"""Module containing built-in formatters for cratonclient CLI.""" diff --git a/cratonclient/formatters/base.py b/cratonclient/formatters/base.py new file mode 100644 index 0000000..62dec88 --- /dev/null +++ b/cratonclient/formatters/base.py @@ -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. +"""Base class implementation for formatting plugins.""" +from cratonclient import crud + + +class Formatter(object): + """Class that defines the formatter interface. + + Instead of having to override and call up to this class's ``__init__`` + method, we also provide an ``after_init`` method that can be implemented + to extend what happens on initialization. + + .. attribute:: args + + Parsed command-line arguments stored as an instance of + :class:`argparse.Namespace`. + + """ + + def __init__(self, parsed_args): + """Instantiate our formatter with the parsed CLI arguments. + + :param parsed_args: + The CLI arguments parsed by :mod:`argparse`. + :type parsed_args: + argparse.Namespace + """ + self.args = parsed_args + self.after_init() + + def after_init(self): + """Initialize the object further after ``__init__``.""" + pass + + def configure(self, *args, **kwargs): + """Optional configuration of the plugin after instantiation.""" + return self + + def handle(self, item_to_format): + """Handle a returned item from the cratonclient API. + + cratonclient's API produces both single Resource objects as well as + generators of those objects. This method should be capable of handling + both. + + Based on the type, this will either call ``handle_generator`` or + ``handle_instance``. Subclasses must implement both of those methods. + + :returns: + None + :rtype: + None + :raises ValueError: + If the item provided is not a subclass of + :class:`~cratonclient.crud.Resource` or an iterable class then + we will not know how to handle it. In that case, we will raise a + ValueError. + """ + if isinstance(item_to_format, crud.Resource): + self.handle_instance(item_to_format) + return + + try: + self.handle_generator(item_to_format) + except TypeError as err: + raise ValueError( + "Expected an iterable object but instead received something " + "of type: %s. Received a TypeError: %s" % ( + type(item_to_format), + err + ) + ) + + def handle_instance(self, instance): + """Format and print the instance provided. + + :param instance: + The instance retrieved from the API that needs to be formatted. + :type instance: + cratonclient.crud.Resource + """ + raise NotImplementedError( + "A formatter plugin subclassed Formatter but did not implement" + " the handle_instance method." + ) + + def handle_generator(self, generator): + """Format and print the instance provided. + + :param generator: + The generator retrieved from the API whose items need to be + formatted. + """ + raise NotImplementedError( + "A formatter plugin subclassed Formatter but did not implement" + " the handle_generator method." + ) diff --git a/cratonclient/formatters/json_format.py b/cratonclient/formatters/json_format.py new file mode 100644 index 0000000..f9bbdfe --- /dev/null +++ b/cratonclient/formatters/json_format.py @@ -0,0 +1,72 @@ +# 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. +"""JSON formatter implementation for the craton CLI.""" +from __future__ import print_function + +import json + +from cratonclient.formatters import base + + +class Formatter(base.Formatter): + """JSON output formatter for the CLI.""" + + def after_init(self): + """Set-up our defaults. + + At some point in the future, we may allow people to configure this via + the CLI. + """ + self.indent = 4 + self.sort_keys = True + + def format(self, dictionary): + """Return the dictionary as a JSON string.""" + return json.dumps( + dictionary, + sort_keys=self.sort_keys, + indent=self.indent, + ) + + def handle_instance(self, instance): + """Print the JSON representation of a single instance.""" + print(self.format(instance.to_dict())) + + def handle_generator(self, generator): + """Print the JSON representation of a collection.""" + # NOTE(sigmavirus24): This is tricky logic that is caused by the JSON + # specification's intolerance for trailing commas. + try: + instance = next(generator) + except StopIteration: + # If there is nothing in the generator, we should just print an + # empty Array and then exit immediately. + print('[]') + return + + # Otherwise, let's print our opening bracket to start our Array + # formatting. + print('[', end='') + while True: + print(self.format(instance.to_dict()), end='') + # After printing our instance as a JSON object, we need to + # decide if we have another object to print. If we do have + # another object to print, we need to print a comma to separate + # our previous object and our next one. If we don't, we exit our + # loop to print our closing Array bracket. + try: + instance = next(generator) + except StopIteration: + break + else: + print(', ', end='') + print(']') diff --git a/cratonclient/formatters/table.py b/cratonclient/formatters/table.py new file mode 100644 index 0000000..4dc1190 --- /dev/null +++ b/cratonclient/formatters/table.py @@ -0,0 +1,180 @@ +# 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. +"""Pretty-table formatter implementation for the craton CLI.""" +from __future__ import print_function + +import textwrap + +from oslo_utils import encodeutils +import prettytable +import six + +from cratonclient.formatters import base + + +class Formatter(base.Formatter): + """Implementation of the default table-style formatter.""" + + def after_init(self): + """Set-up after initialization.""" + self.fields = [] + self.formatters = {} + self.sortby_index = 0 + self.mixed_case_fields = set([]) + self.field_labels = [] + self.dict_property = "Property" + self.wrap = 0 + self.dict_value = "Value" + + def configure(self, fields=None, formatters=None, sortby_index=False, + mixed_case_fields=None, field_labels=None, + dict_property=None, dict_value=None, wrap=None): + """Configure some of the settings used to print the tables. + + Parameters that configure list presentation: + + :param list fields: + List of field names as strings. + :param dict formatters: + Mapping of field names to formatter functions that accept the + resource. + :param int sortby_index: + The index of the field name in :param:`fields` to sort the table + rows by. If ``None``, PrettyTable will not sort the items at all. + :param list mixed_case_fields: + List of field names also in :param:`fields` that are mixed case + and need preprocessing prior to retrieving the attribute. + :param list field_labels: + List of field labels that need to match :param:`fields`. + + Parameters that configure the plain resource representation: + + :param str dict_property: + The name of the first column. + :param str dict_value: + The name of the second column. + :param int wrap: + Length at which to wrap the second column. + + All of these may be specified, but will be ignored based on how the + formatter is executed. + """ + if fields is not None: + self.fields = fields + if field_labels is None: + self.field_labels = self.fields + elif len(field_labels) != len(self.fields): + raise ValueError( + "Field labels list %(labels)s has different number " + "of elements than fields list %(fields)s" % + {'labels': field_labels, 'fields': fields} + ) + else: + self.field_labels = field_labels + + if formatters is not None: + self.formatters = formatters + + if sortby_index is not False: + try: + sortby_index = int(sortby_index) + except TypeError: + if sortby_index is not None: + raise ValueError( + 'sortby_index must be None or an integer' + ) + except ValueError: + raise + else: + if self.field_labels and ( + sortby_index < 0 or + sortby_index > len(self.field_labels)): + raise ValueError( + 'sortby_index must be a non-negative number less ' + 'than {}'.format(len(self.field_labels)) + ) + self.sortby_index = sortby_index + + if mixed_case_fields is not None: + self.mixed_case_fields = set(mixed_case_fields) + + if dict_property is not None: + self.dict_property = dict_property + + if dict_value is not None: + self.dict_value = dict_value + + if wrap is not None: + self.wrap = wrap + + return self + + def sortby_kwargs(self): + """Generate the sortby keyword argument for PrettyTable.""" + if self.sortby_index is None: + return {} + return {'sortby': self.field_labels[self.sortby_index]} + + def build_table(self, field_labels, alignment='l'): + """Create a PrettyTable instance based off of the labels.""" + table = prettytable.PrettyTable(field_labels) + table.align = alignment + return table + + def handle_generator(self, generator): + """Handle a generator of resources.""" + sortby_kwargs = self.sortby_kwargs() + table = self.build_table(self.field_labels) + + for resource in generator: + row = [] + for field in self.fields: + formatter = self.formatters.get(field) + if formatter is not None: + data = formatter(resource) + else: + if field in self.mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(resource, field_name, '') + row.append(data) + table.add_row(row) + + output = encodeutils.safe_encode(table.get_string(**sortby_kwargs)) + if six.PY3: + output = output.decode() + print(output) + + def handle_instance(self, instance): + """Handle a single resource.""" + table = self.build_table([self.dict_property, self.dict_value]) + + for key, value in sorted(instance.to_dict().items()): + if isinstance(value, dict): + value = six.text_type(value) + if self.wrap > 0: + value = textwrap.fill(six.text_type(value), self.wrap) + + if value and isinstance(value, six.string_types) and '\n' in value: + lines = value.strip().split('\n') + column1 = key + for line in lines: + table.add_row([column1, line]) + column1 = '' + else: + table.add_row([key, value]) + + output = encodeutils.safe_encode(table.get_string()) + if six.PY3: + output = output.decode() + print(output) diff --git a/cratonclient/shell/main.py b/cratonclient/shell/main.py index db11525..981cf0e 100644 --- a/cratonclient/shell/main.py +++ b/cratonclient/shell/main.py @@ -18,6 +18,7 @@ import sys from oslo_utils import encodeutils from oslo_utils import importutils +from stevedore import extension from cratonclient import __version__ from cratonclient import exceptions as exc @@ -27,11 +28,24 @@ from cratonclient.common import cliutils from cratonclient.v1 import client +FORMATTERS_NAMESPACE = 'cratonclient.formatters' + + class CratonShell(object): """Class used to handle shell definition and parsing.""" - @staticmethod - def get_base_parser(): + def __init__(self): + """Initialize our shell object. + + This sets up our formatters extension manager. If we add further + managers, they will be initialized here. + """ + self.extension_mgr = extension.ExtensionManager( + namespace=FORMATTERS_NAMESPACE, + invoke_on_load=False, + ) + + def get_base_parser(self): """Configure base craton arguments and parsing.""" parser = argparse.ArgumentParser( prog='craton', @@ -50,6 +64,13 @@ class CratonShell(object): action='version', version=__version__, ) + parser.add_argument('--format', + default='default', + choices=list(sorted(self.extension_mgr.names())), + help='The format to use to print the information ' + 'to the console. Defaults to pretty-printing ' + 'using ASCII tables.', + ) parser.add_argument('--craton-url', default=cliutils.env('CRATON_URL'), help='The base URL of the running Craton service.' @@ -150,6 +171,8 @@ class CratonShell(object): project_id=args.os_project_id, ) self.cc = client.Client(session, args.craton_url) + formatter_class = self.extension_mgr[args.format].plugin + args.formatter = formatter_class(args) args.func(self.cc, args) @cliutils.arg( diff --git a/cratonclient/tests/unit/formatters/__init__.py b/cratonclient/tests/unit/formatters/__init__.py new file mode 100644 index 0000000..4f79858 --- /dev/null +++ b/cratonclient/tests/unit/formatters/__init__.py @@ -0,0 +1,12 @@ +# 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. +"""Test module for the cratonclient.formatters submodule.""" diff --git a/cratonclient/tests/unit/formatters/base.py b/cratonclient/tests/unit/formatters/base.py new file mode 100644 index 0000000..c8d1718 --- /dev/null +++ b/cratonclient/tests/unit/formatters/base.py @@ -0,0 +1,74 @@ +# 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. +"""Base TestCase class for Formatter tests.""" +import argparse + +import mock +import six + +from cratonclient.tests import base + + +class FormatterTestCase(base.TestCase): + """Base-level Formatter TestCase class.""" + + def patch_stdout(self, autostart=True): + """Patch sys.stdout and capture all output to it. + + This will automatically start the patch by default. + + :param bool autostart: + Start patching sys.stdout immediately or delay that until later. + """ + self.stdout_patcher = mock.patch('sys.stdout', new=six.StringIO()) + if autostart: + self.stdout = self.stdout_patcher.start() + self.addCleanup(self.unpatch_stdout) + + def unpatch_stdout(self): + """Safely unpatch standard out.""" + if getattr(self.stdout_patcher, 'is_local', None) is not None: + self.stdout_patcher.stop() + + def stripped_stdout(self): + """Return the newline stripped standard-out captured string.""" + stdout = self.stdout.getvalue().rstrip('\n') + self.unpatch_stdout() + return stdout + + def args_for(self, **kwargs): + """Return an instantiated argparse Namsepace. + + Using the specified keyword arguments, create and return a Namespace + object from argparse for testing purposes. + :returns: + Instantiated namespace. + :rtype: + argparse.Namespace + """ + return argparse.Namespace(**kwargs) + + def resource_info(self, **kwargs): + """Return a dictionary with resource information. + + :returns: + Dictionary with basic id and name as well as the provided keyword + arguments. + :rtype: + dict + """ + info = { + 'id': 1, + 'name': 'Test Resource', + } + info.update(kwargs) + return info diff --git a/cratonclient/tests/unit/formatters/test_base_formatter.py b/cratonclient/tests/unit/formatters/test_base_formatter.py new file mode 100644 index 0000000..2dc56d9 --- /dev/null +++ b/cratonclient/tests/unit/formatters/test_base_formatter.py @@ -0,0 +1,63 @@ +# 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 base formatter class.""" +import argparse + +import mock + +from cratonclient import crud +from cratonclient.formatters import base +from cratonclient.tests import base as testbase + + +class TestBaseFormatterInstantiation(testbase.TestCase): + """Tests for cratonclient.formatters.base.Formatter creation.""" + + def test_instantiation_calls_after_init(self): + """Verify we call our postinitialization hook.""" + with mock.patch.object(base.Formatter, 'after_init') as after_init: + base.Formatter(mock.Mock()) + + after_init.assert_called_once_with() + + def test_stores_namespace_object(self): + """Verify we store our parsed CLI arguments.""" + namespace = argparse.Namespace() + formatter = base.Formatter(namespace) + self.assertIs(namespace, formatter.args) + + +class TestBaseFormatter(testbase.TestCase): + """Tests for cratonclient.formatters.base.Formatter behaviour.""" + + def setUp(self): + """Create test resources.""" + super(TestBaseFormatter, self).setUp() + self.formatter = base.Formatter(argparse.Namespace()) + + def test_handle_detects_resources(self): + """Verify we handle instances explicitly.""" + resource = crud.Resource(mock.Mock(), {}) + method = 'handle_instance' + with mock.patch.object(self.formatter, method) as handle_instance: + self.formatter.handle(resource) + + handle_instance.assert_called_once_with(resource) + + def test_handle_detects_iterables(self): + """Verify we handle generators explicitly.""" + method = 'handle_generator' + iterable = iter([]) + with mock.patch.object(self.formatter, method) as handle_generator: + self.formatter.handle(iterable) + + handle_generator.assert_called_once_with(iterable) diff --git a/cratonclient/tests/unit/formatters/test_json_formatter.py b/cratonclient/tests/unit/formatters/test_json_formatter.py new file mode 100644 index 0000000..e79bfa8 --- /dev/null +++ b/cratonclient/tests/unit/formatters/test_json_formatter.py @@ -0,0 +1,69 @@ +# 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. +"""JSON formatter unit tests.""" +import json + +import mock + +from cratonclient import crud +from cratonclient.formatters import json_format +from cratonclient.tests.unit.formatters import base + + +class TestValidFormatting(base.FormatterTestCase): + """Validate the JSON formatter's console output.""" + + def setUp(self): + """Initialize our instance prior to each test.""" + super(TestValidFormatting, self).setUp() + self.formatter = json_format.Formatter(self.args_for()) + self.patch_stdout() + + def load_json(self, stdout): + """Load JSON data from standard-out capture. + + If there's an error decoding the JSON output, fail the test + automatically. + """ + try: + return json.loads(stdout) + except ValueError as err: + self.fail('Encountered a ValueError: %s' % err) + + def test_instance_handling_creates_valid_json(self): + """Verify the printed data is valid JSON.""" + info = self.resource_info() + instance = crud.Resource(mock.Mock(), info, loaded=True) + self.formatter.handle(instance) + parsed = self.load_json(self.stripped_stdout()) + self.assertDictEqual(info, parsed) + + def test_empty_generator_handling(self): + """Verify we simply print an empty list.""" + self.formatter.handle(iter([])) + parsed = self.load_json(self.stripped_stdout()) + self.assertEqual([], parsed) + + def test_generator_of_a_single_resource(self): + """Verify we print the single list appropriately.""" + info = self.resource_info() + self.formatter.handle(iter([crud.Resource(mock.Mock(), info, True)])) + parsed = self.load_json(self.stripped_stdout()) + self.assertListEqual([info], parsed) + + def test_generator_of_more_than_one_resouurce(self): + """Verify we handle multiple items in a generator correctly.""" + info_dicts = [self.resource_info(id=i) for i in range(10)] + self.formatter.handle(crud.Resource(mock.Mock(), info, True) + for info in info_dicts) + parsed = self.load_json(self.stripped_stdout()) + self.assertListEqual(info_dicts, parsed) diff --git a/cratonclient/tests/unit/formatters/test_table_formatter.py b/cratonclient/tests/unit/formatters/test_table_formatter.py new file mode 100644 index 0000000..a4c5f23 --- /dev/null +++ b/cratonclient/tests/unit/formatters/test_table_formatter.py @@ -0,0 +1,228 @@ +# 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 pretty-table formatter.""" +import mock +import prettytable + +from cratonclient import crud +from cratonclient.formatters import table +from cratonclient.tests.unit.formatters import base + + +class TestTableFormatter(base.FormatterTestCase): + """Tests for cratonclient.formatters.table.Formatter.""" + + def setUp(self): + """Prepare test case for tests.""" + super(TestTableFormatter, self).setUp() + self.print_patcher = mock.patch('cratonclient.formatters.table.print') + self.formatter = table.Formatter(mock.Mock()) + + def test_initialization(self): + """Verify we set up defaults for our PrettyTable formatter.""" + self.assertEqual([], self.formatter.fields) + self.assertEqual({}, self.formatter.formatters) + self.assertEqual(0, self.formatter.sortby_index) + self.assertEqual(set([]), self.formatter.mixed_case_fields) + self.assertEqual([], self.formatter.field_labels) + self.assertEqual("Property", self.formatter.dict_property) + self.assertEqual("Value", self.formatter.dict_value) + self.assertEqual(0, self.formatter.wrap) + + # Case 0: "Everything" that isn't one of the special cases below + def test_configure(self): + """Verify we can configure our formatter. + + There are a few special pieces of logic. For the simpler cases, we can + just exercise those branches here. + """ + self.formatter.configure( + mixed_case_fields=['Foo', 'Bar'], + dict_property='Field', + dict_value='Stored Value', + wrap=72, + # NOTE(sigmavirus24): This value isn't accurate for formatters + formatters={'foo': 'bar'}, + ) + + self.assertEqual({'Foo', 'Bar'}, self.formatter.mixed_case_fields) + self.assertEqual('Field', self.formatter.dict_property) + self.assertEqual('Stored Value', self.formatter.dict_value) + self.assertEqual(72, self.formatter.wrap) + self.assertDictEqual({'foo': 'bar'}, self.formatter.formatters) + + # Assert defaults remain unchanged + self.assertEqual([], self.formatter.fields) + self.assertEqual([], self.formatter.field_labels) + self.assertEqual(0, self.formatter.sortby_index) + + # Case 1: Just fields + def test_configure_fields_only(self): + """Verify the logic for configuring fields.""" + self.formatter.configure(fields=['id', 'name']) + self.assertListEqual(['id', 'name'], self.formatter.fields) + self.assertListEqual(['id', 'name'], self.formatter.field_labels) + + # Case 2: fields + field_labels + def test_configure_fields_and_field_labels(self): + """Verify the behaviour for specifying both fields and field_labels. + + When we specify both arguments, we need to ensure they're the same + length. This demonstrates that we can specify different lists of the + same length and one won't override the other. + """ + self.formatter.configure(fields=['id', 'name'], + field_labels=['name', 'id']) + self.assertListEqual(['id', 'name'], self.formatter.fields) + self.assertListEqual(['name', 'id'], self.formatter.field_labels) + + # Case 3: fields + field_labels different length + def test_configure_incongruent_fields_and_field_labels(self): + """Verify we check the length of fields and field_labels.""" + self.assertRaises( + ValueError, + self.formatter.configure, + fields=['id', 'name', 'extra'], + field_labels=['id', 'name'], + ) + self.assertRaises( + ValueError, + self.formatter.configure, + fields=['id', 'name'], + field_labels=['id', 'name', 'extra'], + ) + + # Case 4: sortby_index is None + def test_configure_null_sortby_index(self): + """Verify we can configure sortby_index to be None. + + In this case, the user does not want the table rows sorted. + """ + self.formatter.configure(sortby_index=None) + self.assertIsNone(self.formatter.sortby_index) + + # Case 5: sortby_index is an integer + def test_configure_sortby_index_non_negative_int(self): + """Verify we can configure sortby_index with an int.""" + self.formatter.configure( + fields=['id', 'name'], + sortby_index=1, + ) + + self.assertEqual(1, self.formatter.sortby_index) + + # Case 6: sortby_index is a string of digits + def test_configure_sortby_index_int_str(self): + """Verify we can configure sortby_index with a str. + + It makes sense to also allow for strings of integers. This test + ensures that they come out as integers on the other side. + """ + self.formatter.configure( + fields=['id', 'name'], + sortby_index='1', + ) + + self.assertEqual(1, self.formatter.sortby_index) + + # Case 7: sortby_index is negative + def test_configure_sortby_index_negative_int(self): + """Verify we cannot configure sortby_index with a negative value. + + This will verify that we can neither pass negative integers nor + strings with negative integer values. + """ + self.assertRaises( + ValueError, + self.formatter.configure, + fields=['id', 'name'], + sortby_index='-1', + ) + self.assertRaises( + ValueError, + self.formatter.configure, + fields=['id', 'name'], + sortby_index='-1', + ) + + # Case 8: sortby_index exceeds length of self.field_labels + def test_configure_sortby_index_too_large_int(self): + """Verify we can not use an index larger than the labels.""" + self.assertRaises( + ValueError, + self.formatter.configure, + fields=['id', 'name'], + sortby_index=3, + ) + + def test_sortby_kwargs(self): + """Verify sortby_kwargs relies on sortby_index.""" + self.formatter.field_labels = ['id', 'created_at'] + self.assertDictEqual({'sortby': 'id'}, self.formatter.sortby_kwargs()) + + self.formatter.sortby_index = 1 + self.assertDictEqual({'sortby': 'created_at'}, + self.formatter.sortby_kwargs()) + + self.formatter.sortby_index = None + self.assertDictEqual({}, self.formatter.sortby_kwargs()) + + def test_build_table(self): + """Verify that we build our table and auto-align it.""" + table = self.formatter.build_table(['id', 'created_at']) + self.assertIsInstance(table, prettytable.PrettyTable) + self.assertDictEqual({'created_at': 'l', 'id': 'l'}, table.align) + + def test_build_table_with_labels(self): + """Verify we pass along our field labels to our table.""" + with mock.patch('prettytable.PrettyTable') as PrettyTable: + self.formatter.build_table(['id', 'created_at']) + + PrettyTable.assert_called_once_with(['id', 'created_at']) + + def test_handle_instance(self): + """Verify our handling of resource instances.""" + resource = crud.Resource(mock.Mock(), self.resource_info()) + self.print_ = self.print_patcher.start() + mocktable = mock.Mock() + mocktable.get_string.return_value = '' + with mock.patch('prettytable.PrettyTable') as PrettyTable: + PrettyTable.return_value = mocktable + self.formatter.handle_instance(resource) + self.print_patcher.stop() + + PrettyTable.assert_called_once_with(["Property", "Value"]) + self.assertListEqual([ + mock.call(['id', 1]), + mock.call(['name', 'Test Resource']), + ], mocktable.add_row.call_args_list) + self.print_.assert_called_once_with('') + + def test_handle_generator(self): + """Verify how we handle generators of instances.""" + info_list = [self.resource_info(id=i) for i in range(15)] + self.print_ = self.print_patcher.start() + mocktable = mock.Mock() + mocktable.get_string.return_value = '' + self.formatter.configure(fields=['id', 'Name']) + with mock.patch('prettytable.PrettyTable') as PrettyTable: + PrettyTable.return_value = mocktable + self.formatter.handle_generator(crud.Resource(mock.Mock(), info) + for info in info_list) + + PrettyTable.assert_called_once_with(['id', 'Name']) + self.assertListEqual( + [mock.call([i, 'Test Resource']) for i in range(15)], + mocktable.add_row.call_args_list, + ) + mocktable.get_string.assert_called_once_with(sortby='id') + self.print_.assert_called_once_with('') diff --git a/cratonclient/tests/unit/shell/test_main.py b/cratonclient/tests/unit/shell/test_main.py index f44d6d1..ef7e555 100644 --- a/cratonclient/tests/unit/shell/test_main.py +++ b/cratonclient/tests/unit/shell/test_main.py @@ -121,6 +121,10 @@ class TestCratonShell(base.TestCase): '--version', action='version', version=cratonclient.__version__, ), + mock.call( + '--format', default='default', choices=['default', 'json'], + help=mock.ANY, + ), mock.call( '--craton-url', default='', help='The base URL of the running Craton service. ' diff --git a/requirements.txt b/requirements.txt index b32b30b..4874245 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ oslo.utils>=3.18.0 # Apache-2.0 pbr>=1.8 # Apache-2.0 requests!=2.12.2,!=2.13.0,>=2.10.0 # Apache-2.0 keystoneauth1>=2.18.0 # Apache-2.0 +stevedore>=1.17.1 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index f36a76c..83615ea 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,9 @@ packages = [entry_points] console_scripts = craton = cratonclient.shell.main:main +cratonclient.formatters = + json = cratonclient.formatters.json_format:Formatter + default = cratonclient.formatters.table:Formatter [build_sphinx] source-dir = doc/source