Add --format to the client shell

Add a new way of formatting our output in a consistent way. This turns
print_list and print_dict into a formatter that has the same API as any
other formatter and allows users to create their own formatters and plug
them into cratonclient.

This includes tests for the base level formatter and our two default
formatters as well as some refactoring to allow users to specify their
own --format.

At the moment, however, the subcommand shells do *not* use the pluggable
formatter decided by the user. That change and all of the downstream
effects it has on testing is going to be *very* significant and deserves
its own commit as this one is large enough.

Change-Id: I6649ebce57d5ddf2d4aeb689e77e3c17ef3a2e97
This commit is contained in:
Ian Cordasco
2017-02-23 14:57:33 -06:00
parent d41c87c725
commit d1a88bad18
13 changed files with 850 additions and 2 deletions

View File

@@ -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."""

View File

@@ -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."
)

View File

@@ -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(']')

View File

@@ -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)

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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('')

View File

@@ -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. '

View File

@@ -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

View File

@@ -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