From e745e375c0711d2fc153dbd957c2066725ef6522 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:25:30 +0000 Subject: [PATCH 01/43] chore!: Remove interchange (temporary) Going scorched-earth to find out who's using it downstream Part 1 of (https://github.com/narwhals-dev/narwhals/pull/3558#discussion_r3486404879) --- src/narwhals/stable/v1/__init__.py | 13 +++++- src/narwhals/stable/v2/__init__.py | 1 - src/narwhals/translate.py | 43 ++++--------------- .../interchange_native_namespace_test.py | 8 ++++ tests/frame/interchange_schema_test.py | 12 +++++- tests/frame/interchange_select_test.py | 8 ++++ tests/frame/interchange_to_arrow_test.py | 7 +++ tests/frame/interchange_to_pandas_test.py | 7 +++ tests/translate/from_native_test.py | 2 +- tests/v1_test.py | 9 ++++ 10 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 0a683e3167..4fb9b83222 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -656,11 +656,22 @@ def from_native( msg = f"from_native() got an unexpected keyword argument {next(iter(kwds))!r}" raise TypeError(msg) + if eager_only and eager_or_interchange_only: + msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" + raise ValueError(msg) + + if eager_or_interchange_only: + if eager_only: + msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" + raise ValueError(msg) + + msg = "TODO @dangotbanned: Isolate v1 `__dataframe__` support" + raise NotImplementedError(msg) + return _from_native_impl( # type: ignore[no-any-return] native_object, pass_through=pass_through, eager_only=eager_only, - eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, allow_series=allow_series, version=Version.V1, diff --git a/src/narwhals/stable/v2/__init__.py b/src/narwhals/stable/v2/__init__.py index 1260482cdd..2d4193fd4d 100644 --- a/src/narwhals/stable/v2/__init__.py +++ b/src/narwhals/stable/v2/__init__.py @@ -485,7 +485,6 @@ def from_native( eager_only=eager_only, series_only=series_only, allow_series=allow_series, - eager_or_interchange_only=False, version=Version.V2, ) diff --git a/src/narwhals/translate.py b/src/narwhals/translate.py index 8a722b1570..30b8431e8c 100644 --- a/src/narwhals/translate.py +++ b/src/narwhals/translate.py @@ -213,7 +213,6 @@ def from_native( native_object, pass_through=pass_through, eager_only=eager_only, - eager_or_interchange_only=False, series_only=series_only, allow_series=allow_series, version=Version.MAIN, @@ -225,8 +224,6 @@ def _translate_if_compliant( # noqa: C901,PLR0911 *, pass_through: bool = False, eager_only: bool = False, - # Interchange-level was removed after v1 - eager_or_interchange_only: bool, series_only: bool, allow_series: bool | None, version: Version, @@ -246,9 +243,9 @@ def _translate_if_compliant( # noqa: C901,PLR0911 msg = "Cannot only use `series_only` with lazyframe" raise TypeError(msg) return compliant_object - if eager_only or eager_or_interchange_only: + if eager_only: if not pass_through: - msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe" + msg = "Cannot only use `eager_only` with lazyframe" raise TypeError(msg) return compliant_object return version.lazyframe( @@ -272,13 +269,10 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 *, pass_through: bool = False, eager_only: bool = False, - # Interchange-level was removed after v1 - eager_or_interchange_only: bool, series_only: bool, allow_series: bool | None, version: Version, ) -> Any: - from narwhals._interchange.dataframe import supports_dataframe_interchange from narwhals.dataframe import DataFrame, LazyFrame from narwhals.series import Series @@ -309,9 +303,6 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 msg = "Invalid parameter combination: `series_only=True` and `allow_series=False`" raise ValueError(msg) allow_series = True - if eager_only and eager_or_interchange_only: - msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" - raise ValueError(msg) # Extensions if ( @@ -319,7 +310,6 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 native_object, pass_through=pass_through, eager_only=eager_only, - eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, allow_series=allow_series, version=version, @@ -334,11 +324,9 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 msg = f"Cannot only use `series_only` with {type(native_object).__qualname__}" raise TypeError(msg) return native_object - if (eager_only or eager_or_interchange_only) and is_polars_lazyframe( - native_object - ): + if (eager_only) and is_polars_lazyframe(native_object): if not pass_through: - msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame" + msg = "Cannot only use `eager_only` with polars.LazyFrame" raise TypeError(msg) return native_object if (not allow_series) and is_polars_series(native_object): @@ -397,9 +385,9 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 msg = "Cannot only use `series_only` with dask DataFrame" raise TypeError(msg) return native_object - if eager_only or eager_or_interchange_only: + if eager_only: if not pass_through: - msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with dask DataFrame" + msg = "Cannot only use `eager_only` with dask DataFrame" raise TypeError(msg) return native_object if ( @@ -443,36 +431,21 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 # PySpark if is_native_spark_like(native_object): # pragma: no cover ns_spark = version.namespace.from_native_object(native_object) - if series_only or eager_only or eager_or_interchange_only: + if series_only or eager_only: if not pass_through: msg = ( - "Cannot only use `series_only`, `eager_only` or `eager_or_interchange_only` " + "Cannot only use `series_only`, `eager_only` " f"with {ns_spark.implementation} DataFrame" ) raise TypeError(msg) return native_object return ns_spark.compliant.from_native(native_object).to_narwhals() - # Interchange protocol - if version is Version.V1 and supports_dataframe_interchange(native_object): - from narwhals._interchange.dataframe import InterchangeFrame - - if eager_only or series_only: - if not pass_through: - msg = ( - "Cannot only use `series_only=True` or `eager_only=False` " - "with object which only implements __dataframe__" - ) - raise TypeError(msg) - return native_object - return Version.V1.dataframe(InterchangeFrame(native_object)) - if (compliant_object := plugins.from_native(native_object, version)) is not None: return _translate_if_compliant( compliant_object, pass_through=pass_through, eager_only=eager_only, - eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, allow_series=allow_series, version=version, diff --git a/tests/frame/interchange_native_namespace_test.py b/tests/frame/interchange_native_namespace_test.py index 79a92ef6c9..bf0fb9926f 100644 --- a/tests/frame/interchange_native_namespace_test.py +++ b/tests/frame/interchange_native_namespace_test.py @@ -14,7 +14,13 @@ data: Mapping[str, Any] = {"a": [1, 2, 3], "b": [4.5, 6.7, 8.9], "z": ["x", "y", "w"]} +XFAIL_INTERCHANGE = pytest.mark.xfail( + reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", + raises=NotImplementedError, +) + +@XFAIL_INTERCHANGE def test_interchange() -> None: df_pl = pl.DataFrame(data) df = nw_v1.from_native(df_pl.__dataframe__(), eager_or_interchange_only=True) @@ -33,6 +39,7 @@ def test_interchange() -> None: series.__native_namespace__() +@XFAIL_INTERCHANGE @pytest.mark.filterwarnings("ignore:.*The `ArrowDtype` class is not available in pandas") def test_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest @@ -56,6 +63,7 @@ def test_ibis( assert series.__native_namespace__() == ibis +@XFAIL_INTERCHANGE def test_duckdb() -> None: pytest.importorskip("duckdb") import duckdb diff --git a/tests/frame/interchange_schema_test.py b/tests/frame/interchange_schema_test.py index 8228e87e9e..0810be9394 100644 --- a/tests/frame/interchange_schema_test.py +++ b/tests/frame/interchange_schema_test.py @@ -10,7 +10,13 @@ pytest.importorskip("polars") import polars as pl +XFAIL_INTERCHANGE = pytest.mark.xfail( + reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", + raises=NotImplementedError, +) + +@XFAIL_INTERCHANGE def test_interchange_schema() -> None: df_pl = pl.DataFrame( { @@ -68,6 +74,7 @@ def test_interchange_schema() -> None: assert df["a"].dtype == nw_v1.Int64 +@XFAIL_INTERCHANGE @pytest.mark.filterwarnings("ignore:.*locale specific date formats") def test_interchange_schema_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest @@ -164,6 +171,7 @@ def test_interchange_schema_ibis( assert df.collect_schema() == expected +@XFAIL_INTERCHANGE def test_interchange_schema_duckdb() -> None: pytest.importorskip("duckdb") import duckdb @@ -233,6 +241,7 @@ def test_interchange_schema_duckdb() -> None: assert df.collect_schema() == expected +@pytest.mark.xfail(reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support") def test_invalid() -> None: df = pl.DataFrame({"a": [1, 2, 3]}).__dataframe__() with pytest.raises(TypeError, match="Cannot only use `series_only=True`"): @@ -246,8 +255,7 @@ def test_invalid() -> None: reason=( "Error was being raised by `InterchangeSeries._implementation`, but test uses `InterchangeFrame.filter`.\n" "Revealed by adding `InterchangeSeries._implementation = Implementation.UNKNOWN`" - ), - raises=ValueError, + ) ) def test_invalid_filter() -> None: df = pl.DataFrame({"a": [1, 2, 3]}).__dataframe__() diff --git a/tests/frame/interchange_select_test.py b/tests/frame/interchange_select_test.py index a927ba18c6..4f2efc88c3 100644 --- a/tests/frame/interchange_select_test.py +++ b/tests/frame/interchange_select_test.py @@ -12,6 +12,11 @@ data: Mapping[str, Any] = {"a": [1, 2, 3], "b": [4.0, 5.0, 6.1], "z": ["x", "y", "z"]} +XFAIL_INTERCHANGE = pytest.mark.xfail( + reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", + raises=NotImplementedError, +) + class InterchangeDataFrame: def __init__(self, df: CustomDataFrame) -> None: @@ -39,6 +44,7 @@ def __dataframe__(self, *, allow_copy: bool = True) -> InterchangeDataFrame: return InterchangeDataFrame(self) +@XFAIL_INTERCHANGE def test_interchange() -> None: df = CustomDataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "z": [1, 4, 2]}) result = nw_v1.from_native(df, eager_or_interchange_only=True).select("a", "z") @@ -51,6 +57,7 @@ def test_interchange_non_v1() -> None: nw.from_native(df) # type: ignore[call-overload] +@XFAIL_INTERCHANGE def test_interchange_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover @@ -77,6 +84,7 @@ def test_interchange_ibis( assert out_cols == ["a", "z"] +@XFAIL_INTERCHANGE def test_interchange_duckdb() -> None: pytest.importorskip("polars") pytest.importorskip("duckdb") diff --git a/tests/frame/interchange_to_arrow_test.py b/tests/frame/interchange_to_arrow_test.py index 2277d498ea..2385185e8a 100644 --- a/tests/frame/interchange_to_arrow_test.py +++ b/tests/frame/interchange_to_arrow_test.py @@ -13,8 +13,13 @@ pytest.importorskip("polars") pytest.importorskip("pyarrow") +XFAIL_INTERCHANGE = pytest.mark.xfail( + reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", + raises=NotImplementedError, +) +@XFAIL_INTERCHANGE def test_interchange_to_arrow() -> None: import polars as pl import pyarrow as pa @@ -26,6 +31,7 @@ def test_interchange_to_arrow() -> None: assert isinstance(result, pa.Table) +@XFAIL_INTERCHANGE def test_interchange_ibis_to_arrow( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover @@ -51,6 +57,7 @@ def test_interchange_ibis_to_arrow( assert isinstance(result, pa.Table) +@XFAIL_INTERCHANGE def test_interchange_duckdb_to_arrow() -> None: pytest.importorskip("duckdb") diff --git a/tests/frame/interchange_to_pandas_test.py b/tests/frame/interchange_to_pandas_test.py index 254e712a51..1acd79edd4 100644 --- a/tests/frame/interchange_to_pandas_test.py +++ b/tests/frame/interchange_to_pandas_test.py @@ -9,8 +9,13 @@ from tests.utils import DUCKDB_VERSION, PANDAS_VERSION data = {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0], "z": ["x", "y", "z"]} +XFAIL_INTERCHANGE = pytest.mark.xfail( + reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", + raises=NotImplementedError, +) +@XFAIL_INTERCHANGE @pytest.mark.filterwarnings("ignore:.*Interchange Protocol:DeprecationWarning") def test_interchange_to_pandas() -> None: df_raw = pd.DataFrame(data) @@ -18,6 +23,7 @@ def test_interchange_to_pandas() -> None: assert df.to_pandas().equals(df_raw) +@XFAIL_INTERCHANGE def test_interchange_ibis_to_pandas( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover @@ -42,6 +48,7 @@ def test_interchange_ibis_to_pandas( assert df.to_pandas().equals(df_raw) +@XFAIL_INTERCHANGE def test_interchange_duckdb_to_pandas(request: pytest.FixtureRequest) -> None: pytest.importorskip("duckdb") import duckdb diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py index 662693a6da..c9b4041ed8 100644 --- a/tests/translate/from_native_test.py +++ b/tests/translate/from_native_test.py @@ -308,7 +308,7 @@ def test_series_only_sqlframe() -> None: # pragma: no cover True, pytest.raises( TypeError, - match="Cannot only use `series_only`, `eager_only` or `eager_or_interchange_only` with sqlframe DataFrame", + match="Cannot only use `series_only`, `eager_only` with sqlframe DataFrame", ), ), ], diff --git a/tests/v1_test.py b/tests/v1_test.py index e8f0144e5c..ab56a15847 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -55,6 +55,12 @@ from tests.utils import Constructor, ConstructorEager +XFAIL_INTERCHANGE = pytest.mark.xfail( + reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", + raises=NotImplementedError, +) + + def test_toplevel() -> None: pytest.importorskip("pandas") import pandas as pd @@ -259,6 +265,7 @@ def test_hist_v1() -> None: assert isinstance(result, nw_v1.DataFrame) +@XFAIL_INTERCHANGE @pytest.mark.filterwarnings("ignore:.*Interchange Protocol:DeprecationWarning") @pytest.mark.skipif(PANDAS_VERSION < (2, 0), reason="requires interchange protocol") def test_is_ordered_categorical_interchange_protocol() -> None: @@ -406,6 +413,7 @@ def test_is_native_series(is_native_series: Callable[[Any], Any]) -> None: assert not is_native_series(ser) +@XFAIL_INTERCHANGE def test_get_level() -> None: pytest.importorskip("polars") import polars as pl @@ -622,6 +630,7 @@ def test_strict(strict: Any, context: Any) -> None: assert isinstance(res, np.ndarray) +@XFAIL_INTERCHANGE def test_from_native_strict_false_typing() -> None: pytest.importorskip("polars") import polars as pl From 4940eb33b7b9acad2f7c6dadfda4603c5bcbccc6 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:13:10 +0000 Subject: [PATCH 02/43] chore: Reintroduce (limited) interchange support Only 4 failing tests left --- src/narwhals/_interchange/dataframe.py | 33 +++++++++++++++++++ src/narwhals/stable/v1/__init__.py | 10 ++---- .../interchange_native_namespace_test.py | 12 +++---- tests/frame/interchange_schema_test.py | 12 +++---- tests/frame/interchange_select_test.py | 12 +++---- tests/frame/interchange_to_arrow_test.py | 7 ---- tests/frame/interchange_to_pandas_test.py | 7 ---- tests/v1_test.py | 9 ----- 8 files changed, 48 insertions(+), 54 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index ddc20a79f9..abfc14913c 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -1,8 +1,11 @@ from __future__ import annotations import enum +import sys +from functools import lru_cache from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol +from narwhals import dependencies as deps from narwhals._utils import Implementation, Version, _hasattr_static, parse_version if TYPE_CHECKING: @@ -161,3 +164,33 @@ def select(self, *exprs: str) -> Self: # pragma: no cover def supports_dataframe_interchange(obj: Any) -> TypeIs[DataFrameLike]: return _hasattr_static(obj, "__dataframe__") + + +def should_interchange(obj: object) -> TypeIs[DataFrameLike]: + return _should_interchange(type(obj)) + + +_HAS_TOP_LEVEL_DF = ( + deps.get_polars, + deps.get_pandas, + deps.get_dask_dataframe, + deps.get_modin, +) + + +# TODO @dangotbanned: ~~cudf~~, sqlframe?, pyspark?, pyspark-connect? +@lru_cache(64) +def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: + if not supports_dataframe_interchange(tp_native): + return False + exclude = tuple(mod.DataFrame for get in _HAS_TOP_LEVEL_DF if (mod := get())) + if pa := deps.get_pyarrow(): + exclude = (*exclude, pa.Table) + hooks = ( + mod.pandas.DataFrame + for name in deps.IMPORT_HOOKS + if (mod := sys.modules.get(name)) + ) + if exclude := (*exclude, *hooks): + return not issubclass(tp_native, exclude) + return True diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 4fb9b83222..6763dca22c 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -7,6 +7,7 @@ from narwhals import exceptions, functions as nw_f from narwhals._exceptions import issue_warning from narwhals._expression_parsing import ExprKind, ExprNode, is_expr +from narwhals._interchange.dataframe import InterchangeFrame, should_interchange from narwhals._typing_compat import TypeVar, assert_never from narwhals._utils import ( Implementation, @@ -656,17 +657,12 @@ def from_native( msg = f"from_native() got an unexpected keyword argument {next(iter(kwds))!r}" raise TypeError(msg) - if eager_only and eager_or_interchange_only: - msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" - raise ValueError(msg) - if eager_or_interchange_only: if eager_only: msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" raise ValueError(msg) - - msg = "TODO @dangotbanned: Isolate v1 `__dataframe__` support" - raise NotImplementedError(msg) + if should_interchange(native_object): + return DataFrame(InterchangeFrame(native_object)) return _from_native_impl( # type: ignore[no-any-return] native_object, diff --git a/tests/frame/interchange_native_namespace_test.py b/tests/frame/interchange_native_namespace_test.py index bf0fb9926f..dd9f0e70a7 100644 --- a/tests/frame/interchange_native_namespace_test.py +++ b/tests/frame/interchange_native_namespace_test.py @@ -14,13 +14,7 @@ data: Mapping[str, Any] = {"a": [1, 2, 3], "b": [4.5, 6.7, 8.9], "z": ["x", "y", "w"]} -XFAIL_INTERCHANGE = pytest.mark.xfail( - reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", - raises=NotImplementedError, -) - -@XFAIL_INTERCHANGE def test_interchange() -> None: df_pl = pl.DataFrame(data) df = nw_v1.from_native(df_pl.__dataframe__(), eager_or_interchange_only=True) @@ -39,7 +33,10 @@ def test_interchange() -> None: series.__native_namespace__() -@XFAIL_INTERCHANGE +@pytest.mark.xfail( + reason="Cannot access native namespace for interchange-level dataframes with unknown backend.", + raises=NotImplementedError, +) @pytest.mark.filterwarnings("ignore:.*The `ArrowDtype` class is not available in pandas") def test_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest @@ -63,7 +60,6 @@ def test_ibis( assert series.__native_namespace__() == ibis -@XFAIL_INTERCHANGE def test_duckdb() -> None: pytest.importorskip("duckdb") import duckdb diff --git a/tests/frame/interchange_schema_test.py b/tests/frame/interchange_schema_test.py index 0810be9394..defd120505 100644 --- a/tests/frame/interchange_schema_test.py +++ b/tests/frame/interchange_schema_test.py @@ -10,13 +10,7 @@ pytest.importorskip("polars") import polars as pl -XFAIL_INTERCHANGE = pytest.mark.xfail( - reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", - raises=NotImplementedError, -) - -@XFAIL_INTERCHANGE def test_interchange_schema() -> None: df_pl = pl.DataFrame( { @@ -74,7 +68,10 @@ def test_interchange_schema() -> None: assert df["a"].dtype == nw_v1.Int64 -@XFAIL_INTERCHANGE +@pytest.mark.xfail( + reason="Data type date32[day] not supported by interchange protocol", + raises=ValueError, +) @pytest.mark.filterwarnings("ignore:.*locale specific date formats") def test_interchange_schema_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest @@ -171,7 +168,6 @@ def test_interchange_schema_ibis( assert df.collect_schema() == expected -@XFAIL_INTERCHANGE def test_interchange_schema_duckdb() -> None: pytest.importorskip("duckdb") import duckdb diff --git a/tests/frame/interchange_select_test.py b/tests/frame/interchange_select_test.py index 4f2efc88c3..1d3d76ca8d 100644 --- a/tests/frame/interchange_select_test.py +++ b/tests/frame/interchange_select_test.py @@ -12,11 +12,6 @@ data: Mapping[str, Any] = {"a": [1, 2, 3], "b": [4.0, 5.0, 6.1], "z": ["x", "y", "z"]} -XFAIL_INTERCHANGE = pytest.mark.xfail( - reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", - raises=NotImplementedError, -) - class InterchangeDataFrame: def __init__(self, df: CustomDataFrame) -> None: @@ -44,7 +39,6 @@ def __dataframe__(self, *, allow_copy: bool = True) -> InterchangeDataFrame: return InterchangeDataFrame(self) -@XFAIL_INTERCHANGE def test_interchange() -> None: df = CustomDataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "z": [1, 4, 2]}) result = nw_v1.from_native(df, eager_or_interchange_only=True).select("a", "z") @@ -57,7 +51,10 @@ def test_interchange_non_v1() -> None: nw.from_native(df) # type: ignore[call-overload] -@XFAIL_INTERCHANGE +@pytest.mark.xfail( + reason="Expected interchange object to implement `_df` property to allow for recovering original object.", + raises=NotImplementedError, +) def test_interchange_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover @@ -84,7 +81,6 @@ def test_interchange_ibis( assert out_cols == ["a", "z"] -@XFAIL_INTERCHANGE def test_interchange_duckdb() -> None: pytest.importorskip("polars") pytest.importorskip("duckdb") diff --git a/tests/frame/interchange_to_arrow_test.py b/tests/frame/interchange_to_arrow_test.py index 2385185e8a..2277d498ea 100644 --- a/tests/frame/interchange_to_arrow_test.py +++ b/tests/frame/interchange_to_arrow_test.py @@ -13,13 +13,8 @@ pytest.importorskip("polars") pytest.importorskip("pyarrow") -XFAIL_INTERCHANGE = pytest.mark.xfail( - reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", - raises=NotImplementedError, -) -@XFAIL_INTERCHANGE def test_interchange_to_arrow() -> None: import polars as pl import pyarrow as pa @@ -31,7 +26,6 @@ def test_interchange_to_arrow() -> None: assert isinstance(result, pa.Table) -@XFAIL_INTERCHANGE def test_interchange_ibis_to_arrow( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover @@ -57,7 +51,6 @@ def test_interchange_ibis_to_arrow( assert isinstance(result, pa.Table) -@XFAIL_INTERCHANGE def test_interchange_duckdb_to_arrow() -> None: pytest.importorskip("duckdb") diff --git a/tests/frame/interchange_to_pandas_test.py b/tests/frame/interchange_to_pandas_test.py index 1acd79edd4..254e712a51 100644 --- a/tests/frame/interchange_to_pandas_test.py +++ b/tests/frame/interchange_to_pandas_test.py @@ -9,13 +9,8 @@ from tests.utils import DUCKDB_VERSION, PANDAS_VERSION data = {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0], "z": ["x", "y", "z"]} -XFAIL_INTERCHANGE = pytest.mark.xfail( - reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", - raises=NotImplementedError, -) -@XFAIL_INTERCHANGE @pytest.mark.filterwarnings("ignore:.*Interchange Protocol:DeprecationWarning") def test_interchange_to_pandas() -> None: df_raw = pd.DataFrame(data) @@ -23,7 +18,6 @@ def test_interchange_to_pandas() -> None: assert df.to_pandas().equals(df_raw) -@XFAIL_INTERCHANGE def test_interchange_ibis_to_pandas( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover @@ -48,7 +42,6 @@ def test_interchange_ibis_to_pandas( assert df.to_pandas().equals(df_raw) -@XFAIL_INTERCHANGE def test_interchange_duckdb_to_pandas(request: pytest.FixtureRequest) -> None: pytest.importorskip("duckdb") import duckdb diff --git a/tests/v1_test.py b/tests/v1_test.py index ab56a15847..e8f0144e5c 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -55,12 +55,6 @@ from tests.utils import Constructor, ConstructorEager -XFAIL_INTERCHANGE = pytest.mark.xfail( - reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support", - raises=NotImplementedError, -) - - def test_toplevel() -> None: pytest.importorskip("pandas") import pandas as pd @@ -265,7 +259,6 @@ def test_hist_v1() -> None: assert isinstance(result, nw_v1.DataFrame) -@XFAIL_INTERCHANGE @pytest.mark.filterwarnings("ignore:.*Interchange Protocol:DeprecationWarning") @pytest.mark.skipif(PANDAS_VERSION < (2, 0), reason="requires interchange protocol") def test_is_ordered_categorical_interchange_protocol() -> None: @@ -413,7 +406,6 @@ def test_is_native_series(is_native_series: Callable[[Any], Any]) -> None: assert not is_native_series(ser) -@XFAIL_INTERCHANGE def test_get_level() -> None: pytest.importorskip("polars") import polars as pl @@ -630,7 +622,6 @@ def test_strict(strict: Any, context: Any) -> None: assert isinstance(res, np.ndarray) -@XFAIL_INTERCHANGE def test_from_native_strict_false_typing() -> None: pytest.importorskip("polars") import polars as pl From 3288b0d1bb9bcba7d88ca4c68350dd8985e77353 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:18:11 +0000 Subject: [PATCH 03/43] grow up mypy --- src/narwhals/_interchange/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index abfc14913c..b92be5fcf2 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -167,7 +167,7 @@ def supports_dataframe_interchange(obj: Any) -> TypeIs[DataFrameLike]: def should_interchange(obj: object) -> TypeIs[DataFrameLike]: - return _should_interchange(type(obj)) + return _should_interchange(type(obj)) # type: ignore[arg-type] _HAS_TOP_LEVEL_DF = ( From ce86e5cb18a18c2401142cfe8edf196a133251b9 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:26:42 +0000 Subject: [PATCH 04/43] test: fix invalid test The error was checking a flag that we didn't set? --- tests/frame/interchange_schema_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/frame/interchange_schema_test.py b/tests/frame/interchange_schema_test.py index defd120505..caf572786a 100644 --- a/tests/frame/interchange_schema_test.py +++ b/tests/frame/interchange_schema_test.py @@ -237,10 +237,9 @@ def test_interchange_schema_duckdb() -> None: assert df.collect_schema() == expected -@pytest.mark.xfail(reason="TODO @dangotbanned: Isolate v1 `__dataframe__` support") def test_invalid() -> None: df = pl.DataFrame({"a": [1, 2, 3]}).__dataframe__() - with pytest.raises(TypeError, match="Cannot only use `series_only=True`"): + with pytest.raises(TypeError, match="Unsupported dataframe type"): nw_v1.from_native(df, eager_only=True) with pytest.raises(ValueError, match="Invalid parameter combination"): nw_v1.from_native(df, eager_only=True, eager_or_interchange_only=True) # type: ignore[call-overload] From 60068f0c85f95d79e1b07a430a028849a35e1092 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:30:03 +0000 Subject: [PATCH 05/43] test: isolate `test_invalid_filter` It was raising in `Series.from_iterable` before, because of `list[bool]` --- tests/frame/interchange_schema_test.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/frame/interchange_schema_test.py b/tests/frame/interchange_schema_test.py index caf572786a..4898c5728f 100644 --- a/tests/frame/interchange_schema_test.py +++ b/tests/frame/interchange_schema_test.py @@ -245,16 +245,9 @@ def test_invalid() -> None: nw_v1.from_native(df, eager_only=True, eager_or_interchange_only=True) # type: ignore[call-overload] -# TODO @dangotbanned: Fix this? -@pytest.mark.xfail( - reason=( - "Error was being raised by `InterchangeSeries._implementation`, but test uses `InterchangeFrame.filter`.\n" - "Revealed by adding `InterchangeSeries._implementation = Implementation.UNKNOWN`" - ) -) def test_invalid_filter() -> None: df = pl.DataFrame({"a": [1, 2, 3]}).__dataframe__() with pytest.raises( NotImplementedError, match="is not supported for interchange-level dataframes" ): - nw_v1.from_native(df, eager_or_interchange_only=True).filter([True, False, True]) + nw_v1.from_native(df, eager_or_interchange_only=True).filter(nw_v1.col("a") == 1) From 3cfb767401c2c6c768ad01e1d47f00bab380f0d5 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:10:33 +0000 Subject: [PATCH 06/43] chore: reintroduce `ibis` - Still quite a mess - No more dataframe in `IbisLazyFrame` - Probably too much typing --- src/narwhals/_ibis/dataframe.py | 34 +---- src/narwhals/_ibis/interchange.py | 75 +++++++++++ src/narwhals/_ibis/series.py | 41 ------ src/narwhals/_interchange/__init__.py | 6 + src/narwhals/_interchange/dataframe.py | 125 +++++++++++++++++- src/narwhals/stable/v1/__init__.py | 8 +- .../interchange_native_namespace_test.py | 4 - tests/frame/interchange_schema_test.py | 4 - tests/frame/interchange_select_test.py | 4 - 9 files changed, 209 insertions(+), 92 deletions(-) create mode 100644 src/narwhals/_ibis/interchange.py delete mode 100644 src/narwhals/_ibis/series.py diff --git a/src/narwhals/_ibis/dataframe.py b/src/narwhals/_ibis/dataframe.py index f089093803..5f4f97cf2c 100644 --- a/src/narwhals/_ibis/dataframe.py +++ b/src/narwhals/_ibis/dataframe.py @@ -27,28 +27,23 @@ from types import ModuleType from typing import TypeAlias - import pandas as pd - import pyarrow as pa from ibis.expr.operations import Binary from typing_extensions import Self, TypeIs from narwhals._compliant.typing import CompliantDataFrameAny from narwhals._ibis.group_by import IbisGroupBy from narwhals._ibis.namespace import IbisNamespace - from narwhals._ibis.series import IbisInterchangeSeries from narwhals._typing import _EagerAllowedImpl from narwhals._utils import _LimitedContext from narwhals.dataframe import LazyFrame from narwhals.dtypes import DType - from narwhals.stable.v1 import DataFrame as DataFrameV1 from narwhals.typing import AsofJoinStrategy, JoinStrategy, UniqueKeepStrategy JoinPredicates: TypeAlias = "Sequence[ir.BooleanColumn] | Sequence[str]" class IbisLazyFrame( - SQLLazyFrame["IbisExpr", "ir.Table", "LazyFrame[ir.Table] | DataFrameV1[ir.Table]"], - ValidateBackendVersion, + SQLLazyFrame["IbisExpr", "ir.Table", "LazyFrame[ir.Table]"], ValidateBackendVersion ): _implementation = Implementation.IBIS @@ -70,20 +65,9 @@ def _is_native(obj: ir.Table | Any) -> TypeIs[ir.Table]: def from_native(cls, data: ir.Table, /, *, context: _LimitedContext) -> Self: return cls(data, version=context._version) - def to_narwhals(self) -> LazyFrame[ir.Table] | DataFrameV1[ir.Table]: - if self._version is Version.V1: - from narwhals.stable.v1 import DataFrame - - return DataFrame(self) + def to_narwhals(self) -> LazyFrame[ir.Table]: return self._version.lazyframe(self) - def __narwhals_dataframe__(self) -> Self: # pragma: no cover - # Keep around for backcompat. - if self._version is not Version.V1: - msg = "__narwhals_dataframe__ is not implemented for IbisLazyFrame" - raise AttributeError(msg) - return self - def __narwhals_lazyframe__(self) -> Self: return self @@ -95,11 +79,6 @@ def __narwhals_namespace__(self) -> IbisNamespace: return IbisNamespace(version=self._version) - def get_column(self, name: str) -> IbisInterchangeSeries: - from narwhals._ibis.series import IbisInterchangeSeries - - return IbisInterchangeSeries(self.native.select(name), version=self._version) - def _iter_columns(self) -> Iterator[ir.Expr]: for name in self.columns: yield self.native[name] @@ -167,6 +146,7 @@ def drop(self, columns: Sequence[str], *, strict: bool) -> Self: selection = (col for col in self.columns if col not in columns_to_drop) return self._with_native(self.native.select(*selection)) + # TODO @dangotbanned: Figure out if this is needed def lazy(self, backend: None = None, **_: None) -> Self: # The `backend`` argument has no effect but we keep it here for # backwards compatibility because in `narwhals.stable.v1` @@ -207,14 +187,6 @@ def columns(self) -> list[str]: ) return self._cached_columns - def to_pandas(self) -> pd.DataFrame: - # only if version is v1, keep around for backcompat - return self.native.to_pandas() - - def to_arrow(self) -> pa.Table: - # only if version is v1, keep around for backcompat - return self.native.to_pyarrow() - def _with_version(self, version: Version) -> Self: return self.__class__(self.native, version=version) diff --git a/src/narwhals/_ibis/interchange.py b/src/narwhals/_ibis/interchange.py new file mode 100644 index 0000000000..e11b857467 --- /dev/null +++ b/src/narwhals/_ibis/interchange.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from narwhals import _interchange +from narwhals._ibis.utils import native_to_narwhals_dtype +from narwhals._utils import Implementation + +if TYPE_CHECKING: + from collections.abc import Sequence + + import ibis.expr.types as ir + import ibis.expr.types.dataframe_interchange as ibis_dfi + import pandas as pd + import pyarrow as pa + from typing_extensions import Self + + from narwhals.dtypes import DType + + +class IbisDataFrame(_interchange.InterchangeFrameV1["ir.Table"]): + _implementation = Implementation.IBIS + + def __init__(self, df: ir.Table | ibis_dfi.IbisDataFrame) -> None: + self._dfi: ibis_dfi.IbisDataFrame = df.__dataframe__() + + @classmethod + def from_dfi(cls, df: ibis_dfi.IbisDataFrame) -> Self: + self = cls.__new__(cls) + self._dfi = df + return self + + @property + def native(self) -> ir.Table: + return self._dfi._table + + @property + def _df(self) -> ir.Table: + return self.native + + def to_pandas(self) -> pd.DataFrame: + return self.native.to_pandas() + + def to_arrow(self) -> pa.Table: + return self.native.to_pyarrow() + + def get_column(self, name: str) -> IbisSeries: + return IbisSeries(self.native.select(name)) + + def column_names(self) -> list[str]: + return list(self.native.columns) + + def select_columns_by_name(self, names: Sequence[str]) -> Self: + return self.from_dfi(self._dfi.select_columns_by_name(names)) + + @property + def schema(self) -> dict[str, DType]: + return { + name: native_to_narwhals_dtype(dtype, self._version) + for name, dtype in self.native.schema().fields.items() + } + + +class IbisSeries(_interchange.InterchangeSeriesV1["ir.Table"]): + _implementation = Implementation.IBIS + + def __init__(self, df: ir.Table) -> None: + self._native_series: ir.Table = df + + @property + def dtype(self) -> DType: + native = next(iter(self._native_series.schema().values())) + # NOTE: `Concrete` (base class for `DataType`) has two bases that define `__hash__ = None`, + # and then decides to implement `__hash__`? Okay ... + return native_to_narwhals_dtype(native, self._version) # type: ignore[arg-type] diff --git a/src/narwhals/_ibis/series.py b/src/narwhals/_ibis/series.py deleted file mode 100644 index 3c55d3c8d2..0000000000 --- a/src/narwhals/_ibis/series.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, NoReturn - -from narwhals._ibis.utils import native_to_narwhals_dtype -from narwhals.dependencies import get_ibis - -if TYPE_CHECKING: - from types import ModuleType - - from typing_extensions import Self - - from narwhals._utils import Version - from narwhals.dtypes import DType - - -class IbisInterchangeSeries: - def __init__(self, df: Any, version: Version) -> None: - self._native_series = df - self._version = version - - def __narwhals_series__(self) -> Self: - return self - - def __native_namespace__(self) -> ModuleType: - return get_ibis() - - @property - def dtype(self) -> DType: - return native_to_narwhals_dtype( - self._native_series.schema().types[0], self._version - ) - - def __getattr__(self, attr: str) -> NoReturn: - msg = ( - f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" - "If you would like to see this kind of object better supported in " - "Narwhals, please open a feature request " - "at https://github.com/narwhals-dev/narwhals/issues." - ) - raise NotImplementedError(msg) diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index e69de29bb2..980d0421cb 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from narwhals._interchange.dataframe import ( + InterchangeFrameV1 as InterchangeFrameV1, + InterchangeSeriesV1 as InterchangeSeriesV1, +) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index b92be5fcf2..c44459a766 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -3,18 +3,23 @@ import enum import sys from functools import lru_cache -from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol +from inspect import getattr_static +from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypeVar from narwhals import dependencies as deps from narwhals._utils import Implementation, Version, _hasattr_static, parse_version if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from types import ModuleType + import pandas as pd import pyarrow as pa from typing_extensions import Self, TypeIs from narwhals._interchange.series import InterchangeSeries from narwhals.dtypes import DType + from narwhals.stable.v1 import DataFrame as DataFrameV1 class DataFrameLike(Protocol): @@ -78,6 +83,109 @@ def map_interchange_dtype_to_narwhals_dtype( # noqa: C901, PLR0911, PLR0912 raise AssertionError(msg) +Original_co = TypeVar("Original_co", bound=DataFrameLike, covariant=True) + + +class Column(Protocol): + @property + def dtype(self) -> tuple[DtypeKind, int, Any, Any] | Any: ... + + +class RecoverableColumn(Column, Protocol[Original_co]): + _version: Version = Version.V1 + _native_series: Original_co + + def __narwhals_series__(self) -> Self: + return self + + +class InterchangeSeriesV1(RecoverableColumn[Original_co], Protocol[Original_co]): + _implementation: Implementation + + @property + def dtype(self) -> DType: ... # ? + + def __native_namespace__(self) -> ModuleType: + return self._implementation.to_native_namespace() + + +class Frame(Protocol): + def __dataframe__(self, *, allow_copy: bool = True) -> Self: # pragma: no cover + return self + + def column_names(self) -> Iterable[str]: ... + def get_column_by_name(self, name: str, /) -> Column: ... + def select_columns_by_name(self, names: Sequence[str], /) -> Self: ... + + +class RecoverableFrame(Frame, Protocol[Original_co]): + _version: Version = Version.V1 + + @property + def _df(self) -> Original_co: ... + + """Allow for recovering original object. + + See https://github.com/data-apis/dataframe-api/issues/360. + """ + + def __narwhals_dataframe__(self) -> Self: + return self + + +class InterchangeFrameV1(RecoverableFrame[Original_co], Protocol[Original_co]): + _implementation: Implementation + + @property + def schema(self) -> dict[str, DType]: ... + @property + def columns(self) -> list[str]: + return list(self.column_names()) + + def to_pandas(self) -> pd.DataFrame: ... + def to_arrow(self) -> pa.Table: ... + def get_column(self, name: str, /) -> InterchangeSeriesV1[Original_co]: ... + def get_column_by_name( + self, name: str, / + ) -> InterchangeSeriesV1[Original_co]: # pragma: no cover + return self.get_column(name) + + def collect_schema(self) -> dict[str, DType]: + return self.schema + + def __native_namespace__(self) -> ModuleType: + return self._implementation.to_native_namespace() + + def simple_select(self, *column_names: str) -> Self: + return self.select_columns_by_name(column_names) + + def to_narwhals(self) -> DataFrameV1[Any]: # pragma: no cover + from narwhals.stable.v1 import DataFrame as DataFrameV1 + + return DataFrameV1(self) + + +DFI_METHODS = ( + "column_names", + "__dataframe__", + "get_chunks", + "get_column", + "get_column_by_name", + "get_columns", + "metadata", + "num_chunks", + "num_columns", + "num_rows", + "select_columns", + "select_columns_by_name", +) + +SENTINEL = object() + + +# TODO @dangotbanned: Review what is going on here +# - roll in the protocol stuff +# - integrate duckdb class InterchangeFrame: _version = Version.V1 _implementation: Final = Implementation.UNKNOWN @@ -132,7 +240,13 @@ def schema(self) -> dict[str, DType]: def columns(self) -> list[str]: return list(self._interchange_frame.column_names()) - def __getattr__(self, attr: str) -> NoReturn: + def __getattr__(self, attr: str) -> Any: + if ( + attr in DFI_METHODS + and (func := getattr_static(self._interchange_frame, attr, SENTINEL)) + is not SENTINEL + ): # pragma: no cover + return func msg = ( f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" "Hint: you probably called `nw.from_native` on an object which isn't fully " @@ -184,13 +298,10 @@ def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: if not supports_dataframe_interchange(tp_native): return False exclude = tuple(mod.DataFrame for get in _HAS_TOP_LEVEL_DF if (mod := get())) - if pa := deps.get_pyarrow(): - exclude = (*exclude, pa.Table) + exclude = (*exclude, pa.Table) if (pa := deps.get_pyarrow()) else exclude hooks = ( mod.pandas.DataFrame for name in deps.IMPORT_HOOKS if (mod := sys.modules.get(name)) ) - if exclude := (*exclude, *hooks): - return not issubclass(tp_native, exclude) - return True + return not (exclude := (*exclude, *hooks)) or not issubclass(tp_native, exclude) diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 6763dca22c..c7485535c7 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -661,13 +661,19 @@ def from_native( if eager_only: msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" raise ValueError(msg) + if should_interchange(native_object): + # TODO @dangotbanned: Handle duckdb + if dependencies.is_ibis_table(native_object): + from narwhals._ibis.interchange import IbisDataFrame + + return DataFrame(IbisDataFrame(native_object)) return DataFrame(InterchangeFrame(native_object)) return _from_native_impl( # type: ignore[no-any-return] native_object, pass_through=pass_through, - eager_only=eager_only, + eager_only=eager_only or eager_or_interchange_only, series_only=series_only, allow_series=allow_series, version=Version.V1, diff --git a/tests/frame/interchange_native_namespace_test.py b/tests/frame/interchange_native_namespace_test.py index dd9f0e70a7..79a92ef6c9 100644 --- a/tests/frame/interchange_native_namespace_test.py +++ b/tests/frame/interchange_native_namespace_test.py @@ -33,10 +33,6 @@ def test_interchange() -> None: series.__native_namespace__() -@pytest.mark.xfail( - reason="Cannot access native namespace for interchange-level dataframes with unknown backend.", - raises=NotImplementedError, -) @pytest.mark.filterwarnings("ignore:.*The `ArrowDtype` class is not available in pandas") def test_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest diff --git a/tests/frame/interchange_schema_test.py b/tests/frame/interchange_schema_test.py index 4898c5728f..0df3f23e18 100644 --- a/tests/frame/interchange_schema_test.py +++ b/tests/frame/interchange_schema_test.py @@ -68,10 +68,6 @@ def test_interchange_schema() -> None: assert df["a"].dtype == nw_v1.Int64 -@pytest.mark.xfail( - reason="Data type date32[day] not supported by interchange protocol", - raises=ValueError, -) @pytest.mark.filterwarnings("ignore:.*locale specific date formats") def test_interchange_schema_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest diff --git a/tests/frame/interchange_select_test.py b/tests/frame/interchange_select_test.py index 1d3d76ca8d..a927ba18c6 100644 --- a/tests/frame/interchange_select_test.py +++ b/tests/frame/interchange_select_test.py @@ -51,10 +51,6 @@ def test_interchange_non_v1() -> None: nw.from_native(df) # type: ignore[call-overload] -@pytest.mark.xfail( - reason="Expected interchange object to implement `_df` property to allow for recovering original object.", - raises=NotImplementedError, -) def test_interchange_ibis( tmpdir: pytest.TempdirFactory, request: pytest.FixtureRequest ) -> None: # pragma: no cover From 6ba4d66fb5f9f5544153f36cddfe73728307124a Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:28:50 +0000 Subject: [PATCH 07/43] fix: remember that `duckdb` doesn't support interchange woops --- src/narwhals/_interchange/dataframe.py | 4 +++- src/narwhals/stable/v1/__init__.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index c44459a766..02092bf244 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -296,7 +296,9 @@ def should_interchange(obj: object) -> TypeIs[DataFrameLike]: @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: if not supports_dataframe_interchange(tp_native): - return False + return (duckdb := deps.get_duckdb()) and issubclass( + tp_native, duckdb.DuckDBPyRelation + ) exclude = tuple(mod.DataFrame for get in _HAS_TOP_LEVEL_DF if (mod := get())) exclude = (*exclude, pa.Table) if (pa := deps.get_pyarrow()) else exclude hooks = ( diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index c7485535c7..59d5335bce 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -668,6 +668,11 @@ def from_native( from narwhals._ibis.interchange import IbisDataFrame return DataFrame(IbisDataFrame(native_object)) + if dependencies.is_duckdb_relation(native_object): + fake_interchange = Version.V1.namespace.from_backend( + "duckdb" + ).compliant.from_native(native_object) + return DataFrame(fake_interchange) return DataFrame(InterchangeFrame(native_object)) return _from_native_impl( # type: ignore[no-any-return] From 902796adb2d5ca64ecc67c611af45ce8962716cd Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:04:46 +0000 Subject: [PATCH 08/43] fix: add compat alias for marimo Not sure `to_native` was ever supposed to work on interchange, but they're testing it --- src/narwhals/_interchange/dataframe.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 02092bf244..5bb7137d0d 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -142,6 +142,10 @@ def schema(self) -> dict[str, DType]: ... def columns(self) -> list[str]: return list(self.column_names()) + @property + def _native_frame(self) -> Original_co: + return self._df + def to_pandas(self) -> pd.DataFrame: ... def to_arrow(self) -> pa.Table: ... def get_column(self, name: str, /) -> InterchangeSeriesV1[Original_co]: ... From b7e533d9f2e10be02713617b3cfb556eb236da66 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:08:52 +0000 Subject: [PATCH 09/43] ignore mypy --- src/narwhals/_ibis/interchange.py | 2 +- src/narwhals/_interchange/dataframe.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/narwhals/_ibis/interchange.py b/src/narwhals/_ibis/interchange.py index e11b857467..312da5c244 100644 --- a/src/narwhals/_ibis/interchange.py +++ b/src/narwhals/_ibis/interchange.py @@ -72,4 +72,4 @@ def dtype(self) -> DType: native = next(iter(self._native_series.schema().values())) # NOTE: `Concrete` (base class for `DataType`) has two bases that define `__hash__ = None`, # and then decides to implement `__hash__`? Okay ... - return native_to_narwhals_dtype(native, self._version) # type: ignore[arg-type] + return native_to_narwhals_dtype(native, self._version) # pyright: ignore[reportArgumentType] diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 5bb7137d0d..5705bbb88d 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -91,7 +91,7 @@ class Column(Protocol): def dtype(self) -> tuple[DtypeKind, int, Any, Any] | Any: ... -class RecoverableColumn(Column, Protocol[Original_co]): +class RecoverableColumn(Column, Protocol[Original_co]): # type: ignore[misc] _version: Version = Version.V1 _native_series: Original_co @@ -99,7 +99,7 @@ def __narwhals_series__(self) -> Self: return self -class InterchangeSeriesV1(RecoverableColumn[Original_co], Protocol[Original_co]): +class InterchangeSeriesV1(RecoverableColumn[Original_co], Protocol[Original_co]): # type: ignore[misc] _implementation: Implementation @property @@ -166,7 +166,7 @@ def simple_select(self, *column_names: str) -> Self: def to_narwhals(self) -> DataFrameV1[Any]: # pragma: no cover from narwhals.stable.v1 import DataFrame as DataFrameV1 - return DataFrameV1(self) + return DataFrameV1(self) # type: ignore[no-any-return] DFI_METHODS = ( From 3ceb3e050c700af3b0cafaa4f01f0bcc2627aa2f Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:19:47 +0000 Subject: [PATCH 10/43] test: add `tests.interchange` --- tests/interchange/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/interchange/__init__.py diff --git a/tests/interchange/__init__.py b/tests/interchange/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From ecfc247969a2bc7a5468020e5bd6bd127e4ecbba Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:25:07 +0000 Subject: [PATCH 11/43] test: migrate `get_level` tests --- tests/interchange/get_level_test.py | 74 +++++++++++++++++++++++++++++ tests/v1_test.py | 65 +------------------------ 2 files changed, 75 insertions(+), 64 deletions(-) create mode 100644 tests/interchange/get_level_test.py diff --git a/tests/interchange/get_level_test.py b/tests/interchange/get_level_test.py new file mode 100644 index 0000000000..fc3356ecdb --- /dev/null +++ b/tests/interchange/get_level_test.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeAlias + +import pytest + +import narwhals as nw +import narwhals.stable.v1 as nw_v1 + +if TYPE_CHECKING: + from narwhals._typing import EagerAllowed + + +MainInstances: TypeAlias = tuple[nw.DataFrame[Any], nw.LazyFrame[Any], nw.Series[Any]] + + +@pytest.fixture +def main_instances(eager_implementation: EagerAllowed) -> MainInstances: + df = nw.DataFrame.from_dict({"a": [1, 2, 3]}, backend=eager_implementation) + return df, df.lazy(), df.get_column("a") + + +def test_get_level() -> None: + pytest.importorskip("polars") + import polars as pl + + df = pl.DataFrame({"a": [1, 2, 3]}) + assert nw_v1.get_level(nw_v1.from_native(df)) == "full" + assert ( + nw_v1.get_level( + nw_v1.from_native(df.__dataframe__(), eager_or_interchange_only=True) + ) + == "interchange" + ) + + +def test_v1_explicit_level_kwarg() -> None: + pytest.importorskip("polars") + import polars as pl + + nw_lf = nw_v1.from_native(pl.LazyFrame({"a": [1]})) + rewrapped_lf = nw_v1.LazyFrame[pl.LazyFrame](nw_lf._compliant_frame) + assert nw_v1.get_level(rewrapped_lf) == "lazy" + + nw_s = nw_v1.from_native(pl.Series(name="a", values=[1]), series_only=True) + rewrapped_s = nw_v1.Series(nw_s._compliant_series) + assert nw_v1.get_level(rewrapped_s) == "full" + + +def test_get_level_raises_main(main_instances: MainInstances) -> None: + df, lf, ser = main_instances + with pytest.raises(TypeError): + nw_v1.get_level(df) # type: ignore[arg-type] + with pytest.raises(TypeError): + nw_v1.get_level(ser) # type: ignore[arg-type] + with pytest.raises(TypeError): + nw_v1.get_level(lf) # type: ignore[arg-type] + + +def test_get_level_main_via_v1_from_native(main_instances: MainInstances) -> None: + df, lf, ser = main_instances + df_v1 = nw_v1.from_native(df) + # NOTE: Typing doesn't match the behavior following (#3515) + _lf_v1 = nw_v1.from_native(lf) # type: ignore[call-overload] + lf_v1: nw_v1.LazyFrame[Any] = _lf_v1 + ser_v1 = nw_v1.from_native(ser, series_only=True) + + assert nw_v1.get_level(df_v1) == "full" + assert nw_v1.get_level(ser_v1) == "full" + if lf_v1.implementation.is_polars(): + assert nw_v1.get_level(lf_v1) == "lazy" + else: + # Well this is strange? + assert nw_v1.get_level(lf_v1) == "full" diff --git a/tests/v1_test.py b/tests/v1_test.py index 289014e1d4..aec5839520 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -5,7 +5,7 @@ from collections import deque from contextlib import nullcontext as does_not_raise from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, TypeAlias, cast +from typing import TYPE_CHECKING, Any, cast import pytest @@ -422,69 +422,6 @@ def test_is_native_series(is_native_series: Callable[[Any], Any]) -> None: assert not is_native_series(ser) -def test_get_level() -> None: - pytest.importorskip("polars") - import polars as pl - - df = pl.DataFrame({"a": [1, 2, 3]}) - assert nw_v1.get_level(nw_v1.from_native(df)) == "full" - assert ( - nw_v1.get_level( - nw_v1.from_native(df.__dataframe__(), eager_or_interchange_only=True) - ) - == "interchange" - ) - - -def test_v1_explicit_level_kwarg() -> None: - pytest.importorskip("polars") - import polars as pl - - nw_lf = nw_v1.from_native(pl.LazyFrame({"a": [1]})) - rewrapped_lf = nw_v1.LazyFrame[pl.LazyFrame](nw_lf._compliant_frame) - assert nw_v1.get_level(rewrapped_lf) == "lazy" - - nw_s = nw_v1.from_native(pl.Series(name="a", values=[1]), series_only=True) - rewrapped_s = nw_v1.Series(nw_s._compliant_series) - assert nw_v1.get_level(rewrapped_s) == "full" - - -MainInstances: TypeAlias = tuple[nw.DataFrame[Any], nw.LazyFrame[Any], nw.Series[Any]] - - -@pytest.fixture -def main_instances(eager_implementation: EagerAllowed) -> MainInstances: - df = nw.DataFrame.from_dict({"a": [1, 2, 3]}, backend=eager_implementation) - return df, df.lazy(), df.get_column("a") - - -def test_get_level_raises_main(main_instances: MainInstances) -> None: - df, lf, ser = main_instances - with pytest.raises(TypeError): - nw_v1.get_level(df) # type: ignore[arg-type] - with pytest.raises(TypeError): - nw_v1.get_level(ser) # type: ignore[arg-type] - with pytest.raises(TypeError): - nw_v1.get_level(lf) # type: ignore[arg-type] - - -def test_get_level_main_via_v1_from_native(main_instances: MainInstances) -> None: - df, lf, ser = main_instances - df_v1 = nw_v1.from_native(df) - # NOTE: Typing doesn't match the behavior following (#3515) - _lf_v1 = nw_v1.from_native(lf) # type: ignore[call-overload] - lf_v1: nw_v1.LazyFrame[Any] = _lf_v1 - ser_v1 = nw_v1.from_native(ser, series_only=True) - - assert nw_v1.get_level(df_v1) == "full" - assert nw_v1.get_level(ser_v1) == "full" - if lf_v1.implementation.is_polars(): - assert nw_v1.get_level(lf_v1) == "lazy" - else: - # Well this is strange? - assert nw_v1.get_level(lf_v1) == "full" - - def test_any_horizontal() -> None: # here, it defaults to Kleene logic. pytest.importorskip("polars") From 545e9a301287f9e80bd08e64f0d7d4cdf5b385ad Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:42:39 +0000 Subject: [PATCH 12/43] test: migrate `from_native` tests --- tests/interchange/from_native_test.py | 53 +++++++++++++++++++++++++++ tests/translate/from_native_test.py | 12 ------ tests/v1_test.py | 25 ------------- tests/v2_test.py | 14 ------- 4 files changed, 53 insertions(+), 51 deletions(-) create mode 100644 tests/interchange/from_native_test.py diff --git a/tests/interchange/from_native_test.py b/tests/interchange/from_native_test.py new file mode 100644 index 0000000000..b43924579c --- /dev/null +++ b/tests/interchange/from_native_test.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +import narwhals as nw +import narwhals.stable.v1 as nw_v1 +import narwhals.stable.v2 as nw_v2 +from tests.utils import PANDAS_VERSION + + +class MockDf: + def __dataframe__(self) -> None: # pragma: no cover + return + + +@pytest.fixture +def mockdf() -> MockDf: + return MockDf() + + +def test_main_reject(mockdf: MockDf) -> None: + result = nw.from_native(mockdf, pass_through=True) + assert result is mockdf + with pytest.raises(TypeError): + nw.from_native(mockdf) # type: ignore[call-overload] + + +def test_v1_non_strict(mockdf: MockDf) -> None: + result = nw_v1.from_native(mockdf, eager_only=True, strict=False) + # mypy issue? + assert result is mockdf # type: ignore[comparison-overlap] + + +def test_v2_reject(mockdf: MockDf) -> None: + with pytest.raises(TypeError, match="Unsupported dataframe type"): + # Typing rejection **is** expected in v2, since IntoDataFrame excludes + # DataFrameLike objects! + nw_v2.from_native(mockdf) # type: ignore[call-overload] + assert nw_v2.from_native(mockdf, pass_through=True) is mockdf + + +@pytest.mark.filterwarnings("ignore:.*Interchange Protocol:DeprecationWarning") +@pytest.mark.skipif(PANDAS_VERSION < (2, 0), reason="requires interchange protocol") +def test_is_ordered_categorical() -> None: + pytest.importorskip("pandas") + import pandas as pd + + data = {"a": ["a", "b"]} + native = pd.DataFrame(data, dtype=pd.CategoricalDtype(ordered=True)) + interchange = native.__dataframe__() + df = nw_v1.from_native(interchange, eager_or_interchange_only=True) + series = df["a"] + assert nw_v1.is_ordered_categorical(series) diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py index c9b4041ed8..aeb1c3b16a 100644 --- a/tests/translate/from_native_test.py +++ b/tests/translate/from_native_test.py @@ -322,18 +322,6 @@ def test_eager_only_sqlframe(eager_only: Any, context: Any) -> None: # pragma: assert isinstance(res, nw.LazyFrame) -def test_interchange_protocol_non_v1() -> None: - class MockDf: - def __dataframe__(self) -> None: # pragma: no cover - pass - - mockdf = MockDf() - result = nw.from_native(mockdf, pass_through=True) - assert result is mockdf - with pytest.raises(TypeError): - nw.from_native(mockdf) # type: ignore[call-overload] - - def test_from_native_strict_native_series() -> None: obj: list[int] = [1, 2, 3, 4] array_like = cast("Iterable[Any]", obj) diff --git a/tests/v1_test.py b/tests/v1_test.py index aec5839520..d7d3b05a97 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -275,20 +275,6 @@ def test_hist_v1() -> None: assert isinstance(result, nw_v1.DataFrame) -@pytest.mark.filterwarnings("ignore:.*Interchange Protocol:DeprecationWarning") -@pytest.mark.skipif(PANDAS_VERSION < (2, 0), reason="requires interchange protocol") -def test_is_ordered_categorical_interchange_protocol() -> None: - pytest.importorskip("pandas") - import pandas as pd - - df = pd.DataFrame( - {"a": ["a", "b"]}, dtype=pd.CategoricalDtype(ordered=True) - ).__dataframe__() - assert nw_v1.is_ordered_categorical( - nw_v1.from_native(df, eager_or_interchange_only=True)["a"] - ) - - def test_all_nulls_pandas() -> None: pytest.importorskip("pandas") import pandas as pd @@ -590,17 +576,6 @@ def test_from_native_strict_false_invalid() -> None: nw_v1.from_native({"a": [1, 2, 3]}, strict=True, pass_through=False) # type: ignore[call-overload] -def test_from_mock_interchange_protocol_non_strict() -> None: - class MockDf: - def __dataframe__(self) -> None: # pragma: no cover - pass - - mockdf = MockDf() - result = nw_v1.from_native(mockdf, eager_only=True, strict=False) - # mypy issue? - assert result is mockdf # type: ignore[comparison-overlap] - - def test_from_native_lazyframe() -> None: pytest.importorskip("polars") import polars as pl diff --git a/tests/v2_test.py b/tests/v2_test.py index 112fdd71f5..42ecb790a3 100644 --- a/tests/v2_test.py +++ b/tests/v2_test.py @@ -578,17 +578,3 @@ def test_first_last() -> None: result = df.select(b=nw_v2.col("a").first(), c=nw_v2.col("a").last()) expected = {"b": [0], "c": [-1]} assert_equal_data(result, expected) - - -def test_from_mock_interchange_protocol_rejected() -> None: - """v2 must reject objects which only implement `__dataframe__`.""" - - class MockDf: - def __dataframe__(self) -> None: ... # pragma: no cover - - mockdf = MockDf() - with pytest.raises(TypeError, match="Unsupported dataframe type"): - # Typing rejection **is** expected in v2, since IntoDataFrame excludes - # DataFrameLike objects! - nw_v2.from_native(mockdf) # type: ignore[call-overload] - assert nw_v2.from_native(mockdf, pass_through=True) is mockdf From fc21485128a5bb0695f073c4d9fd120e6f247779 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:46:27 +0000 Subject: [PATCH 13/43] test: migrate `interchange_*` test modules --- .../native_namespace_test.py} | 0 .../interchange_schema_test.py => interchange/schema_test.py} | 0 .../interchange_select_test.py => interchange/select_test.py} | 0 .../interchange_to_arrow_test.py => interchange/to_arrow_test.py} | 0 .../to_pandas_test.py} | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename tests/{frame/interchange_native_namespace_test.py => interchange/native_namespace_test.py} (100%) rename tests/{frame/interchange_schema_test.py => interchange/schema_test.py} (100%) rename tests/{frame/interchange_select_test.py => interchange/select_test.py} (100%) rename tests/{frame/interchange_to_arrow_test.py => interchange/to_arrow_test.py} (100%) rename tests/{frame/interchange_to_pandas_test.py => interchange/to_pandas_test.py} (100%) diff --git a/tests/frame/interchange_native_namespace_test.py b/tests/interchange/native_namespace_test.py similarity index 100% rename from tests/frame/interchange_native_namespace_test.py rename to tests/interchange/native_namespace_test.py diff --git a/tests/frame/interchange_schema_test.py b/tests/interchange/schema_test.py similarity index 100% rename from tests/frame/interchange_schema_test.py rename to tests/interchange/schema_test.py diff --git a/tests/frame/interchange_select_test.py b/tests/interchange/select_test.py similarity index 100% rename from tests/frame/interchange_select_test.py rename to tests/interchange/select_test.py diff --git a/tests/frame/interchange_to_arrow_test.py b/tests/interchange/to_arrow_test.py similarity index 100% rename from tests/frame/interchange_to_arrow_test.py rename to tests/interchange/to_arrow_test.py diff --git a/tests/frame/interchange_to_pandas_test.py b/tests/interchange/to_pandas_test.py similarity index 100% rename from tests/frame/interchange_to_pandas_test.py rename to tests/interchange/to_pandas_test.py From af940e6b1af0dc45f190d6cc3d3048947f766df6 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:50:38 +0000 Subject: [PATCH 14/43] docs: add a package doc --- tests/interchange/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/interchange/__init__.py b/tests/interchange/__init__.py index e69de29bb2..565f52b60e 100644 --- a/tests/interchange/__init__.py +++ b/tests/interchange/__init__.py @@ -0,0 +1,4 @@ +"""All the tests related to our deprecated [`__dataframe__`] support. + +[`__dataframe__`]: https://github.com/data-apis/dataframe-api +""" From 68506eaa346cc8063fa602ac67057c1ab88c1af2 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:47:42 +0000 Subject: [PATCH 15/43] fix: Don't require `eager_or_interchange_only=True` --- src/narwhals/stable/v1/__init__.py | 43 +++++++++++++++++------------- tests/interchange/schema_test.py | 2 +- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index c7a94fca93..07484ef446 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -622,7 +622,7 @@ def from_native( ) -> Any: ... -def from_native( +def from_native( # noqa: PLR0911 native_object: IntoDataFrameT | IntoLazyFrameT | IntoFrame @@ -656,24 +656,29 @@ def from_native( if kwds: msg = f"from_native() got an unexpected keyword argument {next(iter(kwds))!r}" raise TypeError(msg) - - if eager_or_interchange_only: - if eager_only: - msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" - raise ValueError(msg) - - if should_interchange(native_object): - # TODO @dangotbanned: Handle duckdb - if dependencies.is_ibis_table(native_object): - from narwhals._ibis.interchange import IbisDataFrame - - return DataFrame(IbisDataFrame(native_object)) - if dependencies.is_duckdb_relation(native_object): - fake_interchange = Version.V1.namespace.from_backend( - "duckdb" - ).compliant.from_native(native_object) - return DataFrame(fake_interchange) - return DataFrame(InterchangeFrame(native_object)) + if eager_only and eager_or_interchange_only: + msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`" + raise ValueError(msg) + + if should_interchange(native_object): + if eager_only or series_only: + if not pass_through: + msg = "Cannot use `series_only=True` or `eager_only=True` with object which only implements `__dataframe__`" + raise TypeError(msg) + return native_object # type: ignore[return-value] + + if dependencies.is_ibis_table(native_object): + from narwhals._ibis.interchange import IbisDataFrame + + return DataFrame(IbisDataFrame(native_object)) + + # TODO @dangotbanned: Isolate eager duckdb from `DuckDBLazyFrame` + if dependencies.is_duckdb_relation(native_object): + fake_interchange = Version.V1.namespace.from_backend( + "duckdb" + ).compliant.from_native(native_object) + return DataFrame(fake_interchange) + return DataFrame(InterchangeFrame(native_object)) return _from_native_impl( # type: ignore[no-any-return] native_object, diff --git a/tests/interchange/schema_test.py b/tests/interchange/schema_test.py index 0df3f23e18..c4396254aa 100644 --- a/tests/interchange/schema_test.py +++ b/tests/interchange/schema_test.py @@ -235,7 +235,7 @@ def test_interchange_schema_duckdb() -> None: def test_invalid() -> None: df = pl.DataFrame({"a": [1, 2, 3]}).__dataframe__() - with pytest.raises(TypeError, match="Unsupported dataframe type"): + with pytest.raises(TypeError, match=r"`eager_only=True`.+`__dataframe__`"): nw_v1.from_native(df, eager_only=True) with pytest.raises(ValueError, match="Invalid parameter combination"): nw_v1.from_native(df, eager_only=True, eager_or_interchange_only=True) # type: ignore[call-overload] From d7c5b450f1e597d6b94d4fbcb6743091d3223205 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:48:37 +0000 Subject: [PATCH 16/43] test: isolate interchange `is_into_*frame` tests kinda --- tests/interchange/dependencies_test.py | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/interchange/dependencies_test.py diff --git a/tests/interchange/dependencies_test.py b/tests/interchange/dependencies_test.py new file mode 100644 index 0000000000..688bd56ef6 --- /dev/null +++ b/tests/interchange/dependencies_test.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeAlias + +import pytest + +import narwhals.stable.v1 as nw_v1 +from narwhals.stable.v1.dependencies import ( + is_into_dataframe as v1_is_into_dataframe, + is_into_lazyframe as v1_is_into_lazyframe, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + import duckdb + import ibis + + Interchange: TypeAlias = duckdb.DuckDBPyRelation | ibis.Table + + +@pytest.fixture +def data() -> dict[str, Any]: + return {"a": [1, 2, 3], "b": [4, 5, 6]} + + +@pytest.fixture +def frame(constructor: Callable[[Any], Interchange], data: dict[str, Any]) -> Interchange: + name = str(constructor) + if "duckdb" in name or "ibis" in name: + return constructor(data) + pytest.skip("non-interchange frames are checked in other tests") + + +def test_is_into_dataframe(frame: Interchange) -> None: + nw_v1_frame_1 = nw_v1.from_native(frame, eager_or_interchange_only=True) + assert v1_is_into_dataframe(nw_v1_frame_1) + nw_v1_frame_2 = nw_v1.from_native(frame) + assert v1_is_into_dataframe(nw_v1_frame_2) + + +def test_is_into_lazyframe(frame: Interchange) -> None: + nw_v1_frame_1 = nw_v1.from_native(frame, eager_or_interchange_only=True) + assert v1_is_into_lazyframe(nw_v1_frame_1) is False + nw_v1_frame_2 = nw_v1.from_native(frame) + assert v1_is_into_lazyframe(nw_v1_frame_2) is False + + +@pytest.mark.xfail( + reason="https://github.com/narwhals-dev/narwhals/pull/3613#discussion_r3288440039" +) +def test_is_into_dataframe_native(frame: Interchange) -> None: + assert v1_is_into_dataframe(frame) + + +@pytest.mark.xfail( + reason="https://github.com/narwhals-dev/narwhals/pull/3613#discussion_r3288440039" +) +def test_is_into_lazyframe_native(frame: Interchange) -> None: + assert v1_is_into_lazyframe(frame) is False From edc6feaf88b13532120044cf7eac2cbe0b573f04 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:50:38 +0000 Subject: [PATCH 17/43] fix: Stop expecting interchange to support everything May reveal some more downstream dependencies --- src/narwhals/_interchange/dataframe.py | 95 +++++++++++++------------- tests/v1_test.py | 28 ++++++-- 2 files changed, 71 insertions(+), 52 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 5705bbb88d..0554f7b165 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -110,7 +110,7 @@ def __native_namespace__(self) -> ModuleType: class Frame(Protocol): - def __dataframe__(self, *, allow_copy: bool = True) -> Self: # pragma: no cover + def __dataframe__(self, *_: Any, **__: Any) -> Self: # pragma: no cover return self def column_names(self) -> Iterable[str]: ... @@ -133,7 +133,44 @@ def __narwhals_dataframe__(self) -> Self: return self -class InterchangeFrameV1(RecoverableFrame[Original_co], Protocol[Original_co]): +DFI_METHODS = ( + "column_names", + "__dataframe__", + "get_chunks", + "get_column", + "get_column_by_name", + "get_columns", + "metadata", + "num_chunks", + "num_columns", + "num_rows", + "select_columns", + "select_columns_by_name", +) + +SENTINEL = object() + + +class WrapsInterchangeFrame(Protocol): + _dfi: Frame + + def __getattr__(self, attr: str) -> Any: + if ( + attr in DFI_METHODS + and (func := getattr_static(self._dfi, attr, SENTINEL)) is not SENTINEL + ): + return func + msg = ( + f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" + "Hint: you probably called `from_native` on an object which isn't fully " + "supported by `narwhals.stable.v1`, yet implements `__dataframe__`." + ) + raise NotImplementedError(msg) + + +class InterchangeFrameV1( + WrapsInterchangeFrame, RecoverableFrame[Original_co], Protocol[Original_co] +): _implementation: Implementation @property @@ -169,33 +206,15 @@ def to_narwhals(self) -> DataFrameV1[Any]: # pragma: no cover return DataFrameV1(self) # type: ignore[no-any-return] -DFI_METHODS = ( - "column_names", - "__dataframe__", - "get_chunks", - "get_column", - "get_column_by_name", - "get_columns", - "metadata", - "num_chunks", - "num_columns", - "num_rows", - "select_columns", - "select_columns_by_name", -) - -SENTINEL = object() - - # TODO @dangotbanned: Review what is going on here # - roll in the protocol stuff # - integrate duckdb -class InterchangeFrame: +class InterchangeFrame(WrapsInterchangeFrame): _version = Version.V1 _implementation: Final = Implementation.UNKNOWN def __init__(self, df: DataFrameLike) -> None: - self._interchange_frame = df.__dataframe__() + self._dfi = df.__dataframe__() def __narwhals_dataframe__(self) -> Self: return self @@ -211,7 +230,7 @@ def __native_namespace__(self) -> NoReturn: def get_column(self, name: str) -> InterchangeSeries: from narwhals._interchange.series import InterchangeSeries - return InterchangeSeries(self._interchange_frame.get_column_by_name(name)) + return InterchangeSeries(self._dfi.get_column_by_name(name)) def to_pandas(self) -> pd.DataFrame: import pandas as pd # ignore-banned-import() @@ -222,53 +241,37 @@ def to_pandas(self) -> pd.DataFrame: f" 'pandas>=1.5.0' to be installed, found {pd.__version__}" ) raise NotImplementedError(msg) - return pd.api.interchange.from_dataframe(self._interchange_frame) + return pd.api.interchange.from_dataframe(self._dfi) def to_arrow(self) -> pa.Table: from pyarrow.interchange.from_dataframe import ( # ignore-banned-import() from_dataframe, ) - return from_dataframe(self._interchange_frame) + return from_dataframe(self._dfi) @property def schema(self) -> dict[str, DType]: return { column_name: map_interchange_dtype_to_narwhals_dtype( - self._interchange_frame.get_column_by_name(column_name).dtype + self._dfi.get_column_by_name(column_name).dtype ) - for column_name in self._interchange_frame.column_names() + for column_name in self._dfi.column_names() } @property def columns(self) -> list[str]: - return list(self._interchange_frame.column_names()) - - def __getattr__(self, attr: str) -> Any: - if ( - attr in DFI_METHODS - and (func := getattr_static(self._interchange_frame, attr, SENTINEL)) - is not SENTINEL - ): # pragma: no cover - return func - msg = ( - f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" - "Hint: you probably called `nw.from_native` on an object which isn't fully " - "supported by Narwhals, yet implements `__dataframe__`. If you would like to " - "see this kind of object supported in Narwhals, please open a feature request " - "at https://github.com/narwhals-dev/narwhals/issues." - ) - raise NotImplementedError(msg) + return list(self._dfi.column_names()) def simple_select(self, *column_names: str) -> Self: - frame = self._interchange_frame.select_columns_by_name(list(column_names)) + frame = self._dfi.select_columns_by_name(list(column_names)) if not hasattr(frame, "_df"): # pragma: no cover msg = ( "Expected interchange object to implement `_df` property to allow for recovering original object.\n" "See https://github.com/data-apis/dataframe-api/issues/360." ) raise NotImplementedError(msg) - return self.__class__(frame._df) + return self.__class__(frame._df) # pyright: ignore[reportAttributeAccessIssue] def select(self, *exprs: str) -> Self: # pragma: no cover msg = ( diff --git a/tests/v1_test.py b/tests/v1_test.py index d7d3b05a97..7a431f85c3 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -44,15 +44,28 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence - from typing_extensions import assert_type + from typing_extensions import LiteralString, assert_type from narwhals._typing import EagerAllowed from narwhals.dtypes import DType - from narwhals.stable.v1.typing import IntoDataFrameT + from narwhals.stable.v1.typing import Frame, IntoDataFrameT from narwhals.typing import IntoDType, _1DArray, _2DArray from tests.utils import Constructor, ConstructorEager +# TODO @dangotbanned: Include duckdb after checking ci fallout for ibis +def xfail_interchange( + method: LiteralString, frame: Frame, request: pytest.FixtureRequest +) -> None: + """Ensure we raise when trying to use a non interchange-level method.""" + marker = pytest.mark.xfail( + frame.implementation.is_ibis(), + reason=f"{method!r} is not supported for interchange-level dataframes.", + raises=NotImplementedError, + ) + request.applymarker(marker) + + def test_toplevel() -> None: pytest.importorskip("pandas") import pandas as pd @@ -446,12 +459,13 @@ def test_all_horizontal() -> None: assert_equal_data(result, expected) -def test_with_row_index(constructor: Constructor) -> None: +def test_with_row_index(constructor: Constructor, request: pytest.FixtureRequest) -> None: if "duckdb" in str(constructor) and DUCKDB_VERSION < (1, 3): pytest.skip() data = {"abc": ["foo", "bars"], "xyz": [100, 200], "const": [42, 42]} frame = nw_v1.from_native(constructor(data)) + xfail_interchange("with_row_index", frame, request) msg = "Cannot pass `order_by`" context = ( @@ -877,8 +891,10 @@ def test_is_frame() -> None: assert nw_v1.dependencies.is_narwhals_dataframe(lf.collect()) -def test_with_version(constructor: Constructor) -> None: - lf = nw_v1.from_native(constructor({"a": [1, 2]})).lazy() +def test_with_version(constructor: Constructor, request: pytest.FixtureRequest) -> None: + frame = nw_v1.from_native(constructor({"a": [1, 2]})) + xfail_interchange("lazy", frame, request) + lf = frame.lazy() assert isinstance(lf, nw_v1.LazyFrame) assert lf._compliant_frame._with_version(Version.MAIN)._version is Version.MAIN @@ -1188,7 +1204,7 @@ def test_any_value_expr(constructor: Constructor, request: pytest.FixtureRequest "c": [None, None, 1, None, 2, None], } df = nw_v1.from_native(constructor(data)) - + xfail_interchange("__narwhals_namespace__", df, request) with pytest.warns(NarwhalsUnstableWarning): df.select(nw_v1.col("a", "b").any_value()) From 4a85d7116b326ab52f5a2c774e2fc7987c8bc2f3 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:35:55 +0000 Subject: [PATCH 18/43] chore: isolate `duckdb` interchange - Everything left is fully covered - pretty sure marimo needs some more --- src/narwhals/_duckdb/dataframe.py | 71 ++++------------------ src/narwhals/_duckdb/interchange.py | 82 ++++++++++++++++++++++++++ src/narwhals/_duckdb/series.py | 44 -------------- src/narwhals/_ibis/dataframe.py | 11 ---- src/narwhals/_interchange/__init__.py | 1 + src/narwhals/_interchange/dataframe.py | 30 +++++++--- src/narwhals/stable/v1/__init__.py | 8 +-- tests/v1_test.py | 12 ++-- 8 files changed, 127 insertions(+), 132 deletions(-) create mode 100644 src/narwhals/_duckdb/interchange.py delete mode 100644 src/narwhals/_duckdb/series.py diff --git a/src/narwhals/_duckdb/dataframe.py b/src/narwhals/_duckdb/dataframe.py index 355b34a394..fe13fd30af 100644 --- a/src/narwhals/_duckdb/dataframe.py +++ b/src/narwhals/_duckdb/dataframe.py @@ -8,6 +8,7 @@ from duckdb import StarExpression from narwhals._duckdb.utils import ( + BACKEND_VERSION, DeferredTimeZone, F, catch_duckdb_exception, @@ -37,7 +38,6 @@ from pathlib import Path from types import ModuleType - import pandas as pd import pyarrow as pa from duckdb import Expression from typing_extensions import Self, TypeIs @@ -46,21 +46,17 @@ from narwhals._duckdb.expr import DuckDBExpr from narwhals._duckdb.group_by import DuckDBGroupBy from narwhals._duckdb.namespace import DuckDBNamespace - from narwhals._duckdb.series import DuckDBInterchangeSeries from narwhals._duckdb.utils import duckdb_dtypes from narwhals._typing import _EagerAllowedImpl from narwhals._utils import _LimitedContext from narwhals.dataframe import LazyFrame from narwhals.dtypes import DType - from narwhals.stable.v1 import DataFrame as DataFrameV1 from narwhals.typing import AsofJoinStrategy, JoinStrategy, UniqueKeepStrategy class DuckDBLazyFrame( SQLLazyFrame[ - "DuckDBExpr", - "duckdb.DuckDBPyRelation", - "LazyFrame[duckdb.DuckDBPyRelation] | DataFrameV1[duckdb.DuckDBPyRelation]", + "DuckDBExpr", "duckdb.DuckDBPyRelation", "LazyFrame[duckdb.DuckDBPyRelation]" ], ValidateBackendVersion, ): @@ -77,7 +73,7 @@ def __init__( self._version = version self._cached_native_schema: dict[str, duckdb_dtypes.DuckDBPyType] | None = None self._cached_columns: list[str] | None = None - if validate_backend_version: + if validate_backend_version: # pragma: no cover self._validate_backend_version() @property @@ -94,22 +90,9 @@ def from_native( ) -> Self: return cls(data, version=context._version) - def to_narwhals( - self, *args: Any, **kwds: Any - ) -> LazyFrame[duckdb.DuckDBPyRelation] | DataFrameV1[duckdb.DuckDBPyRelation]: - if self._version is Version.V1: - from narwhals.stable.v1 import DataFrame as DataFrameV1 - - return DataFrameV1(self) # type: ignore[no-any-return] + def to_narwhals(self, *args: Any, **kwds: Any) -> LazyFrame[duckdb.DuckDBPyRelation]: return self._version.lazyframe(self) - def __narwhals_dataframe__(self) -> Self: # pragma: no cover - # Keep around for backcompat. - if self._version is not Version.V1: - msg = "__narwhals_dataframe__ is not implemented for DuckDBLazyFrame" - raise AttributeError(msg) - return self - def __narwhals_lazyframe__(self) -> Self: return self @@ -121,11 +104,6 @@ def __narwhals_namespace__(self) -> DuckDBNamespace: return DuckDBNamespace(version=self._version) - def get_column(self, name: str) -> DuckDBInterchangeSeries: - from narwhals._duckdb.series import DuckDBInterchangeSeries - - return DuckDBInterchangeSeries(self.native.select(name), version=self._version) - def _iter_columns(self) -> Iterator[Expression]: for name in self.columns: yield col(name) @@ -134,19 +112,8 @@ def collect( self, backend: _EagerAllowedImpl | None, **kwargs: Any ) -> CompliantDataFrameAny: if backend is None or backend is Implementation.PYARROW: - from narwhals._arrow.dataframe import ArrowDataFrame - - res_native = ( - self.native.to_arrow_table() - if self._backend_version >= (1, 5) - else self.native.fetch_arrow_table() - ) - return ArrowDataFrame( - res_native, - validate_backend_version=True, - version=self._version, - validate_column_names=True, - ) + ns = self._version.namespace.from_backend(Implementation.PYARROW).compliant + return ns.from_native(to_arrow_table(self.native)) if backend is Implementation.PANDAS: from narwhals._pandas_like.dataframe import PandasLikeDataFrame @@ -198,16 +165,6 @@ def drop(self, columns: Sequence[str], *, strict: bool) -> Self: selection = [col(name) for name in self.columns if name not in columns_to_drop] return self._with_native(self.native.select(*selection)) - def lazy(self, backend: None = None, **_: None) -> Self: - # The `backend`` argument has no effect but we keep it here for - # backwards compatibility because in `narwhals.stable.v1` - # function `.from_native()` will return a DataFrame for DuckDB. - - if backend is not None: # pragma: no cover - msg = "`backend` argument is not supported for DuckDB" - raise ValueError(msg) - return self - def with_columns(self, *exprs: DuckDBExpr) -> Self: new_columns_map = dict(evaluate_exprs_and_aliases(self, *exprs)) result = [ @@ -259,14 +216,6 @@ def columns(self) -> list[str]: ) return self._cached_columns - def to_pandas(self) -> pd.DataFrame: - # only if version is v1, keep around for backcompat - return self.native.df() - - def to_arrow(self) -> pa.Table: - # only if version is v1, keep around for backcompat - return self.lazy().collect(Implementation.PYARROW).native # type: ignore[no-any-return] - def _with_version(self, version: Version) -> Self: return self.__class__(self.native, version=version) @@ -540,9 +489,6 @@ def unpivot( @requires.backend_version((1, 3)) def with_row_index(self, name: str, order_by: Sequence[str]) -> Self: - if order_by is None: - msg = "Cannot pass `order_by` to `with_row_index` for DuckDB" - raise TypeError(msg) expr = (window_expression(F("row_number"), order_by=order_by) - lit(1)).alias( name ) @@ -556,3 +502,8 @@ def sink_parquet(self, file: str | Path | BytesIO) -> None: (FORMAT parquet) """ # noqa: S608 duckdb.sql(query) + + +def to_arrow_table(rel: duckdb.DuckDBPyRelation) -> pa.Table: + to_arrow = rel.to_arrow_table if BACKEND_VERSION >= (1, 5) else rel.fetch_arrow_table + return to_arrow() diff --git a/src/narwhals/_duckdb/interchange.py b/src/narwhals/_duckdb/interchange.py new file mode 100644 index 0000000000..87c24b25ec --- /dev/null +++ b/src/narwhals/_duckdb/interchange.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from duckdb import DuckDBPyRelation + +from narwhals import _interchange +from narwhals._duckdb import utils +from narwhals._duckdb.dataframe import to_arrow_table +from narwhals._utils import Implementation, Version + +if TYPE_CHECKING: + from types import ModuleType + + import pandas as pd + import pyarrow as pa + from typing_extensions import Never, Self + + from narwhals._duckdb.utils import DeferredTimeZone + from narwhals.dtypes import DType + + +def narwhals_dtype(dtype: Any, time_zone: DeferredTimeZone) -> DType: + return utils.native_to_narwhals_dtype(dtype, Version.V1, time_zone) + + +class DuckDBDataFrame: + _implementation = Implementation.DUCKDB + _version: Version = Version.V1 + + def __init__(self, df: DuckDBPyRelation) -> None: + self._native_frame = df + + @property + def native(self) -> DuckDBPyRelation: + return self._native_frame + + def simple_select(self, *column_names: str) -> DuckDBDataFrame: + return DuckDBDataFrame(self.native.select(*column_names)) + + def get_column(self, name: str) -> DuckDBInterchangeSeries: + return DuckDBInterchangeSeries(self.native.select(name)) + + def to_pandas(self) -> pd.DataFrame: + return self.native.df() + + def to_arrow(self) -> pa.Table: + return to_arrow_table(self.native) + + def __narwhals_dataframe__(self) -> Self: + return self + + def __native_namespace__(self) -> ModuleType: + return self._implementation.to_native_namespace() + + @property + def schema(self) -> dict[str, DType]: + deferred = utils.DeferredTimeZone(self.native) + it = zip(self.columns, self.native.types, strict=True) + return {name: narwhals_dtype(dtype, deferred) for name, dtype in it} + + @property + def columns(self) -> list[str]: + return self.native.columns + + def collect_schema(self) -> dict[str, DType]: + return self.schema + + def __getattr__(self, name: str) -> Never: + raise _interchange.unsupported_error(name) + + +class DuckDBInterchangeSeries(_interchange.InterchangeSeriesV1[DuckDBPyRelation]): + _implementation = Implementation.DUCKDB + + def __init__(self, df: DuckDBPyRelation) -> None: + self._native_series = df + + @property + def dtype(self) -> DType: + native = self.native + return narwhals_dtype(native.types[0], utils.DeferredTimeZone(native)) diff --git a/src/narwhals/_duckdb/series.py b/src/narwhals/_duckdb/series.py deleted file mode 100644 index 5b284b95c3..0000000000 --- a/src/narwhals/_duckdb/series.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from narwhals._duckdb.utils import DeferredTimeZone, native_to_narwhals_dtype -from narwhals.dependencies import get_duckdb - -if TYPE_CHECKING: - from types import ModuleType - - import duckdb - from typing_extensions import Never, Self - - from narwhals._utils import Version - from narwhals.dtypes import DType - - -class DuckDBInterchangeSeries: - def __init__(self, df: duckdb.DuckDBPyRelation, version: Version) -> None: - self._native_series = df - self._version = version - - def __narwhals_series__(self) -> Self: - return self - - def __native_namespace__(self) -> ModuleType: - return get_duckdb() # type: ignore[no-any-return] - - @property - def dtype(self) -> DType: - return native_to_narwhals_dtype( - self._native_series.types[0], - self._version, - DeferredTimeZone(self._native_series), - ) - - def __getattr__(self, attr: str) -> Never: - msg = ( # pragma: no cover - f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" - "If you would like to see this kind of object better supported in " - "Narwhals, please open a feature request " - "at https://github.com/narwhals-dev/narwhals/issues." - ) - raise NotImplementedError(msg) # pragma: no cover diff --git a/src/narwhals/_ibis/dataframe.py b/src/narwhals/_ibis/dataframe.py index 5f4f97cf2c..3cd455a753 100644 --- a/src/narwhals/_ibis/dataframe.py +++ b/src/narwhals/_ibis/dataframe.py @@ -146,17 +146,6 @@ def drop(self, columns: Sequence[str], *, strict: bool) -> Self: selection = (col for col in self.columns if col not in columns_to_drop) return self._with_native(self.native.select(*selection)) - # TODO @dangotbanned: Figure out if this is needed - def lazy(self, backend: None = None, **_: None) -> Self: - # The `backend`` argument has no effect but we keep it here for - # backwards compatibility because in `narwhals.stable.v1` - # function `.from_native()` will return a DataFrame for Ibis. - - if backend is not None: # pragma: no cover - msg = "`backend` argument is not supported for Ibis" - raise ValueError(msg) - return self - def with_columns(self, *exprs: IbisExpr) -> Self: new_columns_map = dict(evaluate_exprs(self, *exprs)) return self._with_native(self.native.mutate(**new_columns_map)) diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index 980d0421cb..af846ff34a 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -3,4 +3,5 @@ from narwhals._interchange.dataframe import ( InterchangeFrameV1 as InterchangeFrameV1, InterchangeSeriesV1 as InterchangeSeriesV1, + unsupported_error as unsupported_error, ) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 0554f7b165..761d4e42a5 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -15,7 +15,7 @@ import pandas as pd import pyarrow as pa - from typing_extensions import Self, TypeIs + from typing_extensions import Never, Self, TypeIs from narwhals._interchange.series import InterchangeSeries from narwhals.dtypes import DType @@ -83,7 +83,7 @@ def map_interchange_dtype_to_narwhals_dtype( # noqa: C901, PLR0911, PLR0912 raise AssertionError(msg) -Original_co = TypeVar("Original_co", bound=DataFrameLike, covariant=True) +Original_co = TypeVar("Original_co", bound=DataFrameLike | Any, covariant=True) class Column(Protocol): @@ -98,6 +98,10 @@ class RecoverableColumn(Column, Protocol[Original_co]): # type: ignore[misc] def __narwhals_series__(self) -> Self: return self + @property + def native(self) -> Original_co: + return self._native_series + class InterchangeSeriesV1(RecoverableColumn[Original_co], Protocol[Original_co]): # type: ignore[misc] _implementation: Implementation @@ -108,6 +112,11 @@ def dtype(self) -> DType: ... # ? def __native_namespace__(self) -> ModuleType: return self._implementation.to_native_namespace() + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Never: + raise unsupported_error(name) + class Frame(Protocol): def __dataframe__(self, *_: Any, **__: Any) -> Self: # pragma: no cover @@ -130,6 +139,7 @@ def _df(self) -> Original_co: ... """ def __narwhals_dataframe__(self) -> Self: + """Required once inside `DataFrame.__init__`.""" return self @@ -151,6 +161,15 @@ def __narwhals_dataframe__(self) -> Self: SENTINEL = object() +def unsupported_error(method_name: str) -> NotImplementedError: + msg = ( + f"Attribute {method_name!r} is not supported for interchange-level dataframes.\n\n" + "Hint: you probably called `from_native` on an object which isn't fully " + "supported by `narwhals.stable.v1`, yet implements `__dataframe__`." + ) + return NotImplementedError(msg) + + class WrapsInterchangeFrame(Protocol): _dfi: Frame @@ -160,12 +179,7 @@ def __getattr__(self, attr: str) -> Any: and (func := getattr_static(self._dfi, attr, SENTINEL)) is not SENTINEL ): return func - msg = ( - f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" - "Hint: you probably called `from_native` on an object which isn't fully " - "supported by `narwhals.stable.v1`, yet implements `__dataframe__`." - ) - raise NotImplementedError(msg) + raise unsupported_error(attr) class InterchangeFrameV1( diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 07484ef446..d7bb845f48 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -672,12 +672,10 @@ def from_native( # noqa: PLR0911 return DataFrame(IbisDataFrame(native_object)) - # TODO @dangotbanned: Isolate eager duckdb from `DuckDBLazyFrame` if dependencies.is_duckdb_relation(native_object): - fake_interchange = Version.V1.namespace.from_backend( - "duckdb" - ).compliant.from_native(native_object) - return DataFrame(fake_interchange) + from narwhals._duckdb.interchange import DuckDBDataFrame + + return DataFrame(DuckDBDataFrame(native_object)) return DataFrame(InterchangeFrame(native_object)) return _from_native_impl( # type: ignore[no-any-return] diff --git a/tests/v1_test.py b/tests/v1_test.py index 7a431f85c3..4a84efb37e 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -59,7 +59,7 @@ def xfail_interchange( ) -> None: """Ensure we raise when trying to use a non interchange-level method.""" marker = pytest.mark.xfail( - frame.implementation.is_ibis(), + frame.implementation.is_ibis() or frame.implementation.is_duckdb(), reason=f"{method!r} is not supported for interchange-level dataframes.", raises=NotImplementedError, ) @@ -333,15 +333,19 @@ def test_cast_to_enum_v1( # Backends that do not (yet) support Enum dtype if any( backend in str(constructor) - for backend in ("pyarrow_table", "sqlframe", "pyspark", "ibis") + for backend in ("pyarrow_table", "sqlframe", "pyspark") ): request.applymarker(pytest.mark.xfail) - df_native = constructor({"a": ["a", "b"]}) + df = nw_v1.from_native(constructor({"a": ["a", "b"]})) + # NOTE: the pattern on raises conflicts with the error that is raised on expr evaluation, + # which is before the cast that is tested + xfail_interchange("__narwhals_namespace__", df, request) + _ = df.__narwhals_namespace__() msg = re.escape("Converting to Enum is not supported in narwhals.stable.v1") with pytest.raises(NotImplementedError, match=msg): - nw_v1.from_native(df_native).select(nw_v1.col("a").cast(nw_v1.Enum)) # type: ignore[arg-type] + df.select(nw_v1.col("a").cast(nw_v1.Enum)) # type: ignore[arg-type] def test_v1_ordered_categorical_pandas() -> None: From af0bb3cc4d72305d7d2418b6cea49708dd39e978 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:38:10 +0000 Subject: [PATCH 19/43] refactor: Simplify both `duckdb` and `ibis` --- src/narwhals/_duckdb/interchange.py | 69 ++---------------- src/narwhals/_ibis/interchange.py | 63 ++-------------- src/narwhals/_interchange/__init__.py | 1 + src/narwhals/_interchange/lazyframe.py | 99 ++++++++++++++++++++++++++ src/narwhals/stable/v1/__init__.py | 4 +- 5 files changed, 112 insertions(+), 124 deletions(-) create mode 100644 src/narwhals/_interchange/lazyframe.py diff --git a/src/narwhals/_duckdb/interchange.py b/src/narwhals/_duckdb/interchange.py index 87c24b25ec..2cc125e0b2 100644 --- a/src/narwhals/_duckdb/interchange.py +++ b/src/narwhals/_duckdb/interchange.py @@ -1,82 +1,23 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from duckdb import DuckDBPyRelation from narwhals import _interchange -from narwhals._duckdb import utils from narwhals._duckdb.dataframe import to_arrow_table -from narwhals._utils import Implementation, Version +from narwhals._utils import Implementation if TYPE_CHECKING: - from types import ModuleType - import pandas as pd import pyarrow as pa - from typing_extensions import Never, Self - - from narwhals._duckdb.utils import DeferredTimeZone - from narwhals.dtypes import DType - -def narwhals_dtype(dtype: Any, time_zone: DeferredTimeZone) -> DType: - return utils.native_to_narwhals_dtype(dtype, Version.V1, time_zone) - -class DuckDBDataFrame: +class DuckDBDataFrame(_interchange.LazyFrame[DuckDBPyRelation]): _implementation = Implementation.DUCKDB - _version: Version = Version.V1 - - def __init__(self, df: DuckDBPyRelation) -> None: - self._native_frame = df - - @property - def native(self) -> DuckDBPyRelation: - return self._native_frame - - def simple_select(self, *column_names: str) -> DuckDBDataFrame: - return DuckDBDataFrame(self.native.select(*column_names)) - - def get_column(self, name: str) -> DuckDBInterchangeSeries: - return DuckDBInterchangeSeries(self.native.select(name)) def to_pandas(self) -> pd.DataFrame: - return self.native.df() + return self._compliant.native.df() def to_arrow(self) -> pa.Table: - return to_arrow_table(self.native) - - def __narwhals_dataframe__(self) -> Self: - return self - - def __native_namespace__(self) -> ModuleType: - return self._implementation.to_native_namespace() - - @property - def schema(self) -> dict[str, DType]: - deferred = utils.DeferredTimeZone(self.native) - it = zip(self.columns, self.native.types, strict=True) - return {name: narwhals_dtype(dtype, deferred) for name, dtype in it} - - @property - def columns(self) -> list[str]: - return self.native.columns - - def collect_schema(self) -> dict[str, DType]: - return self.schema - - def __getattr__(self, name: str) -> Never: - raise _interchange.unsupported_error(name) - - -class DuckDBInterchangeSeries(_interchange.InterchangeSeriesV1[DuckDBPyRelation]): - _implementation = Implementation.DUCKDB - - def __init__(self, df: DuckDBPyRelation) -> None: - self._native_series = df - - @property - def dtype(self) -> DType: - native = self.native - return narwhals_dtype(native.types[0], utils.DeferredTimeZone(native)) + return to_arrow_table(self._compliant.native) diff --git a/src/narwhals/_ibis/interchange.py b/src/narwhals/_ibis/interchange.py index 312da5c244..d8dfcd15e2 100644 --- a/src/narwhals/_ibis/interchange.py +++ b/src/narwhals/_ibis/interchange.py @@ -2,74 +2,21 @@ from typing import TYPE_CHECKING +import ibis + from narwhals import _interchange -from narwhals._ibis.utils import native_to_narwhals_dtype from narwhals._utils import Implementation if TYPE_CHECKING: - from collections.abc import Sequence - - import ibis.expr.types as ir - import ibis.expr.types.dataframe_interchange as ibis_dfi import pandas as pd import pyarrow as pa - from typing_extensions import Self - from narwhals.dtypes import DType - -class IbisDataFrame(_interchange.InterchangeFrameV1["ir.Table"]): +class IbisDataFrame(_interchange.LazyFrame[ibis.Table]): _implementation = Implementation.IBIS - def __init__(self, df: ir.Table | ibis_dfi.IbisDataFrame) -> None: - self._dfi: ibis_dfi.IbisDataFrame = df.__dataframe__() - - @classmethod - def from_dfi(cls, df: ibis_dfi.IbisDataFrame) -> Self: - self = cls.__new__(cls) - self._dfi = df - return self - - @property - def native(self) -> ir.Table: - return self._dfi._table - - @property - def _df(self) -> ir.Table: - return self.native - def to_pandas(self) -> pd.DataFrame: - return self.native.to_pandas() + return self._compliant.native.to_pandas() def to_arrow(self) -> pa.Table: - return self.native.to_pyarrow() - - def get_column(self, name: str) -> IbisSeries: - return IbisSeries(self.native.select(name)) - - def column_names(self) -> list[str]: - return list(self.native.columns) - - def select_columns_by_name(self, names: Sequence[str]) -> Self: - return self.from_dfi(self._dfi.select_columns_by_name(names)) - - @property - def schema(self) -> dict[str, DType]: - return { - name: native_to_narwhals_dtype(dtype, self._version) - for name, dtype in self.native.schema().fields.items() - } - - -class IbisSeries(_interchange.InterchangeSeriesV1["ir.Table"]): - _implementation = Implementation.IBIS - - def __init__(self, df: ir.Table) -> None: - self._native_series: ir.Table = df - - @property - def dtype(self) -> DType: - native = next(iter(self._native_series.schema().values())) - # NOTE: `Concrete` (base class for `DataType`) has two bases that define `__hash__ = None`, - # and then decides to implement `__hash__`? Okay ... - return native_to_narwhals_dtype(native, self._version) # pyright: ignore[reportArgumentType] + return self._compliant.native.to_pyarrow() diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index af846ff34a..2e619abb5d 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -5,3 +5,4 @@ InterchangeSeriesV1 as InterchangeSeriesV1, unsupported_error as unsupported_error, ) +from narwhals._interchange.lazyframe import LazyFrame as LazyFrame diff --git a/src/narwhals/_interchange/lazyframe.py b/src/narwhals/_interchange/lazyframe.py new file mode 100644 index 0000000000..a3eee80f94 --- /dev/null +++ b/src/narwhals/_interchange/lazyframe.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeAlias, TypeVar + +from narwhals._compliant import CompliantLazyFrame +from narwhals._interchange.dataframe import unsupported_error +from narwhals._native import NativeDuckDB +from narwhals._utils import Version + +if TYPE_CHECKING: + import ibis + import pandas as pd + import pyarrow as pa + from typing_extensions import Self + + from narwhals._typing import _LazyAllowedImpl + from narwhals.dtypes import DType + +NativeT = TypeVar("NativeT", NativeDuckDB, "ibis.Table") +Compliant: TypeAlias = CompliantLazyFrame[Any, NativeT, Any] + + +def _getattr_typing(self: LazyFrame[Any] | Series[Any], name: str) -> Any: + if name in self._ALLOW: + return getattr(self._compliant, name) + raise unsupported_error(name) + + +class LazyFrame(Protocol[NativeT]): + _compliant: Compliant[NativeT] + _version: Version = Version.V1 + _implementation: ClassVar[_LazyAllowedImpl] + _ALLOW: frozenset[str] = frozenset( + ( + "native", + "__native_namespace__", + "_native_frame", + "schema", + "columns", + "collect_schema", + ) + ) + + def __init__(self, compliant: Compliant[NativeT]) -> None: + self._compliant = compliant + + def simple_select(self, *column_names: str) -> Self: + return type(self)(self._compliant.simple_select(*column_names)) + + def get_column(self, name: str) -> Series[NativeT]: + return Series(self.simple_select(name)) + + def __narwhals_dataframe__(self) -> Self: + return self + + @classmethod + def from_native(cls: type[LazyFrame[Any]], native: NativeT) -> LazyFrame[NativeT]: + ns = Version.V1.namespace.from_native_object(native).compliant + return cls(ns.from_native(native)) + + def to_pandas(self) -> pd.DataFrame: ... + def to_arrow(self) -> pa.Table: ... + + if TYPE_CHECKING: + ... + else: + + def __getattr__(self, name: str) -> Any: + return _getattr_typing(self, name) + + +class Series(Generic[NativeT]): + _lazy: LazyFrame[NativeT] + _version: Version = Version.V1 + + _ALLOW: frozenset[str] = frozenset( + ("native", "__native_namespace__", "_implementation") + ) + + def __init__(self, lazy: LazyFrame[NativeT]) -> None: + self._lazy = lazy + + def __narwhals_series__(self) -> Self: + return self + + @property + def _compliant(self) -> Compliant[NativeT]: + return self._lazy._compliant + + @property + def dtype(self) -> DType: + return next(iter(self._compliant.schema.values())) + + if TYPE_CHECKING: + ... + else: + + def __getattr__(self, name: str) -> Any: + return _getattr_typing(self, name) diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index d7bb845f48..4f564f5390 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -670,12 +670,12 @@ def from_native( # noqa: PLR0911 if dependencies.is_ibis_table(native_object): from narwhals._ibis.interchange import IbisDataFrame - return DataFrame(IbisDataFrame(native_object)) + return DataFrame(IbisDataFrame.from_native(native_object)) if dependencies.is_duckdb_relation(native_object): from narwhals._duckdb.interchange import DuckDBDataFrame - return DataFrame(DuckDBDataFrame(native_object)) + return DataFrame(DuckDBDataFrame.from_native(native_object)) return DataFrame(InterchangeFrame(native_object)) return _from_native_impl( # type: ignore[no-any-return] From f4f7d7232c36950e9ce962935b79bf6fcf243fc7 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:39:04 +0000 Subject: [PATCH 20/43] test: cover `get_column`, `to_native` explicitly Being used by marimo --- tests/interchange/select_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/interchange/select_test.py b/tests/interchange/select_test.py index a927ba18c6..5189ff364e 100644 --- a/tests/interchange/select_test.py +++ b/tests/interchange/select_test.py @@ -75,6 +75,7 @@ def test_interchange_ibis( out_cols = df.select("a", "z").schema.names() assert out_cols == ["a", "z"] + assert isinstance(df.get_column("a").to_native(), ibis.Table) def test_interchange_duckdb() -> None: @@ -91,3 +92,4 @@ def test_interchange_duckdb() -> None: out_cols = df.select("a", "z").schema.names() assert out_cols == ["a", "z"] + assert isinstance(df.get_column("a").to_native(), duckdb.DuckDBPyRelation) From 68f8136341099c11f9c5eb198e7c116a9de6ca59 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:51:56 +0000 Subject: [PATCH 21/43] refactor: remove everything that isn't tested --- src/narwhals/_interchange/__init__.py | 6 +- src/narwhals/_interchange/dataframe.py | 165 ++----------------------- 2 files changed, 9 insertions(+), 162 deletions(-) diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index 2e619abb5d..a844f95eb5 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -1,8 +1,4 @@ from __future__ import annotations -from narwhals._interchange.dataframe import ( - InterchangeFrameV1 as InterchangeFrameV1, - InterchangeSeriesV1 as InterchangeSeriesV1, - unsupported_error as unsupported_error, -) +from narwhals._interchange.dataframe import unsupported_error as unsupported_error from narwhals._interchange.lazyframe import LazyFrame as LazyFrame diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 761d4e42a5..0336d03195 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -3,23 +3,18 @@ import enum import sys from functools import lru_cache -from inspect import getattr_static -from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol from narwhals import dependencies as deps from narwhals._utils import Implementation, Version, _hasattr_static, parse_version if TYPE_CHECKING: - from collections.abc import Iterable, Sequence - from types import ModuleType - import pandas as pd import pyarrow as pa - from typing_extensions import Never, Self, TypeIs + from typing_extensions import Self, TypeIs from narwhals._interchange.series import InterchangeSeries from narwhals.dtypes import DType - from narwhals.stable.v1 import DataFrame as DataFrameV1 class DataFrameLike(Protocol): @@ -83,152 +78,21 @@ def map_interchange_dtype_to_narwhals_dtype( # noqa: C901, PLR0911, PLR0912 raise AssertionError(msg) -Original_co = TypeVar("Original_co", bound=DataFrameLike | Any, covariant=True) - - -class Column(Protocol): - @property - def dtype(self) -> tuple[DtypeKind, int, Any, Any] | Any: ... - - -class RecoverableColumn(Column, Protocol[Original_co]): # type: ignore[misc] - _version: Version = Version.V1 - _native_series: Original_co - - def __narwhals_series__(self) -> Self: - return self - - @property - def native(self) -> Original_co: - return self._native_series - - -class InterchangeSeriesV1(RecoverableColumn[Original_co], Protocol[Original_co]): # type: ignore[misc] - _implementation: Implementation - - @property - def dtype(self) -> DType: ... # ? - - def __native_namespace__(self) -> ModuleType: - return self._implementation.to_native_namespace() - - if not TYPE_CHECKING: - - def __getattr__(self, name: str) -> Never: - raise unsupported_error(name) - - -class Frame(Protocol): - def __dataframe__(self, *_: Any, **__: Any) -> Self: # pragma: no cover - return self - - def column_names(self) -> Iterable[str]: ... - def get_column_by_name(self, name: str, /) -> Column: ... - def select_columns_by_name(self, names: Sequence[str], /) -> Self: ... - - -class RecoverableFrame(Frame, Protocol[Original_co]): - _version: Version = Version.V1 - - @property - def _df(self) -> Original_co: ... - - """Allow for recovering original object. - - See https://github.com/data-apis/dataframe-api/issues/360. - """ - - def __narwhals_dataframe__(self) -> Self: - """Required once inside `DataFrame.__init__`.""" - return self - - -DFI_METHODS = ( - "column_names", - "__dataframe__", - "get_chunks", - "get_column", - "get_column_by_name", - "get_columns", - "metadata", - "num_chunks", - "num_columns", - "num_rows", - "select_columns", - "select_columns_by_name", -) - -SENTINEL = object() - - def unsupported_error(method_name: str) -> NotImplementedError: msg = ( - f"Attribute {method_name!r} is not supported for interchange-level dataframes.\n\n" + f"{method_name!r} is not supported for interchange-level dataframes.\n\n" "Hint: you probably called `from_native` on an object which isn't fully " "supported by `narwhals.stable.v1`, yet implements `__dataframe__`." ) return NotImplementedError(msg) -class WrapsInterchangeFrame(Protocol): - _dfi: Frame - - def __getattr__(self, attr: str) -> Any: - if ( - attr in DFI_METHODS - and (func := getattr_static(self._dfi, attr, SENTINEL)) is not SENTINEL - ): - return func - raise unsupported_error(attr) - - -class InterchangeFrameV1( - WrapsInterchangeFrame, RecoverableFrame[Original_co], Protocol[Original_co] -): - _implementation: Implementation - - @property - def schema(self) -> dict[str, DType]: ... - @property - def columns(self) -> list[str]: - return list(self.column_names()) - - @property - def _native_frame(self) -> Original_co: - return self._df - - def to_pandas(self) -> pd.DataFrame: ... - def to_arrow(self) -> pa.Table: ... - def get_column(self, name: str, /) -> InterchangeSeriesV1[Original_co]: ... - def get_column_by_name( - self, name: str, / - ) -> InterchangeSeriesV1[Original_co]: # pragma: no cover - return self.get_column(name) - - def collect_schema(self) -> dict[str, DType]: - return self.schema - - def __native_namespace__(self) -> ModuleType: - return self._implementation.to_native_namespace() - - def simple_select(self, *column_names: str) -> Self: - return self.select_columns_by_name(column_names) - - def to_narwhals(self) -> DataFrameV1[Any]: # pragma: no cover - from narwhals.stable.v1 import DataFrame as DataFrameV1 - - return DataFrameV1(self) # type: ignore[no-any-return] - - -# TODO @dangotbanned: Review what is going on here -# - roll in the protocol stuff -# - integrate duckdb -class InterchangeFrame(WrapsInterchangeFrame): +class InterchangeFrame: _version = Version.V1 _implementation: Final = Implementation.UNKNOWN def __init__(self, df: DataFrameLike) -> None: - self._dfi = df.__dataframe__() + self._dfi: Any = df.__dataframe__() def __narwhals_dataframe__(self) -> Self: return self @@ -279,22 +143,10 @@ def columns(self) -> list[str]: def simple_select(self, *column_names: str) -> Self: frame = self._dfi.select_columns_by_name(list(column_names)) - if not hasattr(frame, "_df"): # pragma: no cover - msg = ( - "Expected interchange object to implement `_df` property to allow for recovering original object.\n" - "See https://github.com/data-apis/dataframe-api/issues/360." - ) - raise NotImplementedError(msg) - return self.__class__(frame._df) # pyright: ignore[reportAttributeAccessIssue] + return self.__class__(frame._df) - def select(self, *exprs: str) -> Self: # pragma: no cover - msg = ( - "`select`-ing not by name is not supported for interchange-only level.\n\n" - "If you would like to see this kind of object better supported in " - "Narwhals, please open a feature request " - "at https://github.com/narwhals-dev/narwhals/issues." - ) - raise NotImplementedError(msg) + def __getattr__(self, attr: str) -> Any: + raise unsupported_error(attr) def supports_dataframe_interchange(obj: Any) -> TypeIs[DataFrameLike]: @@ -313,7 +165,6 @@ def should_interchange(obj: object) -> TypeIs[DataFrameLike]: ) -# TODO @dangotbanned: ~~cudf~~, sqlframe?, pyspark?, pyspark-connect? @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: if not supports_dataframe_interchange(tp_native): From 28dbbf2080799ad1e55d3f299708638d1a0bf40c Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:55:31 +0000 Subject: [PATCH 22/43] refactor: goodbye `supports_dataframe_interchange` --- src/narwhals/_interchange/dataframe.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 0336d03195..31340128be 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -149,10 +149,6 @@ def __getattr__(self, attr: str) -> Any: raise unsupported_error(attr) -def supports_dataframe_interchange(obj: Any) -> TypeIs[DataFrameLike]: - return _hasattr_static(obj, "__dataframe__") - - def should_interchange(obj: object) -> TypeIs[DataFrameLike]: return _should_interchange(type(obj)) # type: ignore[arg-type] @@ -167,7 +163,7 @@ def should_interchange(obj: object) -> TypeIs[DataFrameLike]: @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: - if not supports_dataframe_interchange(tp_native): + if not _hasattr_static(tp_native, "__dataframe__"): return (duckdb := deps.get_duckdb()) and issubclass( tp_native, duckdb.DuckDBPyRelation ) From 94f2e865c7f8fe9ceeb0e2dbe46b90b16edd37b6 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:55:14 +0000 Subject: [PATCH 23/43] fix: avoid `Protocol.__init__` https://github.com/narwhals-dev/narwhals/pull/3721#discussion_r3495183575 --- src/narwhals/_interchange/lazyframe.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/narwhals/_interchange/lazyframe.py b/src/narwhals/_interchange/lazyframe.py index a3eee80f94..3886c7de33 100644 --- a/src/narwhals/_interchange/lazyframe.py +++ b/src/narwhals/_interchange/lazyframe.py @@ -41,11 +41,8 @@ class LazyFrame(Protocol[NativeT]): ) ) - def __init__(self, compliant: Compliant[NativeT]) -> None: - self._compliant = compliant - def simple_select(self, *column_names: str) -> Self: - return type(self)(self._compliant.simple_select(*column_names)) + return self.from_compliant(self._compliant.simple_select(*column_names)) def get_column(self, name: str) -> Series[NativeT]: return Series(self.simple_select(name)) @@ -54,9 +51,15 @@ def __narwhals_dataframe__(self) -> Self: return self @classmethod - def from_native(cls: type[LazyFrame[Any]], native: NativeT) -> LazyFrame[NativeT]: + def from_compliant(cls, compliant: Compliant[NativeT]) -> Self: + self = cls.__new__(cls) + self._compliant = compliant + return self + + @classmethod + def from_native(cls, native: NativeT) -> Self: ns = Version.V1.namespace.from_native_object(native).compliant - return cls(ns.from_native(native)) + return cls.from_compliant(ns.from_native(native)) def to_pandas(self) -> pd.DataFrame: ... def to_arrow(self) -> pa.Table: ... From 395ee9da6999a18e09d09afe3c19c40f1ecfd1a9 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:06:26 +0000 Subject: [PATCH 24/43] chore: no cover on unreachable path Probably should just remove this? --- src/narwhals/_duckdb/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/narwhals/_duckdb/utils.py b/src/narwhals/_duckdb/utils.py index 28ae56df27..9ced96a8d5 100644 --- a/src/narwhals/_duckdb/utils.py +++ b/src/narwhals/_duckdb/utils.py @@ -273,7 +273,7 @@ def narwhals_to_native_dtype( # noqa: PLR0912, C901 if duckdb_type := NW_TO_DUCKDB_DTYPES.get(base_type): return duckdb_type if isinstance_or_issubclass(dtype, dtypes.Enum): - if version is Version.V1: + if version is Version.V1: # pragma: no cover msg = "Converting to Enum is not supported in narwhals.stable.v1" raise NotImplementedError(msg) if isinstance(dtype, dtypes.Enum): From 9adb2dba93460ed058a47214bca84df4d56113e4 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:13:02 +0000 Subject: [PATCH 25/43] chore: remove dead comment --- tests/v1_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/v1_test.py b/tests/v1_test.py index 4a84efb37e..fda104653e 100644 --- a/tests/v1_test.py +++ b/tests/v1_test.py @@ -53,7 +53,6 @@ from tests.utils import Constructor, ConstructorEager -# TODO @dangotbanned: Include duckdb after checking ci fallout for ibis def xfail_interchange( method: LiteralString, frame: Frame, request: pytest.FixtureRequest ) -> None: From c27bfbdff21e3d9102ccdb31ebd3d65caff7361c Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:08:31 +0000 Subject: [PATCH 26/43] refactor: Simplify `SQLLazyFrame.to_narwhals` - We don't have the `DataFrame | LazyFrame` complexity anymore - It now has 1 **less** type parameter! --- src/narwhals/_duckdb/dataframe.py | 9 +-------- src/narwhals/_ibis/dataframe.py | 8 +------- src/narwhals/_spark_like/dataframe.py | 7 +------ src/narwhals/_sql/dataframe.py | 11 ++++++++--- src/narwhals/_sql/typing.py | 2 +- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/narwhals/_duckdb/dataframe.py b/src/narwhals/_duckdb/dataframe.py index fe13fd30af..e2e8d0bac2 100644 --- a/src/narwhals/_duckdb/dataframe.py +++ b/src/narwhals/_duckdb/dataframe.py @@ -49,16 +49,12 @@ from narwhals._duckdb.utils import duckdb_dtypes from narwhals._typing import _EagerAllowedImpl from narwhals._utils import _LimitedContext - from narwhals.dataframe import LazyFrame from narwhals.dtypes import DType from narwhals.typing import AsofJoinStrategy, JoinStrategy, UniqueKeepStrategy class DuckDBLazyFrame( - SQLLazyFrame[ - "DuckDBExpr", "duckdb.DuckDBPyRelation", "LazyFrame[duckdb.DuckDBPyRelation]" - ], - ValidateBackendVersion, + SQLLazyFrame["DuckDBExpr", "duckdb.DuckDBPyRelation"], ValidateBackendVersion ): _implementation = Implementation.DUCKDB @@ -90,9 +86,6 @@ def from_native( ) -> Self: return cls(data, version=context._version) - def to_narwhals(self, *args: Any, **kwds: Any) -> LazyFrame[duckdb.DuckDBPyRelation]: - return self._version.lazyframe(self) - def __narwhals_lazyframe__(self) -> Self: return self diff --git a/src/narwhals/_ibis/dataframe.py b/src/narwhals/_ibis/dataframe.py index 3cd455a753..aac85caaec 100644 --- a/src/narwhals/_ibis/dataframe.py +++ b/src/narwhals/_ibis/dataframe.py @@ -35,16 +35,13 @@ from narwhals._ibis.namespace import IbisNamespace from narwhals._typing import _EagerAllowedImpl from narwhals._utils import _LimitedContext - from narwhals.dataframe import LazyFrame from narwhals.dtypes import DType from narwhals.typing import AsofJoinStrategy, JoinStrategy, UniqueKeepStrategy JoinPredicates: TypeAlias = "Sequence[ir.BooleanColumn] | Sequence[str]" -class IbisLazyFrame( - SQLLazyFrame["IbisExpr", "ir.Table", "LazyFrame[ir.Table]"], ValidateBackendVersion -): +class IbisLazyFrame(SQLLazyFrame["IbisExpr", "ir.Table"], ValidateBackendVersion): _implementation = Implementation.IBIS def __init__( @@ -65,9 +62,6 @@ def _is_native(obj: ir.Table | Any) -> TypeIs[ir.Table]: def from_native(cls, data: ir.Table, /, *, context: _LimitedContext) -> Self: return cls(data, version=context._version) - def to_narwhals(self) -> LazyFrame[ir.Table]: - return self._version.lazyframe(self) - def __narwhals_lazyframe__(self) -> Self: return self diff --git a/src/narwhals/_spark_like/dataframe.py b/src/narwhals/_spark_like/dataframe.py index a565c24806..031305bd82 100644 --- a/src/narwhals/_spark_like/dataframe.py +++ b/src/narwhals/_spark_like/dataframe.py @@ -47,7 +47,6 @@ from narwhals._spark_like.utils import SparkSession from narwhals._typing import _EagerAllowedImpl from narwhals._utils import Version, _LimitedContext - from narwhals.dataframe import LazyFrame from narwhals.dtypes import DType from narwhals.typing import JoinStrategy, UniqueKeepStrategy @@ -58,8 +57,7 @@ class SparkLikeLazyFrame( - SQLLazyFrame["SparkLikeExpr", "SQLFrameDataFrame", "LazyFrame[SQLFrameDataFrame]"], - ValidateBackendVersion, + SQLLazyFrame["SparkLikeExpr", "SQLFrameDataFrame"], ValidateBackendVersion ): def __init__( self, @@ -113,9 +111,6 @@ def _is_native(obj: SQLFrameDataFrame | Any) -> TypeIs[SQLFrameDataFrame]: def from_native(cls, data: SQLFrameDataFrame, /, *, context: _LimitedContext) -> Self: return cls(data, version=context._version, implementation=context._implementation) - def to_narwhals(self) -> LazyFrame[SQLFrameDataFrame]: - return self._version.lazyframe(self) - def __native_namespace__(self) -> ModuleType: # pragma: no cover return self._implementation.to_native_namespace() diff --git a/src/narwhals/_sql/dataframe.py b/src/narwhals/_sql/dataframe.py index 7a812875c6..70f8450c65 100644 --- a/src/narwhals/_sql/dataframe.py +++ b/src/narwhals/_sql/dataframe.py @@ -8,7 +8,6 @@ NativeExprT, NativeLazyFrameT, ) -from narwhals._translate import ToNarwhalsT_co from narwhals._utils import check_columns_exist, generate_temporary_column_name from narwhals.exceptions import MultiOutputExpressionError @@ -20,14 +19,17 @@ from narwhals._compliant.window import WindowInputs from narwhals._sql.expr import SQLExpr + from narwhals.dataframe import LazyFrame from narwhals.exceptions import ColumnNotFoundError Incomplete: TypeAlias = Any class SQLLazyFrame( - CompliantLazyFrame[CompliantExprT_contra, NativeLazyFrameT, ToNarwhalsT_co], - Protocol[CompliantExprT_contra, NativeLazyFrameT, ToNarwhalsT_co], + CompliantLazyFrame[ + CompliantExprT_contra, NativeLazyFrameT, "LazyFrame[NativeLazyFrameT]" + ], + Protocol[CompliantExprT_contra, NativeLazyFrameT], ): def _evaluate_window_expr( self, @@ -64,3 +66,6 @@ def filter(self, predicate: CompliantExprT_contra) -> Self: filtered = lf_with_tmp._filter(ns.col(tmp_col)) return filtered.drop([tmp_col], strict=False) return self._filter(predicate) + + def to_narwhals(self) -> LazyFrame[NativeLazyFrameT]: + return self._version.lazyframe(self) diff --git a/src/narwhals/_sql/typing.py b/src/narwhals/_sql/typing.py index c6634af6f3..af556285a5 100644 --- a/src/narwhals/_sql/typing.py +++ b/src/narwhals/_sql/typing.py @@ -7,7 +7,7 @@ from narwhals._sql.expr import SQLExpr SQLExprAny = SQLExpr[Any, Any] - SQLLazyFrameAny = SQLLazyFrame[Any, Any, Any] + SQLLazyFrameAny = SQLLazyFrame[Any, Any] SQLExprT = TypeVar("SQLExprT", bound="SQLExprAny") SQLExprT_contra = TypeVar("SQLExprT_contra", bound="SQLExprAny", contravariant=True) From 9a7879eaf42ee91b5f51d887d689a677a9cd5474 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:13:37 +0000 Subject: [PATCH 27/43] refactor: Expose what is actually used externally --- src/narwhals/_interchange/__init__.py | 5 ++++- src/narwhals/stable/v1/__init__.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index a844f95eb5..160810f60d 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -1,4 +1,7 @@ from __future__ import annotations -from narwhals._interchange.dataframe import unsupported_error as unsupported_error +from narwhals._interchange.dataframe import ( + InterchangeFrame as InterchangeFrame, + should_interchange as should_interchange, +) from narwhals._interchange.lazyframe import LazyFrame as LazyFrame diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 4f564f5390..ff91bbdb82 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -7,7 +7,7 @@ from narwhals import exceptions, functions as nw_f from narwhals._exceptions import issue_warning from narwhals._expression_parsing import ExprKind, ExprNode, is_expr -from narwhals._interchange.dataframe import InterchangeFrame, should_interchange +from narwhals._interchange import InterchangeFrame, should_interchange from narwhals._typing_compat import TypeVar, assert_never from narwhals._utils import ( Implementation, From 0d596eb7d443370144970ff2cfab828d67213acd Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:23:21 +0000 Subject: [PATCH 28/43] refactor: Fix weird leftovers --- src/narwhals/translate.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/narwhals/translate.py b/src/narwhals/translate.py index 30b8431e8c..4313767da2 100644 --- a/src/narwhals/translate.py +++ b/src/narwhals/translate.py @@ -324,7 +324,7 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 msg = f"Cannot only use `series_only` with {type(native_object).__qualname__}" raise TypeError(msg) return native_object - if (eager_only) and is_polars_lazyframe(native_object): + if eager_only and is_polars_lazyframe(native_object): if not pass_through: msg = "Cannot only use `eager_only` with polars.LazyFrame" raise TypeError(msg) @@ -433,10 +433,7 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915 ns_spark = version.namespace.from_native_object(native_object) if series_only or eager_only: if not pass_through: - msg = ( - "Cannot only use `series_only`, `eager_only` " - f"with {ns_spark.implementation} DataFrame" - ) + msg = f"Cannot only use `series_only` or `eager_only` with {ns_spark.implementation} DataFrame" raise TypeError(msg) return native_object return ns_spark.compliant.from_native(native_object).to_narwhals() From 322ccba6192bf9ca62889aab1c329b49296f840d Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:33:17 +0000 Subject: [PATCH 29/43] chore: rename and explain `__getattr__` trick --- src/narwhals/_interchange/lazyframe.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/narwhals/_interchange/lazyframe.py b/src/narwhals/_interchange/lazyframe.py index 3886c7de33..40806ba753 100644 --- a/src/narwhals/_interchange/lazyframe.py +++ b/src/narwhals/_interchange/lazyframe.py @@ -20,12 +20,6 @@ Compliant: TypeAlias = CompliantLazyFrame[Any, NativeT, Any] -def _getattr_typing(self: LazyFrame[Any] | Series[Any], name: str) -> Any: - if name in self._ALLOW: - return getattr(self._compliant, name) - raise unsupported_error(name) - - class LazyFrame(Protocol[NativeT]): _compliant: Compliant[NativeT] _version: Version = Version.V1 @@ -69,7 +63,7 @@ def to_arrow(self) -> pa.Table: ... else: def __getattr__(self, name: str) -> Any: - return _getattr_typing(self, name) + return _typed_getattr_impl(self, name) class Series(Generic[NativeT]): @@ -99,4 +93,12 @@ def dtype(self) -> DType: else: def __getattr__(self, name: str) -> Any: - return _getattr_typing(self, name) + return _typed_getattr_impl(self, name) + + +def _typed_getattr_impl(self: LazyFrame[Any] | Series[Any], name: str) -> Any: + # NOTE: `self.__getattr__` is outside of type checking so that `self.i_dont_exist` is reported as an error + # Splitting it out here ensures that features like "Find all references" still work + if name in self._ALLOW: + return getattr(self._compliant, name) + raise unsupported_error(name) From 7699757196fef8f766bf8c1c73b0ad23f2e6b7b9 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:40:13 +0000 Subject: [PATCH 30/43] docs(typing): Explain magic --- src/narwhals/_interchange/lazyframe.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/narwhals/_interchange/lazyframe.py b/src/narwhals/_interchange/lazyframe.py index 40806ba753..16c12c2619 100644 --- a/src/narwhals/_interchange/lazyframe.py +++ b/src/narwhals/_interchange/lazyframe.py @@ -11,20 +11,24 @@ import ibis import pandas as pd import pyarrow as pa - from typing_extensions import Self + from typing_extensions import LiteralString, Self from narwhals._typing import _LazyAllowedImpl from narwhals.dtypes import DType NativeT = TypeVar("NativeT", NativeDuckDB, "ibis.Table") +"""A native lazyframe.""" Compliant: TypeAlias = CompliantLazyFrame[Any, NativeT, Any] +"""An opaque compliant lazyframe wrapper.""" +LazyFrameOps: TypeAlias = frozenset["LiteralString"] +"""Everything that is exposed from `CompliantLazyFrame` via `__getattr__`.""" class LazyFrame(Protocol[NativeT]): _compliant: Compliant[NativeT] _version: Version = Version.V1 _implementation: ClassVar[_LazyAllowedImpl] - _ALLOW: frozenset[str] = frozenset( + _ALLOW: LazyFrameOps = frozenset( ( "native", "__native_namespace__", @@ -69,8 +73,7 @@ def __getattr__(self, name: str) -> Any: class Series(Generic[NativeT]): _lazy: LazyFrame[NativeT] _version: Version = Version.V1 - - _ALLOW: frozenset[str] = frozenset( + _ALLOW: LazyFrameOps = frozenset( ("native", "__native_namespace__", "_implementation") ) From 7c121b714033b23fbb032e6aa445555634cd1489 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:41:40 +0000 Subject: [PATCH 31/43] refactor: `__all__` is shorter --- src/narwhals/_interchange/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index 160810f60d..b106d34b87 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations -from narwhals._interchange.dataframe import ( - InterchangeFrame as InterchangeFrame, - should_interchange as should_interchange, -) -from narwhals._interchange.lazyframe import LazyFrame as LazyFrame +from narwhals._interchange.dataframe import InterchangeFrame, should_interchange +from narwhals._interchange.lazyframe import LazyFrame + +__all__ = "InterchangeFrame", "LazyFrame", "should_interchange" From 0402c65ef84a1688bc343f6f0e85cebc9c863541 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:48:03 +0000 Subject: [PATCH 32/43] shrinky-dink --- src/narwhals/_interchange/dataframe.py | 31 ++++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 31340128be..521e9b4209 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -5,8 +5,16 @@ from functools import lru_cache from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol -from narwhals import dependencies as deps from narwhals._utils import Implementation, Version, _hasattr_static, parse_version +from narwhals.dependencies import ( + IMPORT_HOOKS, + get_dask_dataframe, + get_duckdb, + get_modin, + get_pandas, + get_polars, + get_pyarrow, +) if TYPE_CHECKING: import pandas as pd @@ -153,25 +161,14 @@ def should_interchange(obj: object) -> TypeIs[DataFrameLike]: return _should_interchange(type(obj)) # type: ignore[arg-type] -_HAS_TOP_LEVEL_DF = ( - deps.get_polars, - deps.get_pandas, - deps.get_dask_dataframe, - deps.get_modin, -) - - @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: if not _hasattr_static(tp_native, "__dataframe__"): - return (duckdb := deps.get_duckdb()) and issubclass( - tp_native, duckdb.DuckDBPyRelation - ) - exclude = tuple(mod.DataFrame for get in _HAS_TOP_LEVEL_DF if (mod := get())) - exclude = (*exclude, pa.Table) if (pa := deps.get_pyarrow()) else exclude + return (duckdb := get_duckdb()) and issubclass(tp_native, duckdb.DuckDBPyRelation) + has_top_level_df = (get_polars, get_pandas, get_dask_dataframe, get_modin) + exclude = tuple(mod.DataFrame for get in has_top_level_df if (mod := get())) + exclude = (*exclude, pa.Table) if (pa := get_pyarrow()) else exclude hooks = ( - mod.pandas.DataFrame - for name in deps.IMPORT_HOOKS - if (mod := sys.modules.get(name)) + mod.pandas.DataFrame for name in IMPORT_HOOKS if (mod := sys.modules.get(name)) ) return not (exclude := (*exclude, *hooks)) or not issubclass(tp_native, exclude) From 4eb26e28968cf5ed47e0d79c55b682d884cb684b Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:54:21 +0000 Subject: [PATCH 33/43] fix: Don't give false hope --- src/narwhals/_interchange/dataframe.py | 6 +----- src/narwhals/_interchange/series.py | 20 ++++++-------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 521e9b4209..2e0f69a8b9 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -106,11 +106,7 @@ def __narwhals_dataframe__(self) -> Self: return self def __native_namespace__(self) -> NoReturn: - msg = ( - "Cannot access native namespace for interchange-level dataframes with unknown backend." - "If you would like to see this kind of object supported in Narwhals, please " - "open a feature request at https://github.com/narwhals-dev/narwhals/issues." - ) + msg = "Cannot access native namespace for interchange-level dataframes with unknown backend." raise NotImplementedError(msg) def get_column(self, name: str) -> InterchangeSeries: diff --git a/src/narwhals/_interchange/series.py b/src/narwhals/_interchange/series.py index b86666df33..cb099ae1f8 100644 --- a/src/narwhals/_interchange/series.py +++ b/src/narwhals/_interchange/series.py @@ -2,7 +2,10 @@ from typing import TYPE_CHECKING, Any, Final, NoReturn -from narwhals._interchange.dataframe import map_interchange_dtype_to_narwhals_dtype +from narwhals._interchange.dataframe import ( + map_interchange_dtype_to_narwhals_dtype, + unsupported_error, +) from narwhals._utils import Implementation, Version if TYPE_CHECKING: @@ -22,11 +25,7 @@ def __narwhals_series__(self) -> Self: return self def __native_namespace__(self) -> NoReturn: - msg = ( - "Cannot access native namespace for interchange-level series with unknown backend. " - "If you would like to see this kind of object supported in Narwhals, please " - "open a feature request at https://github.com/narwhals-dev/narwhals/issues." - ) + msg = "Cannot access native namespace for interchange-level series with unknown backend." raise NotImplementedError(msg) @property @@ -38,11 +37,4 @@ def native(self) -> Any: return self._native_series def __getattr__(self, attr: str) -> NoReturn: - msg = ( # pragma: no cover - f"Attribute {attr} is not supported for interchange-level dataframes.\n\n" - "Hint: you probably called `nw.from_native` on an object which isn't fully " - "supported by Narwhals, yet implements `__dataframe__`. If you would like to " - "see this kind of object supported in Narwhals, please open a feature request " - "at https://github.com/narwhals-dev/narwhals/issues." - ) - raise NotImplementedError(msg) + raise unsupported_error(attr) # pragma: no cover From fd13f4b6fbb445ea75e707fa2047460e8a8681fc Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:00:34 +0000 Subject: [PATCH 34/43] refactor: remove `_interchange.series.py` If two modules are dependent on one another, they are one module --- src/narwhals/_interchange/__init__.py | 8 ++++-- src/narwhals/_interchange/dataframe.py | 29 +++++++++++++++++-- src/narwhals/_interchange/series.py | 40 -------------------------- src/narwhals/_utils.py | 2 +- src/narwhals/stable/v1/__init__.py | 3 +- 5 files changed, 34 insertions(+), 48 deletions(-) delete mode 100644 src/narwhals/_interchange/series.py diff --git a/src/narwhals/_interchange/__init__.py b/src/narwhals/_interchange/__init__.py index b106d34b87..07e47b56d0 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -1,6 +1,10 @@ from __future__ import annotations -from narwhals._interchange.dataframe import InterchangeFrame, should_interchange +from narwhals._interchange.dataframe import ( + InterchangeFrame, + InterchangeSeries, + should_interchange, +) from narwhals._interchange.lazyframe import LazyFrame -__all__ = "InterchangeFrame", "LazyFrame", "should_interchange" +__all__ = "InterchangeFrame", "InterchangeSeries", "LazyFrame", "should_interchange" diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 2e0f69a8b9..1263274ffd 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -21,7 +21,6 @@ import pyarrow as pa from typing_extensions import Self, TypeIs - from narwhals._interchange.series import InterchangeSeries from narwhals.dtypes import DType @@ -110,8 +109,6 @@ def __native_namespace__(self) -> NoReturn: raise NotImplementedError(msg) def get_column(self, name: str) -> InterchangeSeries: - from narwhals._interchange.series import InterchangeSeries - return InterchangeSeries(self._dfi.get_column_by_name(name)) def to_pandas(self) -> pd.DataFrame: @@ -153,6 +150,32 @@ def __getattr__(self, attr: str) -> Any: raise unsupported_error(attr) +class InterchangeSeries: + _version = Version.V1 + _implementation: Final = Implementation.UNKNOWN + + def __init__(self, df: Any) -> None: + self._native_series = df + + def __narwhals_series__(self) -> Self: + return self + + def __native_namespace__(self) -> NoReturn: + msg = "Cannot access native namespace for interchange-level series with unknown backend." + raise NotImplementedError(msg) + + @property + def dtype(self) -> DType: + return map_interchange_dtype_to_narwhals_dtype(self._native_series.dtype) + + @property + def native(self) -> Any: + return self._native_series + + def __getattr__(self, attr: str) -> NoReturn: + raise unsupported_error(attr) # pragma: no cover + + def should_interchange(obj: object) -> TypeIs[DataFrameLike]: return _should_interchange(type(obj)) # type: ignore[arg-type] diff --git a/src/narwhals/_interchange/series.py b/src/narwhals/_interchange/series.py deleted file mode 100644 index cb099ae1f8..0000000000 --- a/src/narwhals/_interchange/series.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Final, NoReturn - -from narwhals._interchange.dataframe import ( - map_interchange_dtype_to_narwhals_dtype, - unsupported_error, -) -from narwhals._utils import Implementation, Version - -if TYPE_CHECKING: - from typing_extensions import Self - - from narwhals.dtypes import DType - - -class InterchangeSeries: - _version = Version.V1 - _implementation: Final = Implementation.UNKNOWN - - def __init__(self, df: Any) -> None: - self._native_series = df - - def __narwhals_series__(self) -> Self: - return self - - def __native_namespace__(self) -> NoReturn: - msg = "Cannot access native namespace for interchange-level series with unknown backend." - raise NotImplementedError(msg) - - @property - def dtype(self) -> DType: - return map_interchange_dtype_to_narwhals_dtype(self._native_series.dtype) - - @property - def native(self) -> Any: - return self._native_series - - def __getattr__(self, attr: str) -> NoReturn: - raise unsupported_error(attr) # pragma: no cover diff --git a/src/narwhals/_utils.py b/src/narwhals/_utils.py index 4bb942f91a..0e7ff0dd82 100644 --- a/src/narwhals/_utils.py +++ b/src/narwhals/_utils.py @@ -1152,7 +1152,7 @@ def is_ordered_categorical(series: Series[Any]) -> bool: >>> nw.is_ordered_categorical(s_pl) False """ - from narwhals._interchange.series import InterchangeSeries + from narwhals._interchange import InterchangeSeries dtypes = series._compliant_series._version.dtypes compliant = series._compliant_series diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index ff91bbdb82..9f15fafaaa 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -944,8 +944,7 @@ def get_level(obj: Frame | Series[Any]) -> Literal["full", "lazy", "interchange" which involves iterating over rows in Python. - 'interchange': only metadata operations are supported (`df.schema`) """ - from narwhals._interchange.dataframe import InterchangeFrame - from narwhals._interchange.series import InterchangeSeries + from narwhals._interchange import InterchangeFrame, InterchangeSeries ensure_type(obj, DataFrame, Series, LazyFrame) compliant = obj._compliant From 35478b32178d6f61545a78e605749c0425b44be9 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:11:46 +0000 Subject: [PATCH 35/43] refactor: share more between interchange series/frame --- src/narwhals/_interchange/dataframe.py | 33 ++++++++++---------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 1263274ffd..15408da09c 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -94,20 +94,26 @@ def unsupported_error(method_name: str) -> NotImplementedError: return NotImplementedError(msg) -class InterchangeFrame: - _version = Version.V1 +class _Interchange: + _version: Final = Version.V1 _implementation: Final = Implementation.UNKNOWN + def __native_namespace__(self) -> NoReturn: + kind = "series" if "Series" in type(self).__name__ else "dataframes" + msg = f"Cannot access native namespace for interchange-level {kind} with unknown backend." + raise NotImplementedError(msg) + + def __getattr__(self, attr: str) -> Any: + raise unsupported_error(attr) + + +class InterchangeFrame(_Interchange): def __init__(self, df: DataFrameLike) -> None: self._dfi: Any = df.__dataframe__() def __narwhals_dataframe__(self) -> Self: return self - def __native_namespace__(self) -> NoReturn: - msg = "Cannot access native namespace for interchange-level dataframes with unknown backend." - raise NotImplementedError(msg) - def get_column(self, name: str) -> InterchangeSeries: return InterchangeSeries(self._dfi.get_column_by_name(name)) @@ -146,24 +152,14 @@ def simple_select(self, *column_names: str) -> Self: frame = self._dfi.select_columns_by_name(list(column_names)) return self.__class__(frame._df) - def __getattr__(self, attr: str) -> Any: - raise unsupported_error(attr) - - -class InterchangeSeries: - _version = Version.V1 - _implementation: Final = Implementation.UNKNOWN +class InterchangeSeries(_Interchange): def __init__(self, df: Any) -> None: self._native_series = df def __narwhals_series__(self) -> Self: return self - def __native_namespace__(self) -> NoReturn: - msg = "Cannot access native namespace for interchange-level series with unknown backend." - raise NotImplementedError(msg) - @property def dtype(self) -> DType: return map_interchange_dtype_to_narwhals_dtype(self._native_series.dtype) @@ -172,9 +168,6 @@ def dtype(self) -> DType: def native(self) -> Any: return self._native_series - def __getattr__(self, attr: str) -> NoReturn: - raise unsupported_error(attr) # pragma: no cover - def should_interchange(obj: object) -> TypeIs[DataFrameLike]: return _should_interchange(type(obj)) # type: ignore[arg-type] From c857c8c1eca58c612ae0211f2154ded484c1f175 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:15:07 +0000 Subject: [PATCH 36/43] test: update error pattern x2 just checking the thing that's passed now --- tests/translate/from_native_test.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py index aeb1c3b16a..acb8907b1c 100644 --- a/tests/translate/from_native_test.py +++ b/tests/translate/from_native_test.py @@ -304,13 +304,7 @@ def test_series_only_sqlframe() -> None: # pragma: no cover ("eager_only", "context"), [ (False, does_not_raise()), - ( - True, - pytest.raises( - TypeError, - match="Cannot only use `series_only`, `eager_only` with sqlframe DataFrame", - ), - ), + (True, pytest.raises(TypeError, match=r"`eager_only`.+sqlframe")), ], ) def test_eager_only_sqlframe(eager_only: Any, context: Any) -> None: # pragma: no cover From f19899ba6c4cba85c9be10ed560c1044be5e9ecf Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:23:33 +0000 Subject: [PATCH 37/43] refactor: consistent imports --- src/narwhals/stable/v1/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 9f15fafaaa..82ff0cc768 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -7,7 +7,7 @@ from narwhals import exceptions, functions as nw_f from narwhals._exceptions import issue_warning from narwhals._expression_parsing import ExprKind, ExprNode, is_expr -from narwhals._interchange import InterchangeFrame, should_interchange +from narwhals._interchange import InterchangeFrame, InterchangeSeries, should_interchange from narwhals._typing_compat import TypeVar, assert_never from narwhals._utils import ( Implementation, @@ -944,8 +944,6 @@ def get_level(obj: Frame | Series[Any]) -> Literal["full", "lazy", "interchange" which involves iterating over rows in Python. - 'interchange': only metadata operations are supported (`df.schema`) """ - from narwhals._interchange import InterchangeFrame, InterchangeSeries - ensure_type(obj, DataFrame, Series, LazyFrame) compliant = obj._compliant impl = obj.implementation From 9f794e5d80e23d774ff357885a1487524804d0a2 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:44:12 +0000 Subject: [PATCH 38/43] test: move fixtures to `interchange.conftest` --- tests/interchange/conftest.py | 42 ++++++++++++++++++++++++++ tests/interchange/dependencies_test.py | 22 ++------------ tests/interchange/from_native_test.py | 13 +++----- tests/interchange/get_level_test.py | 14 ++------- 4 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 tests/interchange/conftest.py diff --git a/tests/interchange/conftest.py b/tests/interchange/conftest.py new file mode 100644 index 0000000000..0d941a041d --- /dev/null +++ b/tests/interchange/conftest.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeAlias + +import pytest + +import narwhals as nw + +if TYPE_CHECKING: + from collections.abc import Callable + + import duckdb + import ibis + + from narwhals._typing import EagerAllowed + +Interchange: TypeAlias = "duckdb.DuckDBPyRelation | ibis.Table" +MainInstances: TypeAlias = tuple[nw.DataFrame[Any], nw.LazyFrame[Any], nw.Series[Any]] + + +@pytest.fixture +def main_instances(eager_implementation: EagerAllowed) -> MainInstances: + df = nw.DataFrame.from_dict({"a": [1, 2, 3]}, backend=eager_implementation) + return df, df.lazy(), df.get_column("a") + + +class MockDf: + def __dataframe__(self) -> None: # pragma: no cover + return + + +@pytest.fixture +def mockdf() -> MockDf: + return MockDf() + + +@pytest.fixture +def frame(constructor: Callable[[Any], Interchange]) -> Interchange: + name = str(constructor) + if "duckdb" in name or "ibis" in name: + return constructor({"a": [1, 2, 3], "b": [4, 5, 6]}) + pytest.skip("non-interchange frames are checked in other tests") diff --git a/tests/interchange/dependencies_test.py b/tests/interchange/dependencies_test.py index 688bd56ef6..4977a90fed 100644 --- a/tests/interchange/dependencies_test.py +++ b/tests/interchange/dependencies_test.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING import pytest @@ -11,25 +11,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable - - import duckdb - import ibis - - Interchange: TypeAlias = duckdb.DuckDBPyRelation | ibis.Table - - -@pytest.fixture -def data() -> dict[str, Any]: - return {"a": [1, 2, 3], "b": [4, 5, 6]} - - -@pytest.fixture -def frame(constructor: Callable[[Any], Interchange], data: dict[str, Any]) -> Interchange: - name = str(constructor) - if "duckdb" in name or "ibis" in name: - return constructor(data) - pytest.skip("non-interchange frames are checked in other tests") + from tests.interchange.conftest import Interchange def test_is_into_dataframe(frame: Interchange) -> None: diff --git a/tests/interchange/from_native_test.py b/tests/interchange/from_native_test.py index b43924579c..bab2302cf7 100644 --- a/tests/interchange/from_native_test.py +++ b/tests/interchange/from_native_test.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest import narwhals as nw @@ -7,15 +9,8 @@ import narwhals.stable.v2 as nw_v2 from tests.utils import PANDAS_VERSION - -class MockDf: - def __dataframe__(self) -> None: # pragma: no cover - return - - -@pytest.fixture -def mockdf() -> MockDf: - return MockDf() +if TYPE_CHECKING: + from tests.interchange.conftest import MockDf def test_main_reject(mockdf: MockDf) -> None: diff --git a/tests/interchange/get_level_test.py b/tests/interchange/get_level_test.py index fc3356ecdb..d047bda046 100644 --- a/tests/interchange/get_level_test.py +++ b/tests/interchange/get_level_test.py @@ -1,23 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, Any import pytest -import narwhals as nw import narwhals.stable.v1 as nw_v1 if TYPE_CHECKING: - from narwhals._typing import EagerAllowed - - -MainInstances: TypeAlias = tuple[nw.DataFrame[Any], nw.LazyFrame[Any], nw.Series[Any]] - - -@pytest.fixture -def main_instances(eager_implementation: EagerAllowed) -> MainInstances: - df = nw.DataFrame.from_dict({"a": [1, 2, 3]}, backend=eager_implementation) - return df, df.lazy(), df.get_column("a") + from tests.interchange.conftest import MainInstances def test_get_level() -> None: From bb05096a43df0dbb5fe2bee8ffe6bd9bf1d55337 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:10:00 +0000 Subject: [PATCH 39/43] fix: exclude `cudf.DataFrame` too https://github.com/narwhals-dev/narwhals/pull/3721#discussion_r3519170167 --- src/narwhals/_interchange/dataframe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 2d36a634e1..8abbe06500 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -8,6 +8,7 @@ from narwhals._utils import Implementation, Version, _hasattr_static, parse_version from narwhals.dependencies import ( IMPORT_HOOKS, + get_cudf, get_dask_dataframe, get_duckdb, get_modin, From 28545e04688b9cb77ce28d7dc4ea95b5b7983839 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:32:02 +0000 Subject: [PATCH 40/43] refactor: lower density, more clarity? https://github.com/narwhals-dev/narwhals/pull/3721#discussion_r3516355093 --- src/narwhals/_interchange/dataframe.py | 38 ++++++++++++++++++++------ 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 8abbe06500..6112e47273 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -7,7 +7,7 @@ from narwhals._utils import Implementation, Version, _hasattr_static, parse_version from narwhals.dependencies import ( - IMPORT_HOOKS, + IMPORT_HOOKS as PANDAS_IMPORT_HOOKS, get_cudf, get_dask_dataframe, get_duckdb, @@ -18,6 +18,8 @@ ) if TYPE_CHECKING: + from collections.abc import Iterator + import pandas as pd import pyarrow as pa from typing_extensions import Self, TypeIs @@ -176,14 +178,34 @@ def should_interchange(obj: object) -> TypeIs[DataFrameLike]: return _should_interchange(type(obj)) # type: ignore[arg-type] +def _iter_exclude_interchange() -> Iterator[type[DataFrameLike]]: + """Yield classes that we want to **avoid** using with `__dataframe__`. + + We *could* use them with interchange-level support - but we have a better option at home. + + Sadly this type is [not yet](https://www.youtube.com/watch?v=xUsPD7iwETY) spellable: + + (DataFrameLike & (pl.DataFrame | pd.DataFrame | pa.Table | dd.DataFrame | mpd.DataFrame | cudf.DataFrame) + # | union + # intersection + + We gathers all the types (that are already imported) which fit the the right-hand-side, + as we need to refine from the set produced by checking `__dataframe__`. + """ + has_top_level_df = (get_polars, get_pandas, get_dask_dataframe, get_modin, get_cudf) + for get_module in has_top_level_df: + if module := get_module(): + yield module.DataFrame + if pa := get_pyarrow(): + yield pa.Table + for module_name in PANDAS_IMPORT_HOOKS: + if module := sys.modules.get(module_name): + yield module.pandas.DataFrame + + @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: if not _hasattr_static(tp_native, "__dataframe__"): return (duckdb := get_duckdb()) and issubclass(tp_native, duckdb.DuckDBPyRelation) - has_top_level_df = (get_polars, get_pandas, get_dask_dataframe, get_modin) - exclude = tuple(mod.DataFrame for get in has_top_level_df if (mod := get())) - exclude = (*exclude, pa.Table) if (pa := get_pyarrow()) else exclude - hooks = ( - mod.pandas.DataFrame for name in IMPORT_HOOKS if (mod := sys.modules.get(name)) - ) - return not (exclude := (*exclude, *hooks)) or not issubclass(tp_native, exclude) + exclude_intersection = tuple(_iter_exclude_interchange()) + return (not exclude_intersection) or (not issubclass(tp_native, exclude_intersection)) From 8eb114c45ea007718ae131c360216a0288bfa04e Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:52:10 +0000 Subject: [PATCH 41/43] chore: guard against `ibis` removing `__dataframe__` support - If they removed it, we'd want to treat they like duckdb - but only beyond that future version --- src/narwhals/_interchange/dataframe.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 6112e47273..e344814bb5 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -11,6 +11,7 @@ get_cudf, get_dask_dataframe, get_duckdb, + get_ibis, get_modin, get_pandas, get_polars, @@ -206,6 +207,8 @@ def _iter_exclude_interchange() -> Iterator[type[DataFrameLike]]: @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: if not _hasattr_static(tp_native, "__dataframe__"): - return (duckdb := get_duckdb()) and issubclass(tp_native, duckdb.DuckDBPyRelation) + include = (duckdb.DuckDBPyRelation,) if (duckdb := get_duckdb()) else () + include = (*include, ibis.Table) if (ibis := get_ibis()) else include + return bool(include) and issubclass(tp_native, include) exclude_intersection = tuple(_iter_exclude_interchange()) return (not exclude_intersection) or (not issubclass(tp_native, exclude_intersection)) From 3199d035e01f9b373163dff2f31be05efd61f372 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:54:19 +0000 Subject: [PATCH 42/43] no cov fireducks --- src/narwhals/_interchange/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index e344814bb5..c32a2bb714 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -199,7 +199,7 @@ def _iter_exclude_interchange() -> Iterator[type[DataFrameLike]]: yield module.DataFrame if pa := get_pyarrow(): yield pa.Table - for module_name in PANDAS_IMPORT_HOOKS: + for module_name in PANDAS_IMPORT_HOOKS: # pragma: no cover if module := sys.modules.get(module_name): yield module.pandas.DataFrame From 955a677a1ebd5960686a8f13ebd90b91fb91c432 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:55:30 +0000 Subject: [PATCH 43/43] refactor: invert checks, explain a bit more --- src/narwhals/_interchange/dataframe.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index c32a2bb714..0c58fc9633 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -176,6 +176,7 @@ def native(self) -> Any: def should_interchange(obj: object) -> TypeIs[DataFrameLike]: + """Return True if we'd use interchange/metadata-level support for `obj`.""" return _should_interchange(type(obj)) # type: ignore[arg-type] @@ -206,9 +207,11 @@ def _iter_exclude_interchange() -> Iterator[type[DataFrameLike]]: @lru_cache(64) def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]: - if not _hasattr_static(tp_native, "__dataframe__"): - include = (duckdb.DuckDBPyRelation,) if (duckdb := get_duckdb()) else () - include = (*include, ibis.Table) if (ibis := get_ibis()) else include - return bool(include) and issubclass(tp_native, include) - exclude_intersection = tuple(_iter_exclude_interchange()) - return (not exclude_intersection) or (not issubclass(tp_native, exclude_intersection)) + if _hasattr_static(tp_native, "__dataframe__"): + # Ensure we use `__dataframe__` when it is the **only** option we have + exclude = tuple(_iter_exclude_interchange()) + return (not exclude) or (not issubclass(tp_native, exclude)) + include = (duckdb.DuckDBPyRelation,) if (duckdb := get_duckdb()) else () + # Gracefully handle `ibis` removing `__dataframe__` support in the future (we don't use it anyway) + include = (*include, ibis.Table) if (ibis := get_ibis()) else include + return bool(include) and issubclass(tp_native, include)