Resize columns to fit screen width
Many outputs from python-openstackclient will exceed the width of the user's terminal. When this happens even by one character, the whole PrettyTable layout becomes very difficult to read. This change adds a utils.terminal_width method and uses that to calculate optimal max_width values to fit the table into the available width. Notes on the utils.terminal_width implementation: - Determining width by consuming the COLUMNS environment variable is avoided so that existing scripts which pipe output for value parsing continue to behave deterministically. - python3 has support for getting the terminal size, so this is used when available - The python2 fallback uses the same linux tty detection found in many python recipes - None is returned when width cannot be determined, such as - Not a tty, because the command is being piped to other commands, or it is being run in a non-interactive environment - A python2 environment running on Windows or OSX (these can always be added later by engineers who have access to these environments) The previous column width behaviour is retained in the following scenarios: - utils.terminal_width returns None for any of the reasons stated above - --max-width is specified Closes-Bug: #1485847 Change-Id: I51b6157929f0b4a9ce66990e3e64ce9e730862c2
This commit is contained in:
parent
25cce67529
commit
e7c3c62275
@ -4,6 +4,7 @@
|
||||
import prettytable
|
||||
import six
|
||||
|
||||
from cliff import utils
|
||||
from .base import ListFormatter, SingleFormatter
|
||||
|
||||
|
||||
@ -35,8 +36,6 @@ class TableFormatter(ListFormatter, SingleFormatter):
|
||||
print_empty=False,
|
||||
)
|
||||
x.padding_width = 1
|
||||
if parsed_args.max_width > 0:
|
||||
x.max_width = int(parsed_args.max_width)
|
||||
# Figure out the types of the columns in the
|
||||
# first row and set the alignment of the
|
||||
# output accordingly.
|
||||
@ -56,7 +55,15 @@ class TableFormatter(ListFormatter, SingleFormatter):
|
||||
if isinstance(r, six.string_types) else r
|
||||
for r in row]
|
||||
x.add_row(row)
|
||||
formatted = x.get_string(fields=column_names)
|
||||
|
||||
# Choose a reasonable min_width to better handle many columns on a
|
||||
# narrow console. The table will overflow the console width in
|
||||
# preference to wrapping columns smaller than 8 characters.
|
||||
min_width = 8
|
||||
self._assign_max_widths(
|
||||
stdout, x, int(parsed_args.max_width), min_width)
|
||||
|
||||
formatted = x.get_string()
|
||||
stdout.write(formatted)
|
||||
stdout.write('\n')
|
||||
return
|
||||
@ -65,8 +72,6 @@ class TableFormatter(ListFormatter, SingleFormatter):
|
||||
x = prettytable.PrettyTable(field_names=('Field', 'Value'),
|
||||
print_empty=False)
|
||||
x.padding_width = 1
|
||||
if parsed_args.max_width > 0:
|
||||
x.max_width = int(parsed_args.max_width)
|
||||
# Align all columns left because the values are
|
||||
# not all the same type.
|
||||
x.align['Field'] = 'l'
|
||||
@ -75,7 +80,89 @@ class TableFormatter(ListFormatter, SingleFormatter):
|
||||
value = (value.replace('\r\n', '\n').replace('\r', ' ') if
|
||||
isinstance(value, six.string_types) else value)
|
||||
x.add_row((name, value))
|
||||
formatted = x.get_string(fields=('Field', 'Value'))
|
||||
|
||||
# Choose a reasonable min_width to better handle a narrow
|
||||
# console. The table will overflow the console width in preference
|
||||
# to wrapping columns smaller than 16 characters in an attempt to keep
|
||||
# the Field column readable.
|
||||
min_width = 16
|
||||
self._assign_max_widths(
|
||||
stdout, x, int(parsed_args.max_width), min_width)
|
||||
|
||||
formatted = x.get_string()
|
||||
stdout.write(formatted)
|
||||
stdout.write('\n')
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _field_widths(field_names, first_line):
|
||||
|
||||
# use the first line +----+-------+ to infer column widths
|
||||
# accounting for padding and dividers
|
||||
widths = [max(0, len(i) - 2) for i in first_line.split('+')[1:-1]]
|
||||
return dict(zip(field_names, widths))
|
||||
|
||||
@staticmethod
|
||||
def _width_info(term_width, field_count):
|
||||
# remove padding and dividers for width available to actual content
|
||||
usable_total_width = max(0, term_width - 1 - 3 * field_count)
|
||||
|
||||
# calculate width per column if all columns were equal
|
||||
if field_count == 0:
|
||||
optimal_width = 0
|
||||
else:
|
||||
optimal_width = max(0, usable_total_width // field_count)
|
||||
|
||||
return usable_total_width, optimal_width
|
||||
|
||||
@staticmethod
|
||||
def _build_shrink_fields(usable_total_width, optimal_width,
|
||||
field_widths, field_names):
|
||||
shrink_fields = []
|
||||
shrink_remaining = usable_total_width
|
||||
for field in field_names:
|
||||
w = field_widths[field]
|
||||
if w <= optimal_width:
|
||||
# leave alone columns which are smaller than the optimal width
|
||||
shrink_remaining -= w
|
||||
else:
|
||||
shrink_fields.append(field)
|
||||
|
||||
return shrink_fields, shrink_remaining
|
||||
|
||||
@staticmethod
|
||||
def _assign_max_widths(stdout, x, max_width, min_width=0):
|
||||
if min_width:
|
||||
x.min_width = min_width
|
||||
|
||||
if max_width > 0:
|
||||
x.max_width = max_width
|
||||
return
|
||||
|
||||
term_width = utils.terminal_width(stdout)
|
||||
if not term_width:
|
||||
# not a tty, so do not set any max widths
|
||||
return
|
||||
field_count = len(x.field_names)
|
||||
|
||||
first_line = x.get_string().splitlines()[0]
|
||||
if len(first_line) <= term_width:
|
||||
return
|
||||
|
||||
usable_total_width, optimal_width = TableFormatter._width_info(
|
||||
term_width, field_count)
|
||||
|
||||
field_widths = TableFormatter._field_widths(x.field_names, first_line)
|
||||
|
||||
shrink_fields, shrink_remaining = TableFormatter._build_shrink_fields(
|
||||
usable_total_width, optimal_width, field_widths, x.field_names)
|
||||
|
||||
shrink_to = shrink_remaining // len(shrink_fields)
|
||||
# make all shrinkable fields size shrink_to apart from the last one
|
||||
for field in shrink_fields[:-1]:
|
||||
x.max_width[field] = max(min_width, shrink_to)
|
||||
shrink_remaining -= shrink_to
|
||||
|
||||
# give the last shrinkable column shrink_to plus any remaining
|
||||
field = shrink_fields[-1]
|
||||
x.max_width[field] = max(min_width, shrink_remaining)
|
||||
|
@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import mock
|
||||
from six import StringIO
|
||||
|
||||
from cliff.formatters import table
|
||||
@ -9,7 +11,9 @@ class args(object):
|
||||
self.max_width = max_width
|
||||
|
||||
|
||||
def test_table_formatter():
|
||||
@mock.patch('cliff.utils.terminal_width')
|
||||
def test_table_formatter(tw):
|
||||
tw.return_value = 80
|
||||
sf = table.TableFormatter()
|
||||
c = ('a', 'b', 'c', 'd')
|
||||
d = ('A', 'B', 'C', 'test\rcarriage\r\nreturn')
|
||||
@ -31,7 +35,83 @@ def test_table_formatter():
|
||||
assert expected == actual
|
||||
|
||||
|
||||
def test_table_list_formatter():
|
||||
@mock.patch('cliff.utils.terminal_width')
|
||||
def test_table_formatter_max_width(tw):
|
||||
tw.return_value = 80
|
||||
sf = table.TableFormatter()
|
||||
c = ('field_name', 'a_really_long_field_name')
|
||||
d = ('the value', 'a value significantly longer than the field')
|
||||
expected = '''\
|
||||
+--------------------------+---------------------------------------------+
|
||||
| Field | Value |
|
||||
+--------------------------+---------------------------------------------+
|
||||
| field_name | the value |
|
||||
| a_really_long_field_name | a value significantly longer than the field |
|
||||
+--------------------------+---------------------------------------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
parsed_args = args()
|
||||
sf.emit_one(c, d, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
|
||||
# resize value column
|
||||
tw.return_value = 70
|
||||
expected = '''\
|
||||
+--------------------------+-----------------------------------------+
|
||||
| Field | Value |
|
||||
+--------------------------+-----------------------------------------+
|
||||
| field_name | the value |
|
||||
| a_really_long_field_name | a value significantly longer than the |
|
||||
| | field |
|
||||
+--------------------------+-----------------------------------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
parsed_args = args()
|
||||
sf.emit_one(c, d, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
|
||||
# resize both columns
|
||||
tw.return_value = 50
|
||||
expected = '''\
|
||||
+-----------------------+------------------------+
|
||||
| Field | Value |
|
||||
+-----------------------+------------------------+
|
||||
| field_name | the value |
|
||||
| a_really_long_field_n | a value significantly |
|
||||
| ame | longer than the field |
|
||||
+-----------------------+------------------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
parsed_args = args()
|
||||
sf.emit_one(c, d, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
|
||||
# resize all columns limited by min_width=16
|
||||
tw.return_value = 10
|
||||
expected = '''\
|
||||
+------------------+------------------+
|
||||
| Field | Value |
|
||||
+------------------+------------------+
|
||||
| field_name | the value |
|
||||
| a_really_long_fi | a value |
|
||||
| eld_name | significantly |
|
||||
| | longer than the |
|
||||
| | field |
|
||||
+------------------+------------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
parsed_args = args()
|
||||
sf.emit_one(c, d, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
|
||||
|
||||
@mock.patch('cliff.utils.terminal_width')
|
||||
def test_table_list_formatter(tw):
|
||||
tw.return_value = 80
|
||||
sf = table.TableFormatter()
|
||||
c = ('a', 'b', 'c')
|
||||
d1 = ('A', 'B', 'C')
|
||||
@ -51,3 +131,128 @@ def test_table_list_formatter():
|
||||
sf.emit_list(c, data, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
|
||||
|
||||
@mock.patch('cliff.utils.terminal_width')
|
||||
def test_table_list_formatter_max_width(tw):
|
||||
tw.return_value = 80
|
||||
sf = table.TableFormatter()
|
||||
c = ('one', 'two', 'three')
|
||||
d1 = (
|
||||
'one one one one one',
|
||||
'two two two two',
|
||||
'three three')
|
||||
data = [d1]
|
||||
parsed_args = args()
|
||||
|
||||
# no resize
|
||||
expected = '''\
|
||||
+---------------------+-----------------+-------------+
|
||||
| one | two | three |
|
||||
+---------------------+-----------------+-------------+
|
||||
| one one one one one | two two two two | three three |
|
||||
+---------------------+-----------------+-------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
sf.emit_list(c, data, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
|
||||
# resize 1 column
|
||||
tw.return_value = 50
|
||||
expected = '''\
|
||||
+----------------+-----------------+-------------+
|
||||
| one | two | three |
|
||||
+----------------+-----------------+-------------+
|
||||
| one one one | two two two two | three three |
|
||||
| one one | | |
|
||||
+----------------+-----------------+-------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
sf.emit_list(c, data, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
assert len(actual.splitlines()[0]) == 50
|
||||
|
||||
# resize 2 columns
|
||||
tw.return_value = 45
|
||||
expected = '''\
|
||||
+--------------+--------------+-------------+
|
||||
| one | two | three |
|
||||
+--------------+--------------+-------------+
|
||||
| one one one | two two two | three three |
|
||||
| one one | two | |
|
||||
+--------------+--------------+-------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
sf.emit_list(c, data, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
assert len(actual.splitlines()[0]) == 45
|
||||
|
||||
# resize all columns
|
||||
tw.return_value = 40
|
||||
expected = '''\
|
||||
+------------+------------+------------+
|
||||
| one | two | three |
|
||||
+------------+------------+------------+
|
||||
| one one | two two | three |
|
||||
| one one | two two | three |
|
||||
| one | | |
|
||||
+------------+------------+------------+
|
||||
'''
|
||||
output = StringIO()
|
||||
sf.emit_list(c, data, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
assert len(actual.splitlines()[0]) == 40
|
||||
|
||||
# resize all columns limited by min_width=8
|
||||
tw.return_value = 10
|
||||
expected = '''\
|
||||
+----------+----------+----------+
|
||||
| one | two | three |
|
||||
+----------+----------+----------+
|
||||
| one one | two two | three |
|
||||
| one one | two two | three |
|
||||
| one | | |
|
||||
+----------+----------+----------+
|
||||
'''
|
||||
output = StringIO()
|
||||
sf.emit_list(c, data, output, parsed_args)
|
||||
actual = output.getvalue()
|
||||
assert expected == actual
|
||||
# 3 columns each 8 wide, plus table spacing and borders
|
||||
expected_width = 11 * 3 + 1
|
||||
assert len(actual.splitlines()[0]) == expected_width
|
||||
|
||||
|
||||
def test_field_widths():
|
||||
tf = table.TableFormatter
|
||||
assert {
|
||||
'a': 1,
|
||||
'b': 2,
|
||||
'c': 3,
|
||||
'd': 10
|
||||
} == tf._field_widths(
|
||||
('a', 'b', 'c', 'd'),
|
||||
'+---+----+-----+------------+')
|
||||
|
||||
|
||||
def test_field_widths_zero():
|
||||
tf = table.TableFormatter
|
||||
assert {
|
||||
'a': 0,
|
||||
'b': 0,
|
||||
'c': 0
|
||||
} == tf._field_widths(
|
||||
('a', 'b', 'c'),
|
||||
'+--+-++')
|
||||
|
||||
|
||||
def test_width_info():
|
||||
tf = table.TableFormatter
|
||||
assert (49, 4) == (tf._width_info(80, 10))
|
||||
assert (76, 76) == (tf._width_info(80, 1))
|
||||
assert (79, 0) == (tf._width_info(80, 0))
|
||||
assert (0, 0) == (tf._width_info(0, 80))
|
||||
|
44
cliff/tests/test_utils.py
Normal file
44
cliff/tests/test_utils.py
Normal file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import mock
|
||||
import nose
|
||||
|
||||
from cliff import utils
|
||||
|
||||
|
||||
def test_utils_terminal_width():
|
||||
width = utils.terminal_width(sys.stdout)
|
||||
# Results are specific to the execution environment, so only assert
|
||||
# that no error is raised.
|
||||
assert width is None or isinstance(width, int)
|
||||
|
||||
|
||||
@mock.patch('cliff.utils.os')
|
||||
def test_utils_terminal_width_get_terminal_size(mock_os):
|
||||
if not hasattr(os, 'get_terminal_size'):
|
||||
raise nose.SkipTest('only needed for python 3.3 onwards')
|
||||
ts = os.terminal_size((10, 5))
|
||||
mock_os.get_terminal_size.return_value = ts
|
||||
width = utils.terminal_width(sys.stdout)
|
||||
assert width == 10
|
||||
|
||||
mock_os.get_terminal_size.side_effect = OSError()
|
||||
width = utils.terminal_width(sys.stdout)
|
||||
assert width is None
|
||||
|
||||
|
||||
@mock.patch('cliff.utils.ioctl')
|
||||
def test_utils_terminal_width_ioctl(mock_ioctl):
|
||||
if hasattr(os, 'get_terminal_size'):
|
||||
raise nose.SkipTest('only needed for python 3.2 and before')
|
||||
mock_ioctl.return_value = struct.pack('hhhh', 57, 101, 0, 0)
|
||||
width = utils.terminal_width(sys.stdout)
|
||||
assert width == 101
|
||||
|
||||
mock_ioctl.side_effect = IOError()
|
||||
width = utils.terminal_width(sys.stdout)
|
||||
assert width is None
|
@ -11,6 +11,11 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from fcntl import ioctl
|
||||
import os
|
||||
import struct
|
||||
import termios
|
||||
|
||||
# Each edit operation is assigned different cost, such as:
|
||||
# 'w' means swap operation, the cost is 0;
|
||||
# 's' means substitution operation, the cost is 2;
|
||||
@ -86,3 +91,30 @@ def damerau_levenshtein(s1, s2, cost):
|
||||
row0, row1, row2 = row1, row2, row0
|
||||
|
||||
return row1[-1]
|
||||
|
||||
|
||||
def terminal_width(stdout):
|
||||
if hasattr(os, 'get_terminal_size'):
|
||||
# python 3.3 onwards has built-in support for getting terminal size
|
||||
try:
|
||||
return os.get_terminal_size().columns
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
# winsize structure has 4 unsigned short fields
|
||||
winsize = b'\0' * struct.calcsize('hhhh')
|
||||
try:
|
||||
winsize = ioctl(stdout, termios.TIOCGWINSZ, winsize)
|
||||
except IOError:
|
||||
return None
|
||||
except TypeError:
|
||||
# this is raised in unit tests as stdout is sometimes a StringIO
|
||||
return None
|
||||
winsize = struct.unpack('hhhh', winsize)
|
||||
columns = winsize[1]
|
||||
if not columns:
|
||||
return None
|
||||
return columns
|
||||
except IOError:
|
||||
return None
|
||||
|
Loading…
Reference in New Issue
Block a user