Advanced print formatting for CLI
Modelled mostly after how Heat does it, but with some added power and also consistency between print_list and print_dict. We have print_list for printing of lists and print_dict for printing of show actions. Both functions take as arguments (aside from the data to print) also an optional dict of custom formatters (allowing to freely format the attributes before printing) and custom labels (allowing to tweak table headers and property names). The list is also sorted by one of the columns. The formatting is showcased on sample formatters and actions - rack-show and rack-list. Fixes bug 1213056 Change-Id: Ic14dbb930a5967e2634c1b4777e6705ab2a370ec
This commit is contained in:
parent
add9e5da58
commit
ca01366bea
143
tuskarclient/common/formatting.py
Normal file
143
tuskarclient/common/formatting.py
Normal file
@ -0,0 +1,143 @@
|
||||
# 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.
|
||||
|
||||
import prettytable
|
||||
|
||||
|
||||
def pretty_choice_list(l):
|
||||
return ', '.join("'%s'" % i for i in l)
|
||||
|
||||
|
||||
def print_list(objs, fields, formatters={}, custom_labels={}, sortby=0):
|
||||
'''Prints a list of objects.
|
||||
|
||||
:param objs: list of objects to print
|
||||
:param fields: list of attributes of the objects to print;
|
||||
attributes beginning with '!' have a special meaning - they
|
||||
should be used with custom field labels and formatters only,
|
||||
and the formatter receives the whole object
|
||||
:param formatters: dict of functions that perform pre-print
|
||||
formatting of attributes (keys are strings from `fields`
|
||||
parameter, values are functions that take one parameter - the
|
||||
attribute)
|
||||
:param custom_labels: dict of label overrides for fields (keys are
|
||||
strings from `fields` parameter, values are custom labels -
|
||||
headers of the table)
|
||||
'''
|
||||
field_labels = [custom_labels.get(f, f) for f in fields]
|
||||
pt = prettytable.PrettyTable([f for f in field_labels],
|
||||
caching=False, print_empty=False)
|
||||
pt.align = 'l'
|
||||
|
||||
for o in objs:
|
||||
row = []
|
||||
for field in fields:
|
||||
if field[0] == '!': # custom field
|
||||
if field in formatters:
|
||||
row.append(formatters[field](o))
|
||||
else:
|
||||
raise KeyError(
|
||||
'Custom field "%s" needs a formatter.' % field)
|
||||
else: # attribute-based field
|
||||
if hasattr(o, field) and field in formatters:
|
||||
row.append(formatters[field](getattr(o, field)))
|
||||
else:
|
||||
row.append(getattr(o, field, ''))
|
||||
pt.add_row(row)
|
||||
print pt.get_string(sortby=field_labels[sortby])
|
||||
|
||||
|
||||
def print_dict(d, formatters={}, custom_labels={}):
|
||||
'''Prints a dict.
|
||||
|
||||
:param d: dict to print
|
||||
:param formatters: dict of functions that perform pre-print
|
||||
formatting of dict values (keys are keys from `d` parameter,
|
||||
values are functions that take one parameter - the dict value
|
||||
to format)
|
||||
:param custom_labels: dict of label overrides for keys (keys are
|
||||
keys from `d` parameter, values are custom labels)
|
||||
'''
|
||||
pt = prettytable.PrettyTable(['Property', 'Value'],
|
||||
caching=False, print_empty=False)
|
||||
pt.align = 'l'
|
||||
|
||||
for field in d.keys():
|
||||
label = custom_labels.get(field, field)
|
||||
if field in formatters:
|
||||
pt.add_row([label, formatters[field](d[field])])
|
||||
else:
|
||||
pt.add_row([label, d[field]])
|
||||
print pt.get_string(sortby='Property')
|
||||
|
||||
|
||||
def attr_proxy(attr, formatter=lambda a: a, allow_undefined=True):
|
||||
'''Creates a new formatter function. It will format an object for
|
||||
output by printing it's attribute or running another formatter on
|
||||
that attribute.
|
||||
|
||||
:param attr: name of the attribute to look for on an object
|
||||
:param formatter: formatter to run on that attribute (if not given,
|
||||
the attribute is returned as-is)
|
||||
:param allow_undefined: if true, the created function will return
|
||||
None if `attr` is not defined on the formatted object
|
||||
'''
|
||||
def formatter_proxy(obj):
|
||||
try:
|
||||
attr_value = getattr(obj, attr)
|
||||
except AttributeError as e:
|
||||
if allow_undefined:
|
||||
return None
|
||||
else:
|
||||
raise e
|
||||
return formatter(attr_value)
|
||||
|
||||
return formatter_proxy
|
||||
|
||||
|
||||
def capacities_formatter(capacities):
|
||||
'''Formats a list of capacities for output. Capacity is a dict
|
||||
containing 'name', 'value' and 'unit' keys.
|
||||
'''
|
||||
sorted_capacities = sorted(capacities,
|
||||
lambda c1, c2: cmp(c1['name'], c2['name']))
|
||||
return '\n'.join(['{0}: {1} {2}'.format(c['name'], c['value'], c['unit'])
|
||||
for c in sorted_capacities])
|
||||
|
||||
|
||||
def links_formatter(links):
|
||||
'''Formats a list of links. Link is a dict that has 'href' and
|
||||
'rel' keys.
|
||||
'''
|
||||
sorted_links = sorted(links, lambda l1, l2: cmp(l1['rel'], l2['rel']))
|
||||
return '\n'.join(['{0}: {1}'.format(l['rel'], l['href'])
|
||||
for l in sorted_links])
|
||||
|
||||
|
||||
def resource_links_formatter(links):
|
||||
'''Formats an array of resource links. Resource link is a dict
|
||||
with keys 'id' and 'links'. Under 'links' key there is an array of
|
||||
links. Link is a dict with 'href' and 'rel' keys. Currently we
|
||||
expect only one link to be in the array, so we print the first
|
||||
one. (We cannot fetch by 'rel', values in 'rel' are not used
|
||||
consistently.)
|
||||
'''
|
||||
sorted_links = sorted(links, lambda l1, l2: cmp(l1['id'], l2['id']))
|
||||
return '\n'.join(['{0}: {1}'.format(l['id'], l['links'][0]['href'])
|
||||
for l in sorted_links])
|
||||
|
||||
|
||||
def resource_link_formatter(link):
|
||||
'''Formats one resource link. See docs of
|
||||
`resource_links_formatter` for more details.
|
||||
'''
|
||||
return resource_links_formatter([link])
|
@ -15,11 +15,8 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
import uuid
|
||||
|
||||
import prettytable
|
||||
|
||||
from tuskarclient import exc
|
||||
from tuskarclient.openstack.common import importutils
|
||||
|
||||
@ -62,50 +59,6 @@ def arg(*args, **kwargs):
|
||||
return _decorator
|
||||
|
||||
|
||||
def pretty_choice_list(l):
|
||||
return ', '.join("'%s'" % i for i in l)
|
||||
|
||||
|
||||
def print_list(objs, fields, field_labels, formatters={}, sortby=0):
|
||||
pt = prettytable.PrettyTable([f for f in field_labels],
|
||||
caching=False, print_empty=False)
|
||||
pt.align = 'l'
|
||||
|
||||
for o in objs:
|
||||
row = []
|
||||
for field in fields:
|
||||
if field in formatters:
|
||||
row.append(formatters[field](o))
|
||||
else:
|
||||
data = getattr(o, field, '')
|
||||
row.append(data)
|
||||
pt.add_row(row)
|
||||
print pt.get_string(sortby=field_labels[sortby])
|
||||
|
||||
|
||||
def print_dict(d, dict_property="Property", wrap=0):
|
||||
pt = prettytable.PrettyTable([dict_property, 'Value'],
|
||||
caching=False, print_empty=False)
|
||||
pt.align = 'l'
|
||||
for k, v in d.iteritems():
|
||||
# convert dict to str to check length
|
||||
if isinstance(v, dict):
|
||||
v = str(v)
|
||||
if wrap > 0:
|
||||
v = textwrap.fill(str(v), wrap)
|
||||
# if value has a newline, add in multiple rows
|
||||
# e.g. fault with stacktrace
|
||||
if v and isinstance(v, basestring) and r'\n' in v:
|
||||
lines = v.strip().split(r'\n')
|
||||
col1 = k
|
||||
for line in lines:
|
||||
pt.add_row([col1, line])
|
||||
col1 = ''
|
||||
else:
|
||||
pt.add_row([k, v])
|
||||
print pt.get_string()
|
||||
|
||||
|
||||
def find_resource(manager, name_or_id):
|
||||
"""Helper for the _find_* methods."""
|
||||
# first try to get entity as integer id
|
||||
|
128
tuskarclient/tests/common/test_formatting.py
Normal file
128
tuskarclient/tests/common/test_formatting.py
Normal file
@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
|
||||
import cStringIO
|
||||
import mock
|
||||
|
||||
import tuskarclient.common.formatting as fmt
|
||||
import tuskarclient.tests.utils as tutils
|
||||
|
||||
|
||||
class PrintTest(tutils.TestCase):
|
||||
|
||||
@mock.patch('sys.stdout', new_callable=cStringIO.StringIO)
|
||||
def test_print_dict(self, mock_out):
|
||||
dict_ = {'k': 'v', 'key': 'value'}
|
||||
formatters = {'key': lambda v: 'custom ' + v}
|
||||
custom_labels = {'k': 'custom_key'}
|
||||
|
||||
fmt.print_dict(dict_, formatters, custom_labels)
|
||||
self.assertEqual(
|
||||
('+------------+--------------+\n'
|
||||
'| Property | Value |\n'
|
||||
'+------------+--------------+\n'
|
||||
'| custom_key | v |\n'
|
||||
'| key | custom value |\n'
|
||||
'+------------+--------------+\n'),
|
||||
mock_out.getvalue()
|
||||
)
|
||||
|
||||
@mock.patch('sys.stdout', new_callable=cStringIO.StringIO)
|
||||
def test_print_list(self, mock_out):
|
||||
fields = ['thing', 'color', '!artistic_name']
|
||||
formatters = {
|
||||
'!artistic_name': lambda obj: '{0} {1}'.format(obj.color,
|
||||
obj.thing),
|
||||
'color': lambda c: c.split(' ')[1],
|
||||
}
|
||||
custom_labels = {'thing': 'name', '!artistic_name': 'artistic name'}
|
||||
|
||||
fmt.print_list(self.objects(), fields, formatters, custom_labels)
|
||||
self.assertEqual(
|
||||
('+------+-------+-----------------+\n'
|
||||
'| name | color | artistic name |\n'
|
||||
'+------+-------+-----------------+\n'
|
||||
'| moon | green | dark green moon |\n'
|
||||
'| sun | blue | bright blue sun |\n'
|
||||
'+------+-------+-----------------+\n'),
|
||||
mock_out.getvalue()
|
||||
)
|
||||
|
||||
@mock.patch('sys.stdout', new_callable=cStringIO.StringIO)
|
||||
def test_print_list_custom_field_without_formatter(self, mock_out):
|
||||
fields = ['!artistic_name']
|
||||
|
||||
self.assertRaises(KeyError, fmt.print_list, self.objects(), fields)
|
||||
|
||||
def objects(self):
|
||||
return [
|
||||
mock.Mock(thing='sun', color='bright blue'),
|
||||
mock.Mock(thing='moon', color='dark green'),
|
||||
]
|
||||
|
||||
|
||||
class FormattersTest(tutils.TestCase):
|
||||
|
||||
def test_attr_formatter_plain(self):
|
||||
obj = mock.Mock()
|
||||
obj.foo = 'bar'
|
||||
foo_formatter = fmt.attr_proxy('foo')
|
||||
self.assertEqual('bar', foo_formatter(obj))
|
||||
|
||||
def test_attr_formatter_chained(self):
|
||||
obj = mock.Mock()
|
||||
obj.letters = ['a', 'b', 'c']
|
||||
letters_formatter = fmt.attr_proxy('letters', len)
|
||||
self.assertEqual(3, letters_formatter(obj))
|
||||
|
||||
def test_capacities_formatter(self):
|
||||
capacities = [
|
||||
{'name': 'memory', 'value': '1024', 'unit': 'MB'},
|
||||
{'name': 'cpu', 'value': '2', 'unit': 'CPU'},
|
||||
]
|
||||
self.assertEqual(
|
||||
('cpu: 2 CPU\n'
|
||||
'memory: 1024 MB'),
|
||||
fmt.capacities_formatter(capacities),
|
||||
)
|
||||
|
||||
def test_links_formatter(self):
|
||||
links = [
|
||||
{'rel': 'self', 'href': 'http://self-url'},
|
||||
{'rel': 'parent', 'href': 'http://parent-url'},
|
||||
]
|
||||
self.assertEqual(
|
||||
('parent: http://parent-url\n'
|
||||
'self: http://self-url'),
|
||||
fmt.links_formatter(links),
|
||||
)
|
||||
|
||||
def test_resource_links_formatter(self):
|
||||
resource_links = [
|
||||
{'id': 3, 'links': [{'rel': 'self', 'href': 'http://three'}]},
|
||||
{'id': 5, 'links': [{'rel': 'self', 'href': 'http://five'}]},
|
||||
]
|
||||
self.assertEqual(
|
||||
('3: http://three\n'
|
||||
'5: http://five'),
|
||||
fmt.resource_links_formatter(resource_links),
|
||||
)
|
||||
|
||||
def test_resource_link_formatter(self):
|
||||
resource_link = {
|
||||
'id': 3,
|
||||
'links': [{'rel': 'self', 'href': 'http://three'}]
|
||||
}
|
||||
self.assertEqual(
|
||||
('3: http://three'),
|
||||
fmt.resource_link_formatter(resource_link),
|
||||
)
|
@ -14,40 +14,12 @@
|
||||
# under the License.
|
||||
|
||||
|
||||
import cStringIO
|
||||
import mock
|
||||
import sys
|
||||
|
||||
from tuskarclient.common import utils
|
||||
from tuskarclient.tests import utils as test_utils
|
||||
|
||||
|
||||
class UtilsTest(test_utils.TestCase):
|
||||
|
||||
def test_prettytable(self):
|
||||
class Struct:
|
||||
def __init__(self, **entries):
|
||||
self.__dict__.update(entries)
|
||||
|
||||
# test that the prettytable output is wellformatted (left-aligned)
|
||||
saved_stdout = sys.stdout
|
||||
try:
|
||||
sys.stdout = output_dict = cStringIO.StringIO()
|
||||
utils.print_dict({'K': 'k', 'Key': 'Value'})
|
||||
|
||||
finally:
|
||||
sys.stdout = saved_stdout
|
||||
|
||||
self.assertEqual(output_dict.getvalue(), '''\
|
||||
+----------+-------+
|
||||
| Property | Value |
|
||||
+----------+-------+
|
||||
| K | k |
|
||||
| Key | Value |
|
||||
+----------+-------+
|
||||
''')
|
||||
|
||||
|
||||
class DefineCommandsTest(test_utils.TestCase):
|
||||
|
||||
def test_define_commands_from_module(self):
|
||||
|
@ -10,6 +10,7 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import tuskarclient.common.formatting as fmt
|
||||
from tuskarclient.common import utils
|
||||
from tuskarclient import exc
|
||||
|
||||
@ -21,15 +22,28 @@ def do_rack_show(tuskar, args):
|
||||
rack = utils.find_resource(tuskar.racks, args.id)
|
||||
except exc.HTTPNotFound:
|
||||
raise exc.CommandError("Rack not found: %s" % args.id)
|
||||
formatters = {
|
||||
'capacities': fmt.capacities_formatter,
|
||||
'chassis': fmt.resource_link_formatter,
|
||||
'links': fmt.links_formatter,
|
||||
'nodes': fmt.resource_links_formatter,
|
||||
'resource_class': fmt.resource_link_formatter,
|
||||
}
|
||||
|
||||
utils.print_dict(rack.to_dict())
|
||||
rack_dict = rack.to_dict()
|
||||
# Workaround for API inconsistency, where empty chassis link
|
||||
# prints out as '{}'.
|
||||
if 'chassis' in rack_dict and not rack_dict['chassis']:
|
||||
del rack_dict['chassis']
|
||||
|
||||
fmt.print_dict(rack_dict, formatters)
|
||||
|
||||
|
||||
# TODO(jistr): This is PoC, not final implementation
|
||||
def do_rack_list(tuskar, args):
|
||||
racks = tuskar.racks.list()
|
||||
fields = ['name', 'subnet', 'state', 'nodes']
|
||||
labels = ["Name", "Subnet", "State", "Nodes"]
|
||||
formatters = {'nodes': lambda rack: len(rack.nodes)}
|
||||
fields = ['id', 'name', 'subnet', 'state', 'nodes']
|
||||
labels = {'nodes': '# of nodes'}
|
||||
formatters = {'nodes': len}
|
||||
|
||||
utils.print_list(racks, fields, labels, formatters, 0)
|
||||
fmt.print_list(racks, fields, formatters, labels)
|
||||
|
Loading…
x
Reference in New Issue
Block a user