Repository: narwhals-dev/narwhals

Issue:
[Enh]: add `InterchangeFrame.to_pandas` and `InterchangeFrame.to_arrow`
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


Plotly casts to pandas if a dataframe implements the interchange protocol: [code reference](https://github.com/plotly/plotly.py/blob/master/packages/python/plotly/plotly/express/_core.py#L1444)

### Please describe the purpose of the new feature or describe the problem to solve.

We can add `to_pandas` and `to_arrow` methods directly in the `InterchangeFrame` class so that if conversion to pandas is needed and possible, we do not need to manually check attributes

### Suggest a solution if possible.

- pandas: `pd.api.interchange.from_dataframe(self._native_frame)`
- arrow: `pa.interchange.from_dataframe(self._native_frame)`

### If you have tried alternatives, please describe them below.

_No response_

### Additional information that may help us understand your needs.

_No response_

Discussion:

Patch:
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index 8de24465..2263c3fc 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -4,8 +4,13 @@ from typing import TYPE_CHECKING
 from typing import Any
 
 from narwhals import dtypes
+from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
+    import pandas as pd
+    import pyarrow as pa
+    from typing_extensions import Self
+
     from narwhals._duckdb.series import DuckDBInterchangeSeries
 
 
@@ -73,3 +78,15 @@ class DuckDBInterchangeFrame:
             "at https://github.com/narwhals-dev/narwhals/issues."
         )
         raise NotImplementedError(msg)  # pragma: no cover
+
+    def to_pandas(self: Self) -> pd.DataFrame:
+        import pandas as pd  # ignore-banned-import()
+
+        if parse_version(pd.__version__) >= parse_version("1.0.0"):
+            return self._native_frame.df()
+        else:  # pragma: no cover
+            msg = f"Conversion to pandas requires pandas>=1.0.0, found {pd.__version__}"
+            raise NotImplementedError(msg)
+
+    def to_arrow(self: Self) -> pa.Table:
+        return self._native_frame.arrow()
diff --git a/narwhals/_ibis/dataframe.py b/narwhals/_ibis/dataframe.py
index e2baa4ec..fb7bedbf 100644
--- a/narwhals/_ibis/dataframe.py
+++ b/narwhals/_ibis/dataframe.py
@@ -6,6 +6,10 @@ from typing import Any
 from narwhals import dtypes
 
 if TYPE_CHECKING:
+    import pandas as pd
+    import pyarrow as pa
+    from typing_extensions import Self
+
     from narwhals._ibis.series import IbisInterchangeSeries
 
 
@@ -55,6 +59,12 @@ class IbisInterchangeFrame:
 
         return IbisInterchangeSeries(self._native_frame[item])
 
+    def to_pandas(self: Self) -> pd.DataFrame:
+        return self._native_frame.to_pandas()
+
+    def to_arrow(self: Self) -> pa.Table:
+        return self._native_frame.to_pyarrow()
+
     def __getattr__(self, attr: str) -> Any:
         if attr == "schema":
             return {
diff --git a/narwhals/_interchange/dataframe.py b/narwhals/_interchange/dataframe.py
index 2e977525..975da216 100644
--- a/narwhals/_interchange/dataframe.py
+++ b/narwhals/_interchange/dataframe.py
@@ -6,8 +6,13 @@ from typing import Any
 from typing import NoReturn
 
 from narwhals import dtypes
+from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
+    import pandas as pd
+    import pyarrow as pa
+    from typing_extensions import Self
+
     from narwhals._interchange.series import InterchangeSeries
 
 
@@ -89,6 +94,23 @@ class InterchangeFrame:
             for column_name in self._interchange_frame.column_names()
         }
 
+    def to_pandas(self: Self) -> pd.DataFrame:
+        import pandas as pd  # ignore-banned-import()
+
+        if parse_version(pd.__version__) >= parse_version("1.5.0"):
+            return pd.api.interchange.from_dataframe(self._native_frame)
+        else:  # pragma: no cover
+            msg = (
+                "Conversion to pandas is achieved via interchange protocol which requires"
+                f" pandas>=1.5.0 to be installed, found {pd.__version__}"
+            )
+            raise NotImplementedError(msg)
+
+    def to_arrow(self: Self) -> pa.Table:
+        from pyarrow.interchange import from_dataframe  # ignore-banned-import()
+
+        return from_dataframe(self._native_frame)
+
     def __getattr__(self, attr: str) -> NoReturn:
         msg = (
             f"Attribute {attr} is not supported for metadata-only dataframes.\n\n"
diff --git a/noxfile.py b/noxfile.py
index 06cdc028..1dc37b29 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -10,6 +10,9 @@ PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"]
 def run_common(session: Session, coverage_threshold: float) -> None:
     session.install("-e.", "-r", "requirements-dev.txt")
 
+    if session.python != "3.8":
+        session.install("ibis-framework[duckdb]>=6.0.0")
+
     session.run(
         "pytest",
         "tests",
@@ -73,5 +76,4 @@ def nightly_versions(session: Session) -> None:
         "git+https://github.com/dask/dask",
         "git+https://github.com/dask/dask-expr",
     )
-
     run_common(session, coverage_threshold=50)


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 179891dd..bf4f7d39 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -25,7 +25,7 @@ jobs:
           cache-suffix: ${{ matrix.python-version }}
           cache-dependency-glob: "**requirements*.txt"
       - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools -r requirements-dev.txt ibis-framework[duckdb] --system
+        run: uv pip install --upgrade tox virtualenv setuptools -r requirements-dev.txt --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
@@ -87,7 +87,7 @@ jobs:
       - name: show-deps
         run: uv pip freeze
       - name: install ibis
-        run: uv pip install ibis-framework[duckdb] --system
+        run: uv pip install ibis-framework[duckdb]>=6.0.0 --system
         # Ibis puts upper bounds on dependencies, and requires Python3.10+,
         # which messes with other dependencies on lower Python versions
         if: matrix.python-version == '3.12'
diff --git a/tests/frame/interchange_to_arrow_test.py b/tests/frame/interchange_to_arrow_test.py
new file mode 100644
index 00000000..7308607e
--- /dev/null
+++ b/tests/frame/interchange_to_arrow_test.py
@@ -0,0 +1,41 @@
+import duckdb
+import polars as pl
+import pyarrow as pa
+import pytest
+
+import narwhals.stable.v1 as nw
+
+data = {"a": [1, 2, 3], "b": [4.0, 5.0, 6.1], "z": ["x", "y", "z"]}
+
+
+def test_interchange_to_arrow() -> None:
+    df_pl = pl.DataFrame(data)
+    df = nw.from_native(df_pl.__dataframe__(), eager_or_interchange_only=True)
+    result = df.to_arrow()
+
+    assert isinstance(result, pa.Table)
+
+
+def test_interchange_ibis_to_arrow(
+    tmpdir: pytest.TempdirFactory,
+) -> None:  # pragma: no cover
+    ibis = pytest.importorskip("ibis")
+    df_pl = pl.DataFrame(data)
+
+    filepath = str(tmpdir / "file.parquet")  # type: ignore[operator]
+    df_pl.write_parquet(filepath)
+
+    tbl = ibis.read_parquet(filepath)
+    df = nw.from_native(tbl, eager_or_interchange_only=True)
+    result = df.to_arrow()
+
+    assert isinstance(result, pa.Table)
+
+
+def test_interchange_duckdb_to_arrow() -> None:
+    df_pl = pl.DataFrame(data)  # noqa: F841
+    rel = duckdb.sql("select * from df_pl")
+    df = nw.from_native(rel, eager_or_interchange_only=True)
+    result = df.to_arrow()
+
+    assert isinstance(result, pa.Table)
diff --git a/tests/frame/interchange_to_pandas_test.py b/tests/frame/interchange_to_pandas_test.py
new file mode 100644
index 00000000..f56575fa
--- /dev/null
+++ b/tests/frame/interchange_to_pandas_test.py
@@ -0,0 +1,45 @@
+import duckdb
+import pandas as pd
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
+
+data = {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0], "z": ["x", "y", "z"]}
+
+
+def test_interchange_to_pandas(request: pytest.FixtureRequest) -> None:
+    if parse_version(pd.__version__) < parse_version("1.5.0"):
+        request.applymarker(pytest.mark.xfail)
+    df_raw = pd.DataFrame(data)
+    df = nw.from_native(df_raw.__dataframe__(), eager_or_interchange_only=True)
+
+    assert df.to_pandas().equals(df_raw)
+
+
+def test_interchange_ibis_to_pandas(
+    tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest
+) -> None:  # pragma: no cover
+    if parse_version(pd.__version__) < parse_version("1.5.0"):
+        request.applymarker(pytest.mark.xfail)
+
+    ibis = pytest.importorskip("ibis")
+    df_raw = pd.DataFrame(data)
+
+    filepath = str(tmpdir / "file.parquet")  # type: ignore[operator]
+    df_raw.to_parquet(filepath)
+
+    tbl = ibis.read_parquet(filepath)
+    df = nw.from_native(tbl, eager_or_interchange_only=True)
+
+    assert df.to_pandas().equals(df_raw)
+
+
+def test_interchange_duckdb_to_pandas(request: pytest.FixtureRequest) -> None:
+    if parse_version(pd.__version__) < parse_version("1.0.0"):
+        request.applymarker(pytest.mark.xfail)
+    df_raw = pd.DataFrame(data)
+    rel = duckdb.sql("select * from df_raw")
+    df = nw.from_native(rel, eager_or_interchange_only=True)
+
+    assert df.to_pandas().equals(df_raw)