Repository: nirum/tableprint

Issue:
Auto-width
Love tableprint! 

Since everything is monospaced, it should be easy to set the width on each column automatically based on the longest string value of each column (and its header). Could you please implement this? It would save precious horizontal space and fit more columns.

Comments:

Patch:
diff --git a/README.md b/README.md
index a7d8fba..4aac2fe 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ with tp.TableContext("ABC") as t:
 Hosted at Read The Docs: [tableprint.readthedocs.org](http://tableprint.readthedocs.org)
 
 ## 📦 Dependencies
-- Python 3.6, 3.5, 3.4, or 2.7
+- Python 3.5+ or 2.7
 - [future](https://pypi.org/project/future/)
 - [six](https://pypi.org/project/six/)
 
@@ -67,6 +67,7 @@ Thanks to: [@nowox](https://github.com/nowox), [@nicktimko](https://github.com/n
 ## 🛠 Changelog
 | Version | Release Date | Description |
 |    ---: |      :---:   | :---        |
+| 0.9.0 | May 16 2020 | Adds support for automatically determining the table's width.
 | 0.8.0 | Oct 24 2017 | Improves support for international languages, removes numpy dependency
 | 0.7.0 | May 26 2017 | Adds a TableContext context manager for easy creation of dynamic tables (tables that update periodically). Adds the ability to pass a list or tuple of widths to specify different widths for different columns
 | 0.6.9 | May 25 2017 | Splitting the tableprint.py module into a pacakge with multiple files
diff --git a/tableprint/__init__.py b/tableprint/__init__.py
index 9ad1910..cd7fae9 100644
--- a/tableprint/__init__.py
+++ b/tableprint/__init__.py
@@ -1,7 +1,5 @@
 # -*- coding: utf-8 -*-
-"""
-Tableprint
-"""
+"""Tableprint."""
 from .metadata import __author__, __version__
 from .printer import *
 from .style import *
diff --git a/tableprint/metadata.py b/tableprint/metadata.py
index 6567af6..5505964 100644
--- a/tableprint/metadata.py
+++ b/tableprint/metadata.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 
 # Version info
-__version__ = '0.8.2'
+__version__ = '0.9.0'
 __license__ = 'MIT'
 
 # Project description(s)
diff --git a/tableprint/printer.py b/tableprint/printer.py
index 45f85b8..ac8d3ab 100644
--- a/tableprint/printer.py
+++ b/tableprint/printer.py
@@ -12,45 +12,50 @@ Usage
 """
 from __future__ import print_function, unicode_literals
 
-import sys
+from itertools import chain, starmap
 from numbers import Number
+import sys
 
 from six import string_types
 
 from .style import LineStyle, STYLES
-from .utils import ansi_len, format_line, parse_width
+from .utils import ansi_len, format_line, parse_width, max_width
 
 __all__ = ('table', 'header', 'row', 'hrule', 'top', 'bottom', 'banner', 'dataframe', 'TableContext')
 
 # Defaults
 STYLE = 'round'
-WIDTH = 11
 FMT = '5g'
 ALIGN = 'right'
 ALIGNMENTS = {"left": "<", "right": ">", "center": "^"}
 
 
 class TableContext:
-    def __init__(self, headers, width=WIDTH, align=ALIGN, style=STYLE, add_hr=True, out=sys.stdout):
+    def __init__(self, headers, width=11, align=ALIGN, style=STYLE, add_hr=True, out=sys.stdout):
         """Context manager for table printing
 
         Parameters
         ----------
-        headers : array_like
+        headers: array_like
             A list of N strings consisting of the header of each of the N columns
 
-        width : int or array_like, optional
+        width: int or array_like, optional
             The width of each column in the table (Default: 11)
 
-        align : string
+        align: str
             The alignment to use ('left', 'center', or 'right'). (Default: 'right')
 
-        style : string or tuple, optional
+        style: str or tuple, optional
             A formatting style. (Default: 'round')
 
-        add_hr : boolean, optional
+        add_hr: boolean, optional
             Whether or not to add a horizontal rule (hr) after the headers
 
+        out: IO writer, optional
+            Object used to manage IO (displaying the table). Must have a write() method
+            that takes a string argument, and a flush() method. See sys.stdout for an
+            example. (Default: 'sys.stdout')
+
         Usage
         -----
         >>> with TableContext("ABC") as t:
@@ -76,32 +81,41 @@ class TableContext:
         self.out.flush()
 
 
-def table(data, headers=None, format_spec=FMT, width=WIDTH, align=ALIGN, style=STYLE, out=sys.stdout):
+def table(data, headers=None, format_spec=FMT, width=None, align=ALIGN, style=STYLE, out=sys.stdout):
     """Print a table with the given data
 
     Parameters
     ----------
-    data : array_like
+    data: array_like
         An (m x n) array containing the data to print (m rows of n columns)
 
-    headers : list, optional
+    headers: list, optional
         A list of n strings consisting of the header of each of the n columns (Default: None)
 
-    format_spec : string, optional
+    format_spec: string, optional
         Format specification for formatting numbers (Default: '5g')
 
-    width : int or array_like, optional
-        The width of each column in the table (Default: 11)
+    width: int or None or array_like, optional
+        The width of each column in the table. If None, tries to estimate an appropriate width
+        based on the length of the data in the table. (Default: None)
 
-    align : string
+    align: string
         The alignment to use ('left', 'center', or 'right'). (Default: 'right')
 
-    style : string or tuple, optional
+    style: string or tuple, optional
         A formatting style. (Default: 'fancy_grid')
 
-    out : writer, optional
-        A file handle or object that has write() and flush() methods (Default: sys.stdout)
+    out: IO writer, optional
+        File handle or object used to manage IO (displaying the table). Must have a write()
+        method that takes a string argument, and a flush() method. See sys.stdout for an
+        example. (Default: 'sys.stdout')
     """
+    # Auto-width.
+    if width is None:
+      max_header_width = 0 if headers is None else max_width(headers, FMT)
+      max_data_width = max_width(chain(*data), format_spec)
+      width = max(max_header_width, max_data_width)
+
     # Number of columns in the table.
     ncols = len(data[0]) if headers is None else len(headers)
     tablestyle = STYLES[style]
@@ -123,18 +137,18 @@ def table(data, headers=None, format_spec=FMT, width=WIDTH, align=ALIGN, style=S
     out.flush()
 
 
-def header(headers, width=WIDTH, align=ALIGN, style=STYLE, add_hr=True):
+def header(headers, width=None, align=ALIGN, style=STYLE, add_hr=True):
     """Returns a formatted row of column header strings
 
     Parameters
     ----------
-    headers : list of strings
+    headers: list of strings
         A list of n strings, the column headers
 
-    width : int
-        The width of each column (Default: 11)
+    width: int
+        The width of each column. If None, automatically determines the width. (Default: None)
 
-    style : string or tuple, optional
+    style: string or tuple, optional
         A formatting style (see STYLES)
 
     Returns
@@ -142,6 +156,9 @@ def header(headers, width=WIDTH, align=ALIGN, style=STYLE, add_hr=True):
     headerstr : string
         A string consisting of the full header row to print
     """
+    if width is None:
+      width = max_width(headers, FMT)
+
     tablestyle = STYLES[style]
     widths = parse_width(width, len(headers))
     alignment = ALIGNMENTS[align]
@@ -160,31 +177,34 @@ def header(headers, width=WIDTH, align=ALIGN, style=STYLE, add_hr=True):
     return headerstr
 
 
-def row(values, width=WIDTH, format_spec=FMT, align=ALIGN, style=STYLE):
+def row(values, width=None, format_spec=FMT, align=ALIGN, style=STYLE):
     """Returns a formatted row of data
 
     Parameters
     ----------
-    values : array_like
+    values: array_like
         An iterable array of data (numbers or strings), each value is printed in a separate column
 
-    width : int
-        The width of each column (Default: 11)
+    width: int
+        The width of each column. If None, automatically determines the width. (Default: None)
 
-    format_spec : string
+    format_spec: string
         The precision format string used to format numbers in the values array (Default: '5g')
 
-    align : string
+    align: string
         The alignment to use ('left', 'center', or 'right'). (Default: 'right')
 
-    style : namedtuple, optional
+    style: namedtuple, optional
         A line formatting style
 
     Returns
     -------
-    rowstr : string
+    rowstr: string
         A string consisting of the full row of data to print
     """
+    if width is None:
+      width = max_width(values, format_spec)
+
     tablestyle = STYLES[style]
     widths = parse_width(width, len(values))
 
@@ -195,39 +215,34 @@ def row(values, width=WIDTH, format_spec=FMT, align=ALIGN, style=STYLE):
         format_spec = [format_spec] * len(list(values))
 
     # mapping function for string formatting
-    def mapdata(val):
-
-        # unpack
-        width, datum, prec = val
-
+    def mapdata(width, datum, prec):
+        """Formats an individual piece of data."""
         if isinstance(datum, string_types):
             return ('{:%s%i}' % (ALIGNMENTS[align], width + ansi_len(datum))).format(datum)
-
         elif isinstance(datum, Number):
             return ('{:%s%i.%s}' % (ALIGNMENTS[align], width, prec)).format(datum)
-
         else:
             raise ValueError('Elements in the values array must be strings, ints, or floats')
 
     # string formatter
-    data = map(mapdata, zip(widths, values, format_spec))
+    data = starmap(mapdata, zip(widths, values, format_spec))
 
     # build the row string
     return format_line(data, tablestyle.row)
 
 
-def hrule(n=1, width=WIDTH, linestyle=LineStyle('', '─', '─', '')):
+def hrule(n=1, width=11, linestyle=LineStyle('', '─', '─', '')):
     """Returns a formatted string used as a border between table rows
 
     Parameters
     ----------
-    n : int
+    n: int
         The number of columns in the table
 
-    width : int
+    width: int
         The width of each column (Default: 11)
 
-    linestyle : tuple
+    linestyle: tuple
         A LineStyle namedtuple containing the characters for (begin, hr, sep, end).
         (Default: ('', '─', '─', ''))
 
@@ -242,12 +257,12 @@ def hrule(n=1, width=WIDTH, linestyle=LineStyle('', '─', '─', '')):
     return linestyle.begin + hrstr + linestyle.end
 
 
-def top(n, width=WIDTH, style=STYLE):
+def top(n, width=11, style=STYLE):
     """Prints the top row of a table"""
     return hrule(n, width, linestyle=STYLES[style].top)
 
 
-def bottom(n, width=WIDTH, style=STYLE):
+def bottom(n, width=11, style=STYLE):
     """Prints the top row of a table"""
     return hrule(n, width, linestyle=STYLES[style].bottom)
 
@@ -257,16 +272,16 @@ def banner(message, width=30, style='banner', out=sys.stdout):
 
     Parameters
     ----------
-    message : string
+    message: string
         The message to print in the banner
 
-    width : int
+    width: int
         The minimum width of the banner (Default: 30)
 
-    style : string
+    style: string
         A line formatting style (Default: 'banner')
 
-    out : writer
+    out: writer
         An object that has write() and flush() methods (Default: sys.stdout)
     """
     out.write(header([message], width=max(width, len(message)), style=style) + '\n')
@@ -278,7 +293,7 @@ def dataframe(df, **kwargs):
 
     Parameters
     ----------
-    df : DataFrame
+    df: DataFrame
         A pandas DataFrame with the table to print
     """
     table(df.values, list(df.columns), **kwargs)
diff --git a/tableprint/utils.py b/tableprint/utils.py
index b4ed756..5f50636 100644
--- a/tableprint/utils.py
+++ b/tableprint/utils.py
@@ -1,12 +1,16 @@
 # -*- coding: utf-8 -*-
-"""
-Tableprint utilities
-"""
+"""Tableprint utilities."""
+
 from __future__ import print_function, unicode_literals
-from wcwidth import wcswidth
+
+from functools import reduce
 import math
 import re
 
+from numbers import Number
+from six import string_types
+from wcwidth import wcswidth
+
 __all__ = ('humantime',)
 
 
@@ -78,7 +82,7 @@ def format_line(data, linestyle):
 
 
 def parse_width(width, n):
-    """Parses an int or array of widths
+    """Parses an int or array of widths.
 
     Parameters
     ----------
@@ -89,7 +93,22 @@ def parse_width(width, n):
         widths = [width] * n
 
     else:
-        assert len(width) == n, "Widths and data do not match"
+        assert len(width) == n, "Widths and data do not match."
         widths = width
 
     return widths
+
+
+def max_width(data, format_spec):
+    """Computes the maximum formatted width of an iterable of data."""
+
+    def compute_width(d):
+        """Computes the formatted width of single element."""
+        if isinstance(d, string_types):
+          return len(d)
+        if isinstance(d, Number):
+          return len(('{:0.%s}' % format_spec).format(d))
+        else:
+            raise ValueError('Elements in the values array must be strings, ints, or floats')
+
+    return reduce(max, map(compute_width, data))


Test patch:
diff --git a/tests/test_functions.py b/tests/test_functions.py
index 7b24178..694929d 100644
--- a/tests/test_functions.py
+++ b/tests/test_functions.py
@@ -5,7 +5,7 @@ import pytest
 
 
 def test_borders():
-    """Tests printing of the top and bottom borders"""
+    """Tests printing of the top and bottom borders."""
 
     # top
     assert top(5, width=2, style='round') == '╭────┬────┬────┬────┬────╮'
@@ -17,7 +17,7 @@ def test_borders():
 
 
 def test_row():
-    """Tests printing of a single row of data"""
+    """Tests printing of a single row of data."""
 
     # valid
     assert row("abc", width=3, style='round') == '│   a │   b │   c │'
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 92e3cc9..20d84ac 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,11 +1,26 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
+import numpy as np
 from tableprint import humantime, LineStyle
-from tableprint.utils import format_line
+from tableprint.utils import format_line, max_width
 import pytest
 
 
+def test_max_width():
+    """Tests the auto width feature"""
+
+    # Max width for strings.
+    assert max_width(['a', 'b', 'c'], None) == 1
+    assert max_width(['a', 'b', 'foo'], None) == 3
+
+    # Max width for numbers.
+    assert max_width([1, 2, 3], '0f') == 1
+    assert max_width([1, 2, 3], '2f') == 4
+    assert max_width([np.pi, np.pi], '3f') == 5
+
+
 def test_format_line():
+    """Tests line formatting"""
 
     # using ASCII
     assert format_line(['foo', 'bar'], LineStyle('(', '_', '+', ')')) == '(foo+bar)'
@@ -19,6 +34,7 @@ def test_format_line():
 
 
 def test_humantime():
+    """Tests the humantime utility"""
 
     # test numeric input
     assert humantime(1e6) == u'1 weeks, 4 days, 13 hours, 46 min., 40 s'