Repository: marimo-team/marimo
Pull request: fix: false positive dataframe-like check
Fixes #1602

This is a bit more robust checking if its "dataframe-like" and has the dataframe protocol

Problem statement:
DataFrameLike False Positive When __getattr__ Presents
### Describe the bug

When a class implemented `__getattr__` or `__getattribute__`, marimo will treat this instance as a `DataFrameLike` object, trying to render a table from it and raise exceptions.

### Environment

{
  "marimo": "0.6.17",
  "OS": "Darwin",
  "OS Version": "23.5.0",
  "Processor": "arm",
  "Python Version": "3.11.5",
  "Binaries": {
    "Browser": "125.0.6422.142",
    "Node": "v22.1.0"
  },
  "Requirements": {
    "click": "8.1.7",
    "importlib-resources": "missing",
    "jedi": "0.19.1",
    "markdown": "3.6",
    "pymdown-extensions": "10.8.1",
    "pygments": "2.18.0",
    "tomlkit": "0.12.5",
    "uvicorn": "0.30.1",
    "starlette": "0.37.2",
    "websockets": "12.0",
    "typing-extensions": "4.7.1",
    "black": "24.4.2"
  }
}


### Code to reproduce

```python
class Boom:
    def __getattr__(self, _):
        return ...
```

```python
b = Boom()
```

This will result an error:
`TypeError: 'ellipsis' object is not callable`

Hints from issue discussion:
Thanks for pointing this out - I'll fix this today

Commit message: fix: fake dataframe-like check

Patch to review:
diff --git a/marimo/_plugins/ui/_impl/tables/df_protocol_table.py b/marimo/_plugins/ui/_impl/tables/df_protocol_table.py
index a0de456c0d3..be2ff32e78f 100644
--- a/marimo/_plugins/ui/_impl/tables/df_protocol_table.py
+++ b/marimo/_plugins/ui/_impl/tables/df_protocol_table.py
@@ -17,7 +17,10 @@
     FieldTypes,
     TableManager,
 )
-from marimo._plugins.ui._impl.tables.types import DataFrameLike
+from marimo._plugins.ui._impl.tables.types import (
+    DataFrameLike,
+    is_dataframe_like,
+)
 
 if TYPE_CHECKING:
     import pyarrow as pa  # type: ignore
@@ -54,7 +57,7 @@ def to_json(self) -> bytes:
 
     @staticmethod
     def is_type(value: Any) -> bool:
-        return isinstance(value, DataFrameLike)
+        return is_dataframe_like(value)
 
     def select_rows(
         self, indices: list[int]
diff --git a/marimo/_plugins/ui/_impl/tables/types.py b/marimo/_plugins/ui/_impl/tables/types.py
index 787d089498d..e08037635ce 100644
--- a/marimo/_plugins/ui/_impl/tables/types.py
+++ b/marimo/_plugins/ui/_impl/tables/types.py
@@ -11,3 +11,11 @@ class DataFrameLike(Protocol):
     def __dataframe__(
         self, nan_as_null: bool = False, allow_copy: bool = True
     ) -> DataFrame: ...
+
+
+def is_dataframe_like(value: object) -> bool:
+    return (
+        isinstance(value, DataFrameLike)
+        and hasattr(value, "__dataframe__")
+        and callable(value.__dataframe__)
+    )
diff --git a/marimo/_plugins/ui/_impl/tables/utils.py b/marimo/_plugins/ui/_impl/tables/utils.py
index 58f9f582375..fedc40dc967 100644
--- a/marimo/_plugins/ui/_impl/tables/utils.py
+++ b/marimo/_plugins/ui/_impl/tables/utils.py
@@ -21,7 +21,9 @@
     TableManager,
     TableManagerFactory,
 )
-from marimo._plugins.ui._impl.tables.types import DataFrameLike
+from marimo._plugins.ui._impl.tables.types import (
+    is_dataframe_like,
+)
 
 MANAGERS: List[TableManagerFactory] = [
     PandasTableManagerFactory(),
@@ -46,7 +48,7 @@ def get_table_manager_or_none(data: Any) -> TableManager[Any] | None:
                 return manager(data)
 
     # If we have a DataFrameLike object, use the DataFrameProtocolTableManager
-    if isinstance(data, DataFrameLike):
+    if is_dataframe_like(data):
         return DataFrameProtocolTableManager(data)
 
     return None
diff --git a/marimo/_smoke_tests/bugs/1602.py b/marimo/_smoke_tests/bugs/1602.py
new file mode 100644
index 00000000000..ac614c71236
--- /dev/null
+++ b/marimo/_smoke_tests/bugs/1602.py
@@ -0,0 +1,28 @@
+import marimo
+
+__generated_with = "0.6.17"
+app = marimo.App(width="medium")
+
+
+@app.cell
+def __():
+    class Boom:
+        def __getattr__(self, _):
+            return ...
+    return Boom,
+
+
+@app.cell
+def __(Boom):
+    b = Boom()
+    return b,
+
+
+@app.cell
+def __(b):
+    callable(b.__dataframe__)
+    return
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/tests/_plugins/ui/_impl/tables/test_table_utils.py b/tests/_plugins/ui/_impl/tables/test_table_utils.py
new file mode 100644
index 00000000000..9674fb5a943
--- /dev/null
+++ b/tests/_plugins/ui/_impl/tables/test_table_utils.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from marimo._plugins.ui._impl.tables.types import is_dataframe_like
+
+
+class Fake:
+    def __getattr__(self, _):
+        return ...
+
+
+class Real:
+    def __dataframe__(self):
+        return ...
+
+
+def test_is_dataframe_like():
+    # Primitives
+    assert is_dataframe_like(1) is False
+    assert is_dataframe_like("1") is False
+    assert is_dataframe_like([]) is False
+    assert is_dataframe_like({}) is False
+    assert is_dataframe_like(()) is False
+
+    assert is_dataframe_like(Fake()) is False
+    assert is_dataframe_like(Real()) is True


Code review comments:
diff_hunk: @@ -46,7 +48,7 @@ def get_table_manager_or_none(data: Any) -> TableManager[Any] | None:
                 return manager(data)
 
     # If we have a DataFrameLike object, use the DataFrameProtocolTableManager
-    if isinstance(data, DataFrameLike):
+    if is_dataframe_like(data):
         return DataFrameProtocolTableManager(data)
path: marimo/_plugins/ui/_impl/tables/utils.py
text: @user1:
Maybe we can add a fall back to normal rendering approach if `__dataframe__` method itself returns a non-dataframe object or raises an exception.


@author:
done, thanks for the suggestion!