diff --git a/src/narwhals/_duckdb/dataframe.py b/src/narwhals/_duckdb/dataframe.py index 355b34a394..e2e8d0bac2 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,23 +46,15 @@ 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]", - ], - ValidateBackendVersion, + SQLLazyFrame["DuckDBExpr", "duckdb.DuckDBPyRelation"], ValidateBackendVersion ): _implementation = Implementation.DUCKDB @@ -77,7 +69,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 +86,6 @@ 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] - 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 +97,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 +105,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 +158,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 +209,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 +482,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 +495,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..2cc125e0b2 --- /dev/null +++ b/src/narwhals/_duckdb/interchange.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from duckdb import DuckDBPyRelation + +from narwhals import _interchange +from narwhals._duckdb.dataframe import to_arrow_table +from narwhals._utils import Implementation + +if TYPE_CHECKING: + import pandas as pd + import pyarrow as pa + + +class DuckDBDataFrame(_interchange.LazyFrame[DuckDBPyRelation]): + _implementation = Implementation.DUCKDB + + def to_pandas(self) -> pd.DataFrame: + return self._compliant.native.df() + + def to_arrow(self) -> pa.Table: + return to_arrow_table(self._compliant.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/_duckdb/utils.py b/src/narwhals/_duckdb/utils.py index ae84fe1d99..81869422aa 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): diff --git a/src/narwhals/_ibis/dataframe.py b/src/narwhals/_ibis/dataframe.py index f089093803..aac85caaec 100644 --- a/src/narwhals/_ibis/dataframe.py +++ b/src/narwhals/_ibis/dataframe.py @@ -27,29 +27,21 @@ 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, -): +class IbisLazyFrame(SQLLazyFrame["IbisExpr", "ir.Table"], ValidateBackendVersion): _implementation = Implementation.IBIS def __init__( @@ -70,20 +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] | DataFrameV1[ir.Table]: - if self._version is Version.V1: - from narwhals.stable.v1 import DataFrame - - return DataFrame(self) - 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 +73,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,16 +140,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)) - 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)) @@ -207,14 +170,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..d8dfcd15e2 --- /dev/null +++ b/src/narwhals/_ibis/interchange.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ibis + +from narwhals import _interchange +from narwhals._utils import Implementation + +if TYPE_CHECKING: + import pandas as pd + import pyarrow as pa + + +class IbisDataFrame(_interchange.LazyFrame[ibis.Table]): + _implementation = Implementation.IBIS + + def to_pandas(self) -> pd.DataFrame: + return self._compliant.native.to_pandas() + + def to_arrow(self) -> pa.Table: + return self._compliant.native.to_pyarrow() 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..07e47b56d0 100644 --- a/src/narwhals/_interchange/__init__.py +++ b/src/narwhals/_interchange/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from narwhals._interchange.dataframe import ( + InterchangeFrame, + InterchangeSeries, + should_interchange, +) +from narwhals._interchange.lazyframe import LazyFrame + +__all__ = "InterchangeFrame", "InterchangeSeries", "LazyFrame", "should_interchange" diff --git a/src/narwhals/_interchange/dataframe.py b/src/narwhals/_interchange/dataframe.py index 9328716de2..0c58fc9633 100644 --- a/src/narwhals/_interchange/dataframe.py +++ b/src/narwhals/_interchange/dataframe.py @@ -1,16 +1,30 @@ from __future__ import annotations import enum +import sys +from functools import lru_cache from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol from narwhals._utils import Implementation, Version, _hasattr_static, parse_version +from narwhals.dependencies import ( + IMPORT_HOOKS as PANDAS_IMPORT_HOOKS, + get_cudf, + get_dask_dataframe, + get_duckdb, + get_ibis, + get_modin, + get_pandas, + get_polars, + get_pyarrow, +) if TYPE_CHECKING: + from collections.abc import Iterator + 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 @@ -77,28 +91,37 @@ def map_interchange_dtype_to_narwhals_dtype( # noqa: C901, PLR0911, PLR0912 raise AssertionError(msg) -class InterchangeFrame: - _version = Version.V1 +def unsupported_error(method_name: str) -> NotImplementedError: + msg = ( + 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 _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._interchange_frame = df.__dataframe__() + 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." - "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) - 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() @@ -109,57 +132,86 @@ 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) -> NoReturn: - 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)) - 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) + frame = self._dfi.select_columns_by_name(list(column_names)) 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) +class InterchangeSeries(_Interchange): + def __init__(self, df: Any) -> None: + self._native_series = df + + def __narwhals_series__(self) -> Self: + return self + + @property + def dtype(self) -> DType: + return map_interchange_dtype_to_narwhals_dtype(self._native_series.dtype) -def supports_dataframe_interchange(obj: Any) -> TypeIs[DataFrameLike]: - return _hasattr_static(obj, "__dataframe__") + @property + def native(self) -> Any: + return self._native_series + + +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] + + +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: # pragma: no cover + 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 _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) diff --git a/src/narwhals/_interchange/lazyframe.py b/src/narwhals/_interchange/lazyframe.py new file mode 100644 index 0000000000..16c12c2619 --- /dev/null +++ b/src/narwhals/_interchange/lazyframe.py @@ -0,0 +1,107 @@ +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 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: LazyFrameOps = frozenset( + ( + "native", + "__native_namespace__", + "_native_frame", + "schema", + "columns", + "collect_schema", + ) + ) + + def simple_select(self, *column_names: str) -> Self: + return self.from_compliant(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_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.from_compliant(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 _typed_getattr_impl(self, name) + + +class Series(Generic[NativeT]): + _lazy: LazyFrame[NativeT] + _version: Version = Version.V1 + _ALLOW: LazyFrameOps = 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 _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) diff --git a/src/narwhals/_interchange/series.py b/src/narwhals/_interchange/series.py deleted file mode 100644 index b86666df33..0000000000 --- a/src/narwhals/_interchange/series.py +++ /dev/null @@ -1,48 +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 -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. " - "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) - - @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: - 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) 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) 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 2b7d68a6ce..79a8f83b74 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 import InterchangeFrame, InterchangeSeries, should_interchange from narwhals._typing_compat import TypeVar, assert_never from narwhals._utils import ( Implementation, @@ -622,7 +623,7 @@ def from_native( ) -> Any: ... -def from_native( +def from_native( # noqa: PLR0911 native_object: IntoDataFrameT | IntoLazyFrameT | IntoFrame @@ -656,12 +657,32 @@ def from_native( if kwds: 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 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.from_native(native_object)) + + if dependencies.is_duckdb_relation(native_object): + from narwhals._duckdb.interchange import DuckDBDataFrame + + return DataFrame(DuckDBDataFrame.from_native(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_or_interchange_only=eager_or_interchange_only, + eager_only=eager_only or eager_or_interchange_only, series_only=series_only, allow_series=allow_series, version=Version.V1, @@ -924,9 +945,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.dataframe import InterchangeFrame - from narwhals._interchange.series import InterchangeSeries - ensure_type(obj, DataFrame, Series, LazyFrame) compliant = obj._compliant impl = obj.implementation diff --git a/src/narwhals/stable/v2/__init__.py b/src/narwhals/stable/v2/__init__.py index c9005d30f3..239393fbee 100644 --- a/src/narwhals/stable/v2/__init__.py +++ b/src/narwhals/stable/v2/__init__.py @@ -486,7 +486,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..4313767da2 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,18 @@ 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` " - 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() - # 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/interchange/__init__.py b/tests/interchange/__init__.py new file mode 100644 index 0000000000..565f52b60e --- /dev/null +++ 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 +""" 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 new file mode 100644 index 0000000000..4977a90fed --- /dev/null +++ b/tests/interchange/dependencies_test.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +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 tests.interchange.conftest import Interchange + + +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 diff --git a/tests/interchange/from_native_test.py b/tests/interchange/from_native_test.py new file mode 100644 index 0000000000..bab2302cf7 --- /dev/null +++ b/tests/interchange/from_native_test.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from tests.interchange.conftest import 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/interchange/get_level_test.py b/tests/interchange/get_level_test.py new file mode 100644 index 0000000000..d047bda046 --- /dev/null +++ b/tests/interchange/get_level_test.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +import narwhals.stable.v1 as nw_v1 + +if TYPE_CHECKING: + from tests.interchange.conftest import MainInstances + + +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/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 94% rename from tests/frame/interchange_schema_test.py rename to tests/interchange/schema_test.py index 829f7041c8..1bcee96f2d 100644 --- a/tests/frame/interchange_schema_test.py +++ b/tests/interchange/schema_test.py @@ -242,23 +242,15 @@ def test_interchange_schema_float16() -> None: 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=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] -# 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`" - ), - raises=ValueError, -) 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) diff --git a/tests/frame/interchange_select_test.py b/tests/interchange/select_test.py similarity index 94% rename from tests/frame/interchange_select_test.py rename to tests/interchange/select_test.py index a927ba18c6..5189ff364e 100644 --- a/tests/frame/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) 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 diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py index 662693a6da..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` or `eager_or_interchange_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 @@ -322,18 +316,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 289014e1d4..fda104653e 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 @@ -44,15 +44,27 @@ 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 +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() or frame.implementation.is_duckdb(), + 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 @@ -275,20 +287,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 @@ -334,15 +332,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: @@ -422,69 +424,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") @@ -523,12 +462,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 = ( @@ -653,17 +593,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 @@ -965,8 +894,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 @@ -1276,7 +1207,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()) 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