Repository: streamlit/streamlit

Issue:
st.experimental_memo cannot cache dataclasses.dataclass(frozen=True, eq=True)
### Summary

According to [Python standard library docs](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), we can make a `dataclasses.dataclass` hashable by providing the parameters `frozen=True, eq=True`. Streamlit still cannot hash such a `dataclass`.

> If eq and frozen are both true, by default dataclass() will generate a __hash__() method for you.

### Steps to reproduce

Code snippet:

```python
from dataclasses import dataclass

import streamlit as st

@dataclass(frozen=True, eq=True)  # hashable
class Data:
    foo: str
       

@st.experimental_memo
def process_data(data: Data):
    return data.foo
        
data = Data("bar")

st.info(process_data(data))
```

results in 

> 
> streamlit.caching.cache_errors.UnhashableParamError: Cannot hash argument 'data' (of type `__main__.Data`) in 'process_data'.
> 
> To address this, you can tell Streamlit not to hash this argument by adding a
> leading underscore to the argument's name in the function signature:
> 
> ```
> @st.experimental_memo
> def process_data(_data, ...):
>     ...
> ```

**Expected behavior:**

`st.experimental_memo` can cache a `dataclass(frozen=True, eq=True)`.

**Actual behavior:**

`st.experimental_memo` cannot cache a `dataclass(frozen=True, eq=True)`.


### Is this a regression?

~no~ yes, it works with `st.cache`:

```python
from dataclasses import dataclass

import streamlit as st

@dataclass(frozen=True, eq=True)  # hashable
class Data:
    foo: str
       

@st.cache
def process_data(data: Data):
    return data.foo
        
data = Data("bar")

st.info(process_data(data))
```

### Workaround

Use `dataclasses.asdict` before calling the cached function and re-assemble the `dataclass` later:

```python
from dataclasses import asdict, dataclass
from typing import Dict

import streamlit as st

@dataclass(frozen=True, eq=True)  # hashable
class Data:
    foo: str
       

@st.experimental_memo
def process_data(data: Dict[str, str]):
    return Data(data["foo"])
        
data = Data("bar")

st.info(process_data(asdict(data)))
```

### Debug info

- Streamlit version: Streamlit, version 1.0.0
- Python version: Python 3.8.10
- Using Conda
- OS version: JupyterLab on Linux
- Browser version: Firefox



Discussion:
Thanks for reporting this, @StefanBrand! I'm not able to give a timeline as to when we'll be able to look into fixing this at the moment, but at the very least we'll be sure to aim to do this before `st.experimental_memo` gets moved out of the `experimental` namespace.
+1 for this being important for us, as it severely limits the usefulness of `st.experimental_memo` and keeps us using `st.cache`
@vdonato Shall we close one of #3944 or #4417 in favour of the other?
Ah, thanks for pointing out the similarities between these two @StefanBrand

I'll close #4417 in favor of this one since it was filed first
For an update on the status of this: I'm aware that we have plans to move `st.experimental_{memo, singleton}` out of the experimental namespace in the near future, so I would imagine this fix will at least be scheduled to be done by the time that happens (edit: I just realized that I said exactly this in an earlier comment, whoops). I'll also raise this internally since this seems to affect the usefulness of `st.experimental_memo` enough that it's worth fixing before we move the feature out of experimental

(CC @tconkling)
> For an update on the status of this: I'm aware that we have plans to move `st.experimental_{memo, singleton}` out of the experimental namespace in the near future, so I would imagine this fix will at least be scheduled to be done by the time that happens. I'll also raise this internally since this seems to affect the usefulness of `st.experimental_memo` enough that it's worth fixing before we move the feature out of experimental
> 
> (CC @tconkling)

Sounds good @vdonato, just please see my analysis in the ticket - if it is correct, this might be a one liner. Otherwise I'm not understanding the point of the comment `# As a last resort, hash the output of the object's __reduce__ method `.

Resolving patch:
diff --git a/lib/setup.py b/lib/setup.py
--- a/lib/setup.py
+++ b/lib/setup.py
@@ -79,10 +79,7 @@ def run(self):
     },
     author="Streamlit Inc",
     author_email="hello@streamlit.io",
-    # We *officially* support Python 3.7+, but we're still technically
-    # installable on 3.6. If we use any 3.7+ language features, we should
-    # update python_requires to reflect that.
-    python_requires=">=3.6",
+    python_requires=">=3.7",
     license="Apache 2",
     # PEP 561: https://mypy.readthedocs.io/en/stable/installed_packages.html
     package_data={"streamlit": ["py.typed", "hello/**/*.py"]},
diff --git a/lib/streamlit/caching/hashing.py b/lib/streamlit/caching/hashing.py
--- a/lib/streamlit/caching/hashing.py
+++ b/lib/streamlit/caching/hashing.py
@@ -14,6 +14,7 @@
 
 """Hashing for st.memo and st.singleton."""
 import collections
+import dataclasses
 import functools
 import hashlib
 import inspect
@@ -261,6 +262,9 @@ def _to_bytes(self, obj: Any) -> bytes:
         elif obj is False:
             return b"0"
 
+        elif dataclasses.is_dataclass(obj):
+            return self.to_bytes(dataclasses.asdict(obj))
+
         elif type_util.is_type(obj, "pandas.core.frame.DataFrame") or type_util.is_type(
             obj, "pandas.core.series.Series"
         ):


Test patch:
diff --git a/lib/tests/streamlit/caching/hashing_test.py b/lib/tests/streamlit/caching/hashing_test.py
--- a/lib/tests/streamlit/caching/hashing_test.py
+++ b/lib/tests/streamlit/caching/hashing_test.py
@@ -14,10 +14,12 @@
 
 """st.memo/singleton hashing tests."""
 
+from dataclasses import dataclass
 import functools
 import hashlib
 import os
 import re
+
 import tempfile
 import types
 import unittest
@@ -271,6 +273,15 @@ def test_magic_mock(self):
         # stack due to an infinite recursion.)
         self.assertNotEqual(get_hash(MagicMock()), get_hash(MagicMock()))
 
+    def test_dataclass(self):
+        @dataclass(frozen=True, eq=True)
+        class Data:
+            foo: str
+
+        bar = Data("bar")
+
+        assert get_hash(bar)
+
 
 class NotHashableTest(unittest.TestCase):
     """Tests for various unhashable types. Many of these types *are*