Repository: astanin/python-tabulate
Pull request #125 (2021-05-16 09:57:59)

py37 dataclass support
Thanks for your awesome library.

This can help with https://github.com/astanin/python-tabulate/issues/46.

Behavioural difference from that `asdict()` solution: `asdict()` will try to convert nested dataclasses to dicts as well (creating many copies), and producing different outputs:

```py
>>> from dataclasses import dataclass, asdict
>>> from tabulate import tabulate

>>> @dataclass
... class Mydata:
...     @dataclass
...     class Yolo:
...         x: int
...         y: int
...     name: str
...     yolo: Yolo

>>> d = [Mydata('origin', Mydata.Yolo(0, 0)), Mydata('v', Mydata.Yolo(22, -18))]
>>> print(tabulate(map(asdict, d), headers='keys'))
name    yolo
------  -------------------
origin  {'x': 0, 'y': 0}
v       {'x': 22, 'y': -18}

>>> print(tabulate(d, headers='keys'))
name    yolo
------  ------------------------
origin  Mydata.Yolo(x=0, y=0)
v       Mydata.Yolo(x=22, y=-18)
```

The latter is more accurate.


Patch:
diff --git a/README.md b/README.md
index 5c4b62a..f92bc7c 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,7 @@ The following tabular data types are supported:
 -   list of lists or another iterable of iterables
 -   list or another iterable of dicts (keys as columns)
 -   dict of iterables (keys as columns)
+-   list of dataclasses (Python 3.7+ only, field names as columns)
 -   two-dimensional NumPy array
 -   NumPy record arrays (names as columns)
 -   pandas.DataFrame
diff --git a/tabulate.py b/tabulate.py
index 0579fb3..1f3c814 100644
--- a/tabulate.py
+++ b/tabulate.py
@@ -16,6 +16,11 @@ if sys.version_info >= (3, 3):
 else:
     from collections import Iterable
 
+if sys.version_info >= (3, 7, 0):
+    import dataclasses
+else:
+    dataclasses = None
+
 if sys.version_info[0] < 3:
     from itertools import izip_longest
     from functools import partial
@@ -1057,6 +1062,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
 
     * list of OrderedDicts (usually used with headers="keys")
 
+    * list of dataclasses (Python 3.7+ only, usually used with headers="keys")
+
     * 2D NumPy arrays
 
     * NumPy record arrays (usually used with headers="keys")
@@ -1112,7 +1119,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
         if headers == "keys":
             headers = list(map(_text_type, keys))  # headers should be strings
 
-    else:  # it's a usual an iterable of iterables, or a NumPy array
+    else:  # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses
         rows = list(tabular_data)
 
         if headers == "keys" and not rows:
@@ -1176,6 +1183,17 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             # print tabulate(cursor, headers='keys')
             headers = [column[0] for column in tabular_data.description]
 
+        elif (
+            dataclasses is not None
+            and len(rows) > 0
+            and dataclasses.is_dataclass(rows[0])
+        ):
+            # Python 3.7+'s dataclass
+            field_names = [field.name for field in dataclasses.fields(rows[0])]
+            if headers == "keys":
+                headers = field_names
+            rows = [[getattr(row, f) for f in field_names] for row in rows]
+
         elif headers == "keys" and len(rows) > 0:
             # keys are column indices
             headers = list(map(_text_type, range(len(rows[0]))))
@@ -1264,8 +1282,8 @@ def tabulate(
     The first required argument (`tabular_data`) can be a
     list-of-lists (or another iterable of iterables), a list of named
     tuples, a dictionary of iterables, an iterable of dictionaries,
-    a two-dimensional NumPy array, NumPy record array, or a Pandas'
-    dataframe.
+    an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array,
+    NumPy record array, or a Pandas' dataframe.
 
 
     Table headers


Test patch:
diff --git a/test/test_input.py b/test/test_input.py
index 541b4f7..bc67c64 100644
--- a/test/test_input.py
+++ b/test/test_input.py
@@ -480,3 +480,45 @@ def test_py27orlater_list_of_ordereddicts():
     expected = "\n".join(["  b    a", "---  ---", "  1    2", "  1    2"])
     result = tabulate(lod, headers="keys")
     assert_equal(expected, result)
+
+
+def test_py37orlater_list_of_dataclasses_keys():
+    "Input: a list of dataclasses with first item's fields as keys and headers"
+    try:
+        from dataclasses import make_dataclass
+
+        Person = make_dataclass("Person", ["name", "age", "height"])
+        ld = [Person("Alice", 23, 169.5), Person("Bob", 27, 175.0)]
+        result = tabulate(ld, headers="keys")
+        expected = "\n".join(
+            [
+                "name      age    height",
+                "------  -----  --------",
+                "Alice      23     169.5",
+                "Bob        27     175",
+            ]
+        )
+        assert_equal(expected, result)
+    except ImportError:
+        skip("test_py37orlater_list_of_dataclasses_keys is skipped")
+
+
+def test_py37orlater_list_of_dataclasses_headers():
+    "Input: a list of dataclasses with user-supplied headers"
+    try:
+        from dataclasses import make_dataclass
+
+        Person = make_dataclass("Person", ["name", "age", "height"])
+        ld = [Person("Alice", 23, 169.5), Person("Bob", 27, 175.0)]
+        result = tabulate(ld, headers=["person", "years", "cm"])
+        expected = "\n".join(
+            [
+                "person      years     cm",
+                "--------  -------  -----",
+                "Alice          23  169.5",
+                "Bob            27  175",
+            ]
+        )
+        assert_equal(expected, result)
+    except ImportError:
+        skip("test_py37orlater_list_of_dataclasses_headers is skipped")