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:
Steve Baker 2015-09-08 09:13:36 +12:00
parent 25cce67529
commit e7c3c62275
4 changed files with 376 additions and 8 deletions

View File

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

View File

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

View File

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