Repository: narwhals-dev/narwhals
Pull request #1832 (2025-01-19 17:40:14)

feat: add DuckDB: nw.nth, nw.sum_horizontal, nw.concat_str, group_by with drop_null_keys
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/docs/backcompat.md b/docs/backcompat.md
index b2d312e0..78e01e46 100644
--- a/docs/backcompat.md
+++ b/docs/backcompat.md
@@ -111,6 +111,16 @@ before making any change.
 
 ### After `stable.v1`
 
+The following are differences between the main Narwhals namespace and `narwhals.stable.v1`:
+
+- Since Narwhals 1.23:
+
+  - Passing an `ibis.Table` to `from_native` returns a `LazyFrame`. In
+    `narwhals.stable.v1`, it returns a `DataFrame` with `level='interchange'`.
+  - `eager_or_interchange_only` has been removed from `from_native` and `narwhalify`.
+  - Order-dependent expressions can no longer be used with `narwhals.LazyFrame`.
+  - The following expressions have been deprecated from the main namespace: `Expr.head`,
+    `Expr.tail`, `Expr.gather_every`, `Expr.sample`, `Expr.arg_true`, `Expr.sort`.
 
 - Since Narwhals 1.21, passing a `DuckDBPyRelation` to `from_native` returns a `LazyFrame`. In
   `narwhals.stable.v1`, it returns a `DataFrame` with `level='interchange'`.
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index e1ad9550..df64eba5 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -378,19 +378,19 @@ class ArrowNamespace(CompliantNamespace[ArrowSeries]):
         dtypes = import_dtypes_module(self._version)
 
         def func(df: ArrowDataFrame) -> list[ArrowSeries]:
-            series = (
-                s._native_series
-                for _expr in parsed_exprs
-                for s in _expr.cast(dtypes.String())(df)
-            )
+            compliant_series_list = [
+                s for _expr in parsed_exprs for s in _expr.cast(dtypes.String())(df)
+            ]
             null_handling = "skip" if ignore_nulls else "emit_null"
             result_series = pc.binary_join_element_wise(
-                *series, separator, null_handling=null_handling
+                *(s._native_series for s in compliant_series_list),
+                separator,
+                null_handling=null_handling,
             )
             return [
                 ArrowSeries(
                     native_series=result_series,
-                    name="",
+                    name=compliant_series_list[0].name,
                     backend_version=self._backend_version,
                     version=self._version,
                 )
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 485163a0..b3b3df90 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -354,7 +354,7 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
                     init_value,
                 )
 
-            return [result]
+            return [result.rename(null_mask[0].name)]
 
         return DaskExpr(
             call=func,
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index 0bd2c9f0..1bef37d6 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -196,10 +196,6 @@ class DuckDBLazyFrame:
     def group_by(self: Self, *keys: str, drop_null_keys: bool) -> DuckDBGroupBy:
         from narwhals._duckdb.group_by import DuckDBGroupBy
 
-        if drop_null_keys:
-            msg = "todo"
-            raise NotImplementedError(msg)
-
         return DuckDBGroupBy(
             compliant_frame=self, keys=list(keys), drop_null_keys=drop_null_keys
         )
diff --git a/narwhals/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index c79acdfa..494063eb 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -92,6 +92,32 @@ class DuckDBExpr(CompliantExpr["duckdb.Expression"]):
             kwargs={},
         )
 
+    @classmethod
+    def from_column_indices(
+        cls: type[Self],
+        *column_indices: int,
+        backend_version: tuple[int, ...],
+        version: Version,
+    ) -> Self:
+        def func(df: DuckDBLazyFrame) -> list[duckdb.Expression]:
+            from duckdb import ColumnExpression
+
+            columns = df.columns
+
+            return [ColumnExpression(columns[i]) for i in column_indices]
+
+        return cls(
+            func,
+            depth=0,
+            function_name="nth",
+            root_names=None,
+            output_names=None,
+            returns_scalar=False,
+            backend_version=backend_version,
+            version=version,
+            kwargs={},
+        )
+
     def _from_call(
         self,
         call: Callable[..., duckdb.Expression],
@@ -515,6 +541,24 @@ class DuckDBExpr(CompliantExpr["duckdb.Expression"]):
             lambda _input: _input.isnull(), "is_null", returns_scalar=self._returns_scalar
         )
 
+    def is_nan(self) -> Self:
+        from duckdb import FunctionExpression
+
+        return self._from_call(
+            lambda _input: FunctionExpression("isnan", _input),
+            "is_nan",
+            returns_scalar=self._returns_scalar,
+        )
+
+    def is_finite(self) -> Self:
+        from duckdb import FunctionExpression
+
+        return self._from_call(
+            lambda _input: FunctionExpression("isfinite", _input),
+            "is_finite",
+            returns_scalar=self._returns_scalar,
+        )
+
     def is_in(self, other: Sequence[Any]) -> Self:
         from duckdb import ConstantExpression
 
diff --git a/narwhals/_duckdb/group_by.py b/narwhals/_duckdb/group_by.py
index ce81a157..7cf07d13 100644
--- a/narwhals/_duckdb/group_by.py
+++ b/narwhals/_duckdb/group_by.py
@@ -18,7 +18,10 @@ class DuckDBGroupBy:
         keys: list[str],
         drop_null_keys: bool,  # noqa: FBT001
     ) -> None:
-        self._compliant_frame = compliant_frame
+        if drop_null_keys:
+            self._compliant_frame = compliant_frame.drop_nulls(subset=None)
+        else:
+            self._compliant_frame = compliant_frame
         self._keys = keys
 
     def agg(
@@ -46,7 +49,7 @@ class DuckDBGroupBy:
         try:
             return self._compliant_frame._from_native_frame(
                 self._compliant_frame._native_frame.aggregate(
-                    agg_columns, group_expr=",".join(self._keys)
+                    agg_columns, group_expr=",".join(f'"{key}"' for key in self._keys)
                 )
             )
         except ValueError as exc:  # pragma: no cover
diff --git a/narwhals/_duckdb/namespace.py b/narwhals/_duckdb/namespace.py
index c516336c..72615a68 100644
--- a/narwhals/_duckdb/namespace.py
+++ b/narwhals/_duckdb/namespace.py
@@ -5,6 +5,7 @@ import operator
 from functools import reduce
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Iterable
 from typing import Literal
 from typing import Sequence
 from typing import cast
@@ -74,6 +75,83 @@ class DuckDBNamespace(CompliantNamespace["duckdb.Expression"]):
         )
         return first._from_native_frame(res)
 
+    def concat_str(
+        self,
+        exprs: Iterable[IntoDuckDBExpr],
+        *more_exprs: IntoDuckDBExpr,
+        separator: str,
+        ignore_nulls: bool,
+    ) -> DuckDBExpr:
+        parsed_exprs = [
+            *parse_into_exprs(*exprs, namespace=self),
+            *parse_into_exprs(*more_exprs, namespace=self),
+        ]
+        from duckdb import CaseExpression
+        from duckdb import ConstantExpression
+        from duckdb import FunctionExpression
+
+        def func(df: DuckDBLazyFrame) -> list[duckdb.Expression]:
+            cols = [s for _expr in parsed_exprs for s in _expr(df)]
+            null_mask = [s.isnull() for _expr in parsed_exprs for s in _expr(df)]
+            first_column_name = get_column_name(df, cols[0])
+
+            if not ignore_nulls:
+                null_mask_result = reduce(lambda x, y: x | y, null_mask)
+                cols_separated = [
+                    y
+                    for x in [
+                        (col.cast("string"),)
+                        if i == len(cols) - 1
+                        else (col.cast("string"), ConstantExpression(separator))
+                        for i, col in enumerate(cols)
+                    ]
+                    for y in x
+                ]
+                result = CaseExpression(
+                    condition=~null_mask_result,
+                    value=FunctionExpression("concat", *cols_separated),
+                )
+            else:
+                init_value, *values = [
+                    CaseExpression(~nm, col.cast("string")).otherwise(
+                        ConstantExpression("")
+                    )
+                    for col, nm in zip(cols, null_mask)
+                ]
+                separators = (
+                    CaseExpression(nm, ConstantExpression("")).otherwise(
+                        ConstantExpression(separator)
+                    )
+                    for nm in null_mask[:-1]
+                )
+                result = reduce(
+                    lambda x, y: FunctionExpression("concat", x, y),
+                    (
+                        FunctionExpression("concat", s, v)
+                        for s, v in zip(separators, values)
+                    ),
+                    init_value,
+                )
+
+            return [result.alias(first_column_name)]
+
+        return DuckDBExpr(
+            call=func,
+            depth=max(x._depth for x in parsed_exprs) + 1,
+            function_name="concat_str",
+            root_names=combine_root_names(parsed_exprs),
+            output_names=reduce_output_names(parsed_exprs),
+            returns_scalar=False,
+            backend_version=self._backend_version,
+            version=self._version,
+            kwargs={
+                "exprs": exprs,
+                "more_exprs": more_exprs,
+                "separator": separator,
+                "ignore_nulls": ignore_nulls,
+            },
+        )
+
     def all_horizontal(self, *exprs: IntoDuckDBExpr) -> DuckDBExpr:
         parsed_exprs = parse_into_exprs(*exprs, namespace=self)
 
@@ -158,6 +236,34 @@ class DuckDBNamespace(CompliantNamespace["duckdb.Expression"]):
             kwargs={"exprs": exprs},
         )
 
+    def sum_horizontal(self, *exprs: IntoDuckDBExpr) -> DuckDBExpr:
+        from duckdb import CoalesceOperator
+        from duckdb import ConstantExpression
+
+        parsed_exprs = parse_into_exprs(*exprs, namespace=self)
+
+        def func(df: DuckDBLazyFrame) -> list[duckdb.Expression]:
+            cols = [c for _expr in parsed_exprs for c in _expr(df)]
+            col_name = get_column_name(df, cols[0])
+            return [
+                reduce(
+                    operator.add,
+                    (CoalesceOperator(col, ConstantExpression(0)) for col in cols),
+                ).alias(col_name)
+            ]
+
+        return DuckDBExpr(
+            call=func,
+            depth=max(x._depth for x in parsed_exprs) + 1,
+            function_name="sum_horizontal",
+            root_names=combine_root_names(parsed_exprs),
+            output_names=reduce_output_names(parsed_exprs),
+            returns_scalar=False,
+            backend_version=self._backend_version,
+            version=self._version,
+            kwargs={"exprs": exprs},
+        )
+
     def when(
         self,
         *predicates: IntoDuckDBExpr,
@@ -173,6 +279,11 @@ class DuckDBNamespace(CompliantNamespace["duckdb.Expression"]):
             *column_names, backend_version=self._backend_version, version=self._version
         )
 
+    def nth(self, *column_indices: int) -> DuckDBExpr:
+        return DuckDBExpr.from_column_indices(
+            *column_indices, backend_version=self._backend_version, version=self._version
+        )
+
     def lit(self, value: Any, dtype: DType | None) -> DuckDBExpr:
         from duckdb import ConstantExpression
 
diff --git a/narwhals/_ibis/dataframe.py b/narwhals/_ibis/dataframe.py
index 6fe8997a..6adb0f08 100644
--- a/narwhals/_ibis/dataframe.py
+++ b/narwhals/_ibis/dataframe.py
@@ -6,6 +6,7 @@ from typing import Any
 
 from narwhals.dependencies import get_ibis
 from narwhals.utils import Implementation
+from narwhals.utils import Version
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import validate_backend_version
 
@@ -18,7 +19,6 @@ if TYPE_CHECKING:
 
     from narwhals._ibis.series import IbisInterchangeSeries
     from narwhals.dtypes import DType
-    from narwhals.utils import Version
 
 
 @lru_cache(maxsize=16)
@@ -70,7 +70,7 @@ def native_to_narwhals_dtype(ibis_dtype: Any, version: Version) -> DType:
     return dtypes.Unknown()  # pragma: no cover
 
 
-class IbisInterchangeFrame:
+class IbisLazyFrame:
     _implementation = Implementation.IBIS
 
     def __init__(
@@ -81,7 +81,14 @@ class IbisInterchangeFrame:
         self._backend_version = backend_version
         validate_backend_version(self._implementation, self._backend_version)
 
-    def __narwhals_dataframe__(self) -> Any:
+    def __narwhals_dataframe__(self) -> Any:  # 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) -> Any:
         return self
 
     def __native_namespace__(self: Self) -> ModuleType:
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 28b7a903..5075a2b4 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -509,6 +509,8 @@ def native_to_narwhals_dtype(
                 )
             except Exception:  # noqa: BLE001, S110
                 pass
+        # The most useful assumption is probably String
+        return dtypes.String()
     return dtypes.Unknown()  # pragma: no cover
 
 
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index ba063c64..831b7088 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -92,6 +92,31 @@ class SparkLikeExpr(CompliantExpr["Column"]):
             kwargs={},
         )
 
+    @classmethod
+    def from_column_indices(
+        cls: type[Self],
+        *column_indices: int,
+        backend_version: tuple[int, ...],
+        version: Version,
+    ) -> Self:
+        def func(df: SparkLikeLazyFrame) -> list[Column]:
+            from pyspark.sql import functions as F  # noqa: N812
+
+            columns = df.columns
+            return [F.col(columns[i]) for i in column_indices]
+
+        return cls(
+            func,
+            depth=0,
+            function_name="nth",
+            root_names=None,
+            output_names=None,
+            returns_scalar=False,
+            backend_version=backend_version,
+            version=version,
+            kwargs={},
+        )
+
     def _from_call(
         self,
         call: Callable[..., Column],
@@ -433,13 +458,12 @@ class SparkLikeExpr(CompliantExpr["Column"]):
         def _is_finite(_input: Column) -> Column:
             from pyspark.sql import functions as F  # noqa: N812
 
-            # A value is finite if it's not NaN, not NULL, and not infinite
-            return (
-                ~F.isnan(_input)
-                & ~F.isnull(_input)
-                & (_input != float("inf"))
-                & (_input != float("-inf"))
+            # A value is finite if it's not NaN, and not infinite, while NULLs should be
+            # preserved
+            is_finite_condition = (
+                ~F.isnan(_input) & (_input != float("inf")) & (_input != float("-inf"))
             )
+            return F.when(~F.isnull(_input), is_finite_condition).otherwise(None)
 
         return self._from_call(
             _is_finite, "is_finite", returns_scalar=self._returns_scalar
@@ -535,6 +559,14 @@ class SparkLikeExpr(CompliantExpr["Column"]):
 
         return self._from_call(F.isnull, "is_null", returns_scalar=self._returns_scalar)
 
+    def is_nan(self: Self) -> Self:
+        from pyspark.sql import functions as F  # noqa: N812
+
+        def _is_nan(_input: Column) -> Column:
+            return F.when(F.isnull(_input), None).otherwise(F.isnan(_input))
+
+        return self._from_call(_is_nan, "is_nan", returns_scalar=self._returns_scalar)
+
     @property
     def str(self: Self) -> SparkLikeExprStringNamespace:
         return SparkLikeExprStringNamespace(self)
diff --git a/narwhals/_spark_like/expr_dt.py b/narwhals/_spark_like/expr_dt.py
index 4c31d6b6..a007c744 100644
--- a/narwhals/_spark_like/expr_dt.py
+++ b/narwhals/_spark_like/expr_dt.py
@@ -44,7 +44,7 @@ class SparkLikeExprDateTimeNamespace:
         from pyspark.sql import functions as F  # noqa: N812
 
         return self._compliant_expr._from_call(
-            F.dayofmonth,
+            F.day,
             "day",
             returns_scalar=self._compliant_expr._returns_scalar,
         )
diff --git a/narwhals/_spark_like/namespace.py b/narwhals/_spark_like/namespace.py
index bf876a88..0d76ee70 100644
--- a/narwhals/_spark_like/namespace.py
+++ b/narwhals/_spark_like/namespace.py
@@ -53,6 +53,11 @@ class SparkLikeNamespace(CompliantNamespace["Column"]):
             *column_names, backend_version=self._backend_version, version=self._version
         )
 
+    def nth(self, *column_indices: int) -> SparkLikeExpr:
+        return SparkLikeExpr.from_column_indices(
+            *column_indices, backend_version=self._backend_version, version=self._version
+        )
+
     def lit(self, value: object, dtype: DType | None) -> SparkLikeExpr:
         if dtype is not None:
             msg = "todo"
@@ -293,19 +298,24 @@ class SparkLikeNamespace(CompliantNamespace["Column"]):
         ]
 
         def func(df: SparkLikeLazyFrame) -> list[Column]:
-            cols = (s.cast(StringType()) for _expr in parsed_exprs for s in _expr(df))
+            cols = [s for _expr in parsed_exprs for s in _expr(df)]
+            cols_casted = [s.cast(StringType()) for s in cols]
             null_mask = [F.isnull(s) for _expr in parsed_exprs for s in _expr(df)]
+            first_column_name = get_column_name(df, cols[0])
 
             if not ignore_nulls:
                 null_mask_result = reduce(lambda x, y: x | y, null_mask)
                 result = F.when(
                     ~null_mask_result,
-                    reduce(lambda x, y: F.format_string(f"%s{separator}%s", x, y), cols),
+                    reduce(
+                        lambda x, y: F.format_string(f"%s{separator}%s", x, y),
+                        cols_casted,
+                    ),
                 ).otherwise(F.lit(None))
             else:
                 init_value, *values = [
                     F.when(~nm, col).otherwise(F.lit(""))
-                    for col, nm in zip(cols, null_mask)
+                    for col, nm in zip(cols_casted, null_mask)
                 ]
 
                 separators = (
@@ -318,7 +328,7 @@ class SparkLikeNamespace(CompliantNamespace["Column"]):
                     init_value,
                 )
 
-            return [result]
+            return [result.alias(first_column_name)]
 
         return SparkLikeExpr(
             call=func,
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 6ed82326..9c455055 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -132,7 +132,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[True],
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
 ) -> DataFrame[IntoDataFrameT]: ...
@@ -144,7 +143,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[True],
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
 ) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
@@ -156,7 +154,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[True],
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> DataFrame[IntoDataFrameT]: ...
@@ -168,7 +165,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[True],
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> T: ...
@@ -180,7 +176,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[True],
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> DataFrame[IntoDataFrameT]: ...
@@ -192,7 +187,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[True],
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> T: ...
@@ -204,7 +198,6 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
 ) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT]: ...
@@ -216,43 +209,17 @@ def from_native(
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
 ) -> Series[IntoSeriesT]: ...
 
 
-@overload
-def from_native(
-    native_object: IntoFrameT,
-    *,
-    pass_through: Literal[True],
-    eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
-    series_only: Literal[False] = ...,
-    allow_series: None = ...,
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: ...
-
-
-@overload
-def from_native(
-    native_object: T,
-    *,
-    pass_through: Literal[True],
-    eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
-    series_only: Literal[False] = ...,
-    allow_series: None = ...,
-) -> T: ...
-
-
 @overload
 def from_native(
     native_object: IntoDataFrameT,
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[True],
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> DataFrame[IntoDataFrameT]: ...
@@ -264,7 +231,6 @@ def from_native(
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[True],
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> DataFrame[IntoDataFrameT]: ...
@@ -276,7 +242,6 @@ def from_native(
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
 ) -> DataFrame[Any] | LazyFrame[Any] | Series[Any]: ...
@@ -288,7 +253,6 @@ def from_native(
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
 ) -> Series[IntoSeriesT]: ...
@@ -300,7 +264,6 @@ def from_native(
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
-    eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: None = ...,
 ) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: ...
@@ -313,7 +276,6 @@ def from_native(
     *,
     pass_through: bool,
     eager_only: bool,
-    eager_or_interchange_only: bool = False,
     series_only: bool,
     allow_series: bool | None,
 ) -> Any: ...
@@ -325,7 +287,6 @@ def from_native(
     strict: bool | None = None,
     pass_through: bool | None = None,
     eager_only: bool = False,
-    eager_or_interchange_only: bool = False,
     series_only: bool = False,
     allow_series: bool | None = None,
 ) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
@@ -355,16 +316,6 @@ def from_native(
 
             - `False` (default): don't require `native_object` to be eager
             - `True`: only convert to Narwhals if `native_object` is eager
-        eager_or_interchange_only: Whether to only allow eager objects or objects which
-            have interchange-level support in Narwhals:
-
-            - `False` (default): don't require `native_object` to either be eager or to
-              have interchange-level support in Narwhals
-            - `True`: only convert to Narwhals if `native_object` is eager or has
-              interchange-level support in Narwhals
-
-            See [interchange-only support](../extending.md/#interchange-only-support)
-            for more details.
         series_only: Whether to only allow Series:
 
             - `False` (default): don't require `native_object` to be a Series
@@ -388,7 +339,7 @@ def from_native(
         native_object,
         pass_through=pass_through,
         eager_only=eager_only,
-        eager_or_interchange_only=eager_or_interchange_only,
+        eager_or_interchange_only=False,
         series_only=series_only,
         allow_series=allow_series,
         version=Version.MAIN,
@@ -400,6 +351,7 @@ def _from_native_impl(  # noqa: PLR0915
     *,
     pass_through: bool = False,
     eager_only: bool = False,
+    # Interchange-level was removed after v1
     eager_or_interchange_only: bool = False,
     series_only: bool = False,
     allow_series: bool | None = None,
@@ -728,12 +680,12 @@ def _from_native_impl(  # noqa: PLR0915
             DuckDBLazyFrame(
                 native_object, backend_version=backend_version, version=version
             ),
-            level="full",
+            level="lazy",
         )
 
     # Ibis
     elif is_ibis_table(native_object):  # pragma: no cover
-        from narwhals._ibis.dataframe import IbisInterchangeFrame
+        from narwhals._ibis.dataframe import IbisLazyFrame
 
         if eager_only or series_only:
             if not pass_through:
@@ -746,11 +698,18 @@ def _from_native_impl(  # noqa: PLR0915
         import ibis  # ignore-banned-import
 
         backend_version = parse_version(ibis.__version__)
-        return DataFrame(
-            IbisInterchangeFrame(
-                native_object, version=version, backend_version=backend_version
+        if version is Version.V1:
+            return DataFrame(
+                IbisLazyFrame(
+                    native_object, backend_version=backend_version, version=version
+                ),
+                level="interchange",
+            )
+        return LazyFrame(
+            IbisLazyFrame(
+                native_object, backend_version=backend_version, version=version
             ),
-            level="interchange",
+            level="lazy",
         )
 
     # PySpark
@@ -850,7 +809,6 @@ def narwhalify(
     strict: bool | None = None,
     pass_through: bool | None = None,
     eager_only: bool = False,
-    eager_or_interchange_only: bool = False,
     series_only: bool = False,
     allow_series: bool | None = True,
 ) -> Callable[..., Any]:
@@ -883,16 +841,6 @@ def narwhalify(
 
             - `False` (default): don't require `native_object` to be eager
             - `True`: only convert to Narwhals if `native_object` is eager
-        eager_or_interchange_only: Whether to only allow eager objects or objects which
-            have interchange-level support in Narwhals:
-
-            - `False` (default): don't require `native_object` to either be eager or to
-              have interchange-level support in Narwhals
-            - `True`: only convert to Narwhals if `native_object` is eager or has
-              interchange-level support in Narwhals
-
-            See [interchange-only support](../extending.md/#interchange-only-support)
-            for more details.
         series_only: Whether to only allow Series:
 
             - `False` (default): don't require `native_object` to be a Series
@@ -934,7 +882,6 @@ def narwhalify(
                     arg,
                     pass_through=pass_through,
                     eager_only=eager_only,
-                    eager_or_interchange_only=eager_or_interchange_only,
                     series_only=series_only,
                     allow_series=allow_series,
                 )
@@ -946,7 +893,6 @@ def narwhalify(
                     value,
                     pass_through=pass_through,
                     eager_only=eager_only,
-                    eager_or_interchange_only=eager_or_interchange_only,
                     series_only=series_only,
                     allow_series=allow_series,
                 )
diff --git a/pyproject.toml b/pyproject.toml
index c461d703..9efc72c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -191,7 +191,7 @@ omit = [
   # the latest pyspark (3.5) doesn't officially support Python 3.12 and 3.13
   'narwhals/_spark_like/*',
   # we don't run these in every environment
-  'tests/spark_like_test.py',
+  'tests/ibis_test.py',
 ]
 exclude_also = [
   "if sys.version_info() <",


Test patch:
diff --git a/tests/expr_and_series/all_horizontal_test.py b/tests/expr_and_series/all_horizontal_test.py
index 826c0fe1..706c42ba 100644
--- a/tests/expr_and_series/all_horizontal_test.py
+++ b/tests/expr_and_series/all_horizontal_test.py
@@ -57,8 +57,6 @@ def test_allh_nth(
 ) -> None:
     if "polars" in str(constructor) and POLARS_VERSION < (1, 0):
         request.applymarker(pytest.mark.xfail)
-    if ("pyspark" in str(constructor)) or "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     data = {
         "a": [False, False, True],
         "b": [False, True, True],
diff --git a/tests/expr_and_series/concat_str_test.py b/tests/expr_and_series/concat_str_test.py
index 7c9f259b..e5273aad 100644
--- a/tests/expr_and_series/concat_str_test.py
+++ b/tests/expr_and_series/concat_str_test.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import pytest
 
 import narwhals.stable.v1 as nw
+from tests.utils import POLARS_VERSION
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -27,7 +28,8 @@ def test_concat_str(
     expected: list[str],
     request: pytest.FixtureRequest,
 ) -> None:
-    if "duckdb" in str(constructor):
+    if "polars" in str(constructor) and POLARS_VERSION < (1, 0, 0):
+        # nth only available after 1.0
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor(data))
     result = (
@@ -49,16 +51,16 @@ def test_concat_str(
     assert_equal_data(result, {"full_sentence": expected})
     result = (
         df.select(
-            "a",
+            nw.col("a").alias("a_original"),
             nw.concat_str(
-                nw.col("a") * 2,
+                nw.nth(0) * 2,
                 nw.col("b"),
                 nw.col("c"),
                 separator=" ",
                 ignore_nulls=ignore_nulls,  # default behavior is False
-            ).alias("full_sentence"),
+            ),
         )
-        .sort("a")
-        .select("full_sentence")
+        .sort("a_original")
+        .select("a")
     )
-    assert_equal_data(result, {"full_sentence": expected})
+    assert_equal_data(result, {"a": expected})
diff --git a/tests/expr_and_series/convert_time_zone_test.py b/tests/expr_and_series/dt/convert_time_zone_test.py
similarity index 100%
rename from tests/expr_and_series/convert_time_zone_test.py
rename to tests/expr_and_series/dt/convert_time_zone_test.py
diff --git a/tests/expr_and_series/dt/datetime_attributes_test.py b/tests/expr_and_series/dt/datetime_attributes_test.py
index 3c8a16b7..ad5f8dc3 100644
--- a/tests/expr_and_series/dt/datetime_attributes_test.py
+++ b/tests/expr_and_series/dt/datetime_attributes_test.py
@@ -118,7 +118,6 @@ def test_to_date(request: pytest.FixtureRequest, constructor: Constructor) -> No
             "pandas_nullable_constructor",
             "cudf",
             "modin_constructor",
-            "pyspark",
         )
     ):
         request.applymarker(pytest.mark.xfail)
diff --git a/tests/expr_and_series/replace_time_zone_test.py b/tests/expr_and_series/dt/replace_time_zone_test.py
similarity index 100%
rename from tests/expr_and_series/replace_time_zone_test.py
rename to tests/expr_and_series/dt/replace_time_zone_test.py
diff --git a/tests/expr_and_series/is_finite_test.py b/tests/expr_and_series/is_finite_test.py
index 4fb0246e..8bd3bc0d 100644
--- a/tests/expr_and_series/is_finite_test.py
+++ b/tests/expr_and_series/is_finite_test.py
@@ -11,15 +11,13 @@ data = {"a": [float("nan"), float("inf"), 2.0, None]}
 
 
 @pytest.mark.filterwarnings("ignore:invalid value encountered in cast")
-def test_is_finite_expr(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if ("pyspark" in str(constructor)) or "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-    if "polars" in str(constructor) or "pyarrow_table" in str(constructor):
+def test_is_finite_expr(constructor: Constructor) -> None:
+    if any(
+        x in str(constructor) for x in ("polars", "pyarrow_table", "duckdb", "pyspark")
+    ):
         expected = {"a": [False, False, True, None]}
-    elif (
-        "pandas_constructor" in str(constructor)
-        or "dask" in str(constructor)
-        or "modin_constructor" in str(constructor)
+    elif any(
+        x in str(constructor) for x in ("pandas_constructor", "dask", "modin_constructor")
     ):
         expected = {"a": [False, False, True, False]}
     else:  # pandas_nullable_constructor, pandas_pyarrow_constructor, modin_pyarrrow_constructor
diff --git a/tests/expr_and_series/is_nan_test.py b/tests/expr_and_series/is_nan_test.py
index 0280d655..44ca575c 100644
--- a/tests/expr_and_series/is_nan_test.py
+++ b/tests/expr_and_series/is_nan_test.py
@@ -24,12 +24,10 @@ NON_NULLABLE_CONSTRUCTORS = [
 ]
 
 
-def test_nan(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if ("pyspark" in str(constructor)) or "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-    data_na = {"int": [0, 1, None]}
+def test_nan(constructor: Constructor) -> None:
+    data_na = {"int": [-1, 1, None]}
     df = nw.from_native(constructor(data_na)).with_columns(
-        float=nw.col("int").cast(nw.Float64), float_na=nw.col("int") / nw.col("int")
+        float=nw.col("int").cast(nw.Float64), float_na=nw.col("int") ** 0.5
     )
     result = df.select(
         int=nw.col("int").is_nan(),
diff --git a/tests/expr_and_series/nth_test.py b/tests/expr_and_series/nth_test.py
index d2fc79b0..442b478b 100644
--- a/tests/expr_and_series/nth_test.py
+++ b/tests/expr_and_series/nth_test.py
@@ -25,8 +25,6 @@ def test_nth(
     expected: dict[str, list[int]],
     request: pytest.FixtureRequest,
 ) -> None:
-    if ("pyspark" in str(constructor)) or "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     if "polars" in str(constructor) and POLARS_VERSION < (1, 0, 0):
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor(data))
diff --git a/tests/expr_and_series/sum_horizontal_test.py b/tests/expr_and_series/sum_horizontal_test.py
index 8b3dcf56..7404eefe 100644
--- a/tests/expr_and_series/sum_horizontal_test.py
+++ b/tests/expr_and_series/sum_horizontal_test.py
@@ -10,11 +10,7 @@ from tests.utils import assert_equal_data
 
 
 @pytest.mark.parametrize("col_expr", [nw.col("a"), "a"])
-def test_sumh(
-    constructor: Constructor, col_expr: Any, request: pytest.FixtureRequest
-) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_sumh(constructor: Constructor, col_expr: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8.0, 9.0]}
     df = nw.from_native(constructor(data))
     result = df.with_columns(horizontal_sum=nw.sum_horizontal(col_expr, nw.col("b")))
@@ -27,9 +23,7 @@ def test_sumh(
     assert_equal_data(result, expected)
 
 
-def test_sumh_nullable(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_sumh_nullable(constructor: Constructor) -> None:
     data = {"a": [1, 8, 3], "b": [4, 5, None]}
     expected = {"hsum": [5, 13, 3]}
 
@@ -38,9 +32,7 @@ def test_sumh_nullable(constructor: Constructor, request: pytest.FixtureRequest)
     assert_equal_data(result, expected)
 
 
-def test_sumh_all(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_sumh_all(constructor: Constructor) -> None:
     data = {"a": [1, 2, 3], "b": [10, 20, 30]}
     df = nw.from_native(constructor(data))
     result = df.select(nw.sum_horizontal(nw.all()))
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index 579d4013..1b691c5c 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -5,7 +5,7 @@ from contextlib import nullcontext as does_not_raise
 import pytest
 from polars.exceptions import ShapeError as PlShapeError
 
-import narwhals.stable.v1 as nw
+import narwhals as nw
 from narwhals.exceptions import ShapeError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
@@ -19,11 +19,7 @@ def test_filter(constructor: Constructor) -> None:
     assert_equal_data(result, expected)
 
 
-def test_filter_with_boolean_list(
-    constructor: Constructor, request: pytest.FixtureRequest
-) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_filter_with_boolean_list(constructor: Constructor) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8.0, 9.0]}
     df = nw.from_native(constructor(data))
     context = (
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index 68e2ed8b..d0e27660 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -27,12 +27,12 @@ def test_inner_join_two_keys(constructor: Constructor) -> None:
     df = nw_main.from_native(constructor(data))
     df_right = df
     result = df.join(
-        df_right,  # type: ignore[arg-type]
+        df_right,
         left_on=["antananarivo", "bob"],
         right_on=["antananarivo", "bob"],
         how="inner",
     )
-    result_on = df.join(df_right, on=["antananarivo", "bob"], how="inner")  # type: ignore[arg-type]
+    result_on = df.join(df_right, on=["antananarivo", "bob"], how="inner")
     result = result.sort("idx").drop("idx_right")
     result_on = result_on.sort("idx").drop("idx_right")
     expected = {
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index 586b75dc..fe797dab 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -291,12 +291,7 @@ def test_key_with_nulls(
         assert_equal_data(result, expected)
 
 
-def test_key_with_nulls_ignored(
-    constructor: Constructor, request: pytest.FixtureRequest
-) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_key_with_nulls_ignored(constructor: Constructor) -> None:
     data = {"b": [4, 5, None], "a": [1, 2, 3]}
     result = (
         nw.from_native(constructor(data))
diff --git a/tests/ibis_test.py b/tests/ibis_test.py
new file mode 100644
index 00000000..d4014f26
--- /dev/null
+++ b/tests/ibis_test.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+import polars as pl
+import pytest
+
+import narwhals as nw
+
+if TYPE_CHECKING:
+    import ibis
+
+    from tests.utils import Constructor
+
+ibis = pytest.importorskip("ibis")
+
+
+@pytest.fixture
+def ibis_constructor() -> Constructor:
+    def func(data: dict[str, Any]) -> ibis.Table:
+        df = pl.DataFrame(data)
+        return ibis.memtable(df)
+
+    return func
+
+
+def test_from_native(ibis_constructor: Constructor) -> None:
+    df = nw.from_native(ibis_constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
+    assert df.columns == ["a", "b"]
diff --git a/tests/stable_api_test.py b/tests/stable_api_test.py
index 6872dddc..cac0b098 100644
--- a/tests/stable_api_test.py
+++ b/tests/stable_api_test.py
@@ -40,16 +40,16 @@ def test_renamed_taxicab_norm(
     with pytest.raises(AttributeError):
         result = df.with_columns(b=nw.col("a")._l1_norm())  # type: ignore[attr-defined]
 
-    df = nw_v1.from_native(constructor({"a": [1, 2, 3, -4, 5]}))
+    df_v1 = nw_v1.from_native(constructor({"a": [1, 2, 3, -4, 5]}))
     # The newer `_taxicab_norm` can still work in the old API, no issue.
     # It's new, so it couldn't be backwards-incompatible.
-    result = df.with_columns(b=nw_v1.col("a")._taxicab_norm())
+    result_v1 = df_v1.with_columns(b=nw_v1.col("a")._taxicab_norm())
     expected = {"a": [1, 2, 3, -4, 5], "b": [15] * 5}
-    assert_equal_data(result, expected)
+    assert_equal_data(result_v1, expected)
 
     # The older `_l1_norm` still works in the stable api
-    result = df.with_columns(b=nw_v1.col("a")._l1_norm())
-    assert_equal_data(result, expected)
+    result_v1 = df_v1.with_columns(b=nw_v1.col("a")._l1_norm())
+    assert_equal_data(result_v1, expected)
 
 
 def test_renamed_taxicab_norm_dataframe(
@@ -104,6 +104,10 @@ def test_stable_api_docstrings() -> None:
     for item in main_namespace_api:
         if getattr(nw, item).__doc__ is None:
             continue
+        if item in ("from_native", "narwhalify"):
+            # `eager_or_interchange` param was removed from main namespace,
+            # but is still present in v1 docstring.
+            continue
         v1_doc = remove_docstring_examples(getattr(nw_v1, item).__doc__)
         nw_doc = remove_docstring_examples(getattr(nw, item).__doc__)
         assert v1_doc == nw_doc, item
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index c9928795..78d89684 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -240,7 +240,6 @@ def test_from_native_strict_false_typing() -> None:
     with pytest.deprecated_call(match="please use `pass_through` instead"):
         unstable_nw.from_native(df, strict=False)  # type: ignore[call-overload]
         unstable_nw.from_native(df, strict=False, eager_only=True)  # type: ignore[call-overload]
-        unstable_nw.from_native(df, strict=False, eager_or_interchange_only=True)  # type: ignore[call-overload]
 
 
 def test_from_native_strict_false_invalid() -> None: