Repository: pandas-dev/pandas
Pull request: ENH: Add support for dataclasses in the DataFrame constructor
Added support for data-classes when used in the construction of a new dataframe.

- [X] closes #21910
- [X] tests added / passed
- [X] passes `black pandas`
- [X] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`

Simply put, added support to use dataclasses in the following way:

```python
from dataclasses import dataclass

@dataclass 
class Person:
    name: str
    age: int

df = DataFrame([Person("me", 25), Person("you", 35)])
```

Problem statement:
Dataclass support
#### Proposal  description

Dataclasses were added in Python 3.7.

It would be nice for pandas to support dataclasses. For example could be possible to construct dataframe from by calling `.from_dataclasses` or just `.DataFrame(data=dataclass_list)`. There should be also possibility to do `.to_dataclasses`.

#### Expected Behaviour

```python
from dataclasses import dataclass
import pandas as pd

@dataclass
class SimpleDataObject(object):
  field_a: int
  field_b: str

dataclass_object1 = SimpleDataObject(1, 'a')
dataclass_object2 = SimpleDataObject(2, 'b')
>>> asd

# Dataclasses to DataFrame
df = pd.from_dataclasses([dataclass_object1, dataclass_object2])
df.dtypes == ['field_a', 'field_b']
>>> True
df.dtypes == ['int', 'str']
>>> True

# Dataclasses to DataFrame
df = pd.DataFrame(data=[dataclass_object1, dataclass_object2])
df.dtypes == ['field_a', 'field_b']
>>> True
df.dtypes == ['int', 'str']
>>> True

# DataFrame to Dataclasses
df = pd.DataFrame(columns=['field_a', 'field_b'], data=[[1, 'a'], [2, 'b']])
dataclass_list = df.to_dataclasses()
dataclass_list == [dataclass_object1, dataclass_object2]
>>> True
```

Hints from issue discussion:
AFAIK is s not guaranteed that you can know that a certain instance is a dataclass. E.g. Classes do not inherit from dataclass.

From your example:

```python
@dataclass
class SimpleDataObject(object):
  field_a: int
  field_b: str

x = SimpleDataObject(a=2, b=‘f’)
```

I dont think you could even tell from introspection that ``x`` is a dataclass, correct? If that’s the case, this isn’t possible to do.
The `dataclasses` module has `is_dataclass` and `fields` introspection functions, so that part shouldn't be an issue.

That said I'm not sure we should quickly commit to any specific API/support here.  For now the the `asdict` helper from the dataclasses module can help with the ingest usecase.

```python
In [18]: from dataclasses import asdict

In [19]: pd.DataFrame([asdict(x) for x in [dataclass_object1, dataclass_object2]])
Out[19]:
   field_a field_b
0        1       a
1        2       b
```

I compiled a solution where I check the data provided during \_\_init\_\_, in this [PR](https://github.com/pandas-dev/pandas/pull/27999), however, it looks like their testing pipeline is setup to support multiple py-versions. So I may need a bit more time to make this happen.

Commit message: Update doc/source/getting_started/dsintro.rst

Co-Authored-By: Tom Augspurger <TomAugspurger@users.noreply.github.com>

Patch to review:
diff --git a/doc/source/getting_started/dsintro.rst b/doc/source/getting_started/dsintro.rst
index 9e18951fe3f4c..6bc0ab237cd9a 100644
--- a/doc/source/getting_started/dsintro.rst
+++ b/doc/source/getting_started/dsintro.rst
@@ -397,6 +397,28 @@ The result will be a DataFrame with the same index as the input Series, and
 with one column whose name is the original name of the Series (only if no other
 column name provided).
 
+From a list of dataclasses
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 1.0.0
+dataclasses as introduced in `PEP557 <https://www.python.org/dev/peps/pep-0557>`,
+can be passed into the DataFrame constructor.
+Passing a list of dataclasses is equivilent to passing a list of dictionaries. However,
+it is imperative that all values in the list are dataclasses otherwise expect a TypeError.
+
+.. ipython:: python
+
+    from dataclasses import make_dataclass
+
+    Point = make_dataclass("Point", [("x", int), ("y", int)])
+    HLine = make_dataclass("HLine", [("x0", int), ("x1", int), ("y", int)])
+    
+    data = [Point(0,0), HLine(0,3,10), Point(2,3)]
+    df = pd.DataFrame(data)
+
+.. _basics.dataframe.from_list_dataclasses:
+
+
 **Missing data**
 
 Much more will be said on this topic in the :ref:`Missing data <missing_data>`
diff --git a/pandas/core/dtypes/common.py b/pandas/core/dtypes/common.py
index f75493be2dab1..374be23acf0ec 100644
--- a/pandas/core/dtypes/common.py
+++ b/pandas/core/dtypes/common.py
@@ -46,6 +46,7 @@
     is_scalar,
     is_sequence,
     is_string_like,
+    is_dataclass_instance,
 )
 
 from pandas._typing import ArrayLike
diff --git a/pandas/core/dtypes/inference.py b/pandas/core/dtypes/inference.py
index 461b5cc6232cd..95b7d58090842 100644
--- a/pandas/core/dtypes/inference.py
+++ b/pandas/core/dtypes/inference.py
@@ -450,3 +450,37 @@ def is_sequence(obj):
         return not isinstance(obj, (str, bytes))
     except (TypeError, AttributeError):
         return False
+
+
+def is_dataclass_instance(item):
+    """
+    Checks if the object is a data-class instance
+
+    Parameters
+    ----------
+    item : the object to check
+
+    Returns
+    --------
+    is_dataclass_instance : bool
+        True if the item is an instance of a data-class,
+        will return false if you pass the data class itself
+
+    Examples
+    --------
+    >>> @dataclass
+    >>> class Point:
+    >>>     x: int
+    >>>     y: int
+
+    >>> is_dataclass_instance(Point)
+    False
+    >>> is_dataclass_instance(Point(0,2))
+    True
+
+    """
+    try:
+        from dataclasses import is_dataclass
+        return is_dataclass(item) and not isinstance(item, type)
+    except ImportError:
+        return False
diff --git a/pandas/core/frame.py b/pandas/core/frame.py
index 603a615c1f8cb..e58bfcfe1a943 100644
--- a/pandas/core/frame.py
+++ b/pandas/core/frame.py
@@ -16,6 +16,7 @@
 import sys
 from textwrap import dedent
 from typing import FrozenSet, List, Optional, Set, Tuple, Type, Union
+
 import warnings
 
 import numpy as np
@@ -71,6 +72,7 @@
     is_scalar,
     is_sequence,
     needs_i8_conversion,
+    is_dataclass_instance,
 )
 from pandas.core.dtypes.generic import (
     ABCDataFrame,
@@ -107,6 +109,7 @@
     reorder_arrays,
     sanitize_index,
     to_arrays,
+    _dataclasses_to_dicts,
 )
 from pandas.core.series import Series
 
@@ -440,6 +443,8 @@ def __init__(self, data=None, index=None, columns=None, dtype=None, copy=False):
             if not isinstance(data, abc.Sequence):
                 data = list(data)
             if len(data) > 0:
+                if is_dataclass_instance(data[0]):
+                    data = _dataclasses_to_dicts(data)
                 if is_list_like(data[0]) and getattr(data[0], "ndim", 1) == 1:
                     if is_named_tuple(data[0]) and columns is None:
                         columns = data[0]._fields
diff --git a/pandas/core/internals/construction.py b/pandas/core/internals/construction.py
index 3126b9d9d3e2e..ddf03a089a769 100644
--- a/pandas/core/internals/construction.py
+++ b/pandas/core/internals/construction.py
@@ -422,6 +422,12 @@ def _get_axes(N, K, index, columns):
     return index, columns
 
 
+def _dataclasses_to_dicts(data):
+    from dataclasses import asdict
+
+    return list(map(asdict, data))
+
+
 # ---------------------------------------------------------------------
 # Conversion of Inputs to Arrays
 
diff --git a/pandas/tests/frame/test_constructors.py b/pandas/tests/frame/test_constructors.py
index ebffeeaa3063e..0d97aa0048d1d 100644
--- a/pandas/tests/frame/test_constructors.py
+++ b/pandas/tests/frame/test_constructors.py
@@ -8,7 +8,7 @@
 import numpy.ma.mrecords as mrecords
 import pytest
 
-from pandas.compat import PY36, is_platform_little_endian
+from pandas.compat import PY36, PY37, is_platform_little_endian
 
 from pandas.core.dtypes.cast import construct_1d_object_array_from_listlike
 from pandas.core.dtypes.common import is_integer_dtype
@@ -1336,6 +1336,34 @@ def test_constructor_list_of_namedtuples(self):
         result = DataFrame(tuples, columns=["y", "z"])
         tm.assert_frame_equal(result, expected)
 
+    @pytest.mark.skipif(not PY37, reason="Requires Python >= 3.7")
+    def test_constructor_list_of_dataclasses(self):
+        # GH21910
+        from dataclasses import make_dataclass
+
+        Point = make_dataclass("Point", [("x", int), ("y", int)])
+
+        datas = [Point(0, 3), Point(1, 3)]
+        expected = DataFrame({"x": [0, 1], "y": [3, 3]})
+        result = DataFrame(datas)
+        tm.assert_frame_equal(result, expected)
+
+        # varying types
+        Point = make_dataclass("Point", [("x", int), ("y", int)])
+        HLine = make_dataclass("HLine", [("x0", int), ("x1", int), ("y", int)])
+
+        datas = [Point(0, 3), HLine(1, 3, 3)]
+
+        expected = DataFrame(
+            {"x": [0, np.nan], "y": [3, 3], "x0": [np.nan, 1], "x1": [np.nan, 3]}
+        )
+        result = DataFrame(datas)
+        tm.assert_frame_equal(result, expected)
+
+        # expect TypeError
+        with pytest.raises(TypeError):
+            DataFrame([Point(0, 0), {"x": 1, "y": 0}])
+
     def test_constructor_list_of_dict_order(self):
         # GH10056
         data = [


Code review comments:
diff_hunk: @@ -397,6 +397,28 @@ The result will be a DataFrame with the same index as the input Series, and
 with one column whose name is the original name of the Series (only if no other
 column name provided).
 
+From a list of dataclasses
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 1.0.0
+dataclasses as introduced in `PEP557 <https://www.python.org/dev/peps/pep-0557>`,
+can be passed into the DataFrame constructor.
+Passing a list of dataclasses is equivilent to passing a list of dictionaries. However,
+it is imperative that all values in the list are dataclasses otherwise expect a TypeError.
+
+.. ipython:: python
+
+    from dataclasses import make_dataclass
+
+    Point = make_dataclass("Point", [("x", int), ("y", int)])
+    HLine = make_dataclass("HLine", [("x0", int), ("x1", int), ("y", int)])
+    
+    data = [Point(0,0), HLine(0,3,10), Point(2,3)]
+    df = pd.DataFrame(data)
+
+.. _basics.dataframe.from_list_dataclasses:
path: doc/source/getting_started/dsintro.rst
text: @user1:
This label goes above the title

diff_hunk: @@ -397,6 +397,28 @@ The result will be a DataFrame with the same index as the input Series, and
 with one column whose name is the original name of the Series (only if no other
 column name provided).
 
+From a list of dataclasses
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 1.0.0
+dataclasses as introduced in `PEP557 <https://www.python.org/dev/peps/pep-0557>`,
+can be passed into the DataFrame constructor.
+Passing a list of dataclasses is equivilent to passing a list of dictionaries. However,
+it is imperative that all values in the list are dataclasses otherwise expect a TypeError.
+
+.. ipython:: python
+
+    from dataclasses import make_dataclass
+
+    Point = make_dataclass("Point", [("x", int), ("y", int)])
+    HLine = make_dataclass("HLine", [("x0", int), ("x1", int), ("y", int)])
+    
+    data = [Point(0,0), HLine(0,3,10), Point(2,3)]
path: doc/source/getting_started/dsintro.rst
text: @user1:
I would show an example with all the same dataclasses, that is much more typical (we should encourage passing different ones IMO).