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

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