Repository: narwhals-dev/narwhals (python)

Issue:
[Bug]: type-checking `from_native(spark_dataframe)` broke between v1.22 and v1.23
If we have
```
# ruff: noqa
from __future__ import annotations

import narwhals as nw
import pandas as pd
import polars as pl

from sqlframe.duckdb import DuckDBSession
from sqlframe.duckdb.dataframe import DuckDBDataFrame
import sqlframe.duckdb.functions as F
from pyspark.sql.dataframe import DataFrame as SparkDataFrame

def func(a: SparkDataFrame) -> None:
    reveal_type(nw.from_native(a).to_native())
    reveal_type(nw.from_native(a))
def func2(a: DuckDBDataFrame) -> None:
    reveal_type(nw.from_native(a).to_native())
    reveal_type(nw.from_native(a))
```

Then we get:

v1.22.0:
```python
$ pyright t.py 
/home/marcogorelli/polars-api-compat-dev/t.py
  /home/marcogorelli/polars-api-compat-dev/t.py:14:17 - information: Type of "nw.from_native(a).to_native()" is "DataFrame"
  /home/marcogorelli/polars-api-compat-dev/t.py:15:17 - information: Type of "nw.from_native(a)" is "narwhals.dataframe.DataFrame[pyspark.sql.dataframe.DataFrame] | LazyFrame[pyspark.sql.dataframe.DataFrame]"
  /home/marcogorelli/polars-api-compat-dev/t.py:17:17 - information: Type of "nw.from_native(a).to_native()" is "DuckDBDataFrame"
  /home/marcogorelli/polars-api-compat-dev/t.py:18:17 - information: Type of "nw.from_native(a)" is "DataFrame[DuckDBDataFrame] | LazyFrame[DuckDBDataFrame]"
0 errors, 0 warnings, 4 informations 
```

v1.23.0:
```python
$ pyright t.py 
/home/marcogorelli/polars-api-compat-dev/t.py
  /home/marcogorelli/polars-api-compat-dev/t.py:14:17 - information: Type of "nw.from_native(a).to_native()" is "DataFrame"
  /home/marcogorelli/polars-api-compat-dev/t.py:15:17 - information: Type of "nw.from_native(a)" is "narwhals.dataframe.DataFrame[pyspark.sql.dataframe.DataFrame]"
  /home/marcogorelli/polars-api-compat-dev/t.py:17:17 - information: Type of "nw.from_native(a).to_native()" is "DuckDBDataFrame"
  /home/marcogorelli/polars-api-compat-dev/t.py:18:17 - information: Type of "nw.from_native(a)" is "DataFrame[DuckDBDataFrame]"
0 errors, 0 warnings, 4 informations 
```

Pull request description:
fix(typing): Match lazy `@overload` in `nw.from_native(...)`
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [x] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues
- Closes #2194

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below


Patch:
diff --git a/docs/api-reference/lazyframe.md b/docs/api-reference/lazyframe.md
index 07667ab0..9ab5f128 100644
--- a/docs/api-reference/lazyframe.md
+++ b/docs/api-reference/lazyframe.md
@@ -4,7 +4,6 @@
     handler: python
     options:
       members:
-        - clone
         - collect
         - collect_schema
         - columns
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 3000e7cb..e90cc234 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -94,8 +94,6 @@ class DaskLazyFrame(CompliantLazyFrame):
         backend: Implementation | None,
         **kwargs: Any,
     ) -> CompliantDataFrame[Any]:
-        import pandas as pd
-
         result = self._native_frame.compute(**kwargs)
 
         if backend is None or backend is Implementation.PANDAS:
@@ -162,15 +160,6 @@ class DaskLazyFrame(CompliantLazyFrame):
 
     def select(self: Self, *exprs: DaskExpr) -> Self:
         new_series = evaluate_exprs(self, *exprs)
-
-        if not new_series:
-            # return empty dataframe, like Polars does
-            return self._from_native_frame(
-                dd.from_pandas(
-                    pd.DataFrame(), npartitions=self._native_frame.npartitions
-                ),
-            )
-
         df = select_columns_by_name(
             self._native_frame.assign(**dict(new_series)),
             [s[0] for s in new_series],
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 10744d6b..48627382 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -107,17 +107,10 @@ class DaskNamespace(CompliantNamespace[DaskLazyFrame, "dx.Series"]):
 
     def len(self: Self) -> DaskExpr:
         def func(df: DaskLazyFrame) -> list[dx.Series]:
-            if not df.columns:
-                return [
-                    dd.from_pandas(
-                        pd.Series([0], name="len"),
-                        npartitions=df._native_frame.npartitions,
-                    )
-                ]
+            # We don't allow dataframes with 0 columns, so `[0]` is safe.
             return [df._native_frame[df.columns[0]].size.to_series()]
 
-        # coverage bug? this is definitely hit
-        return DaskExpr(  # pragma: no cover
+        return DaskExpr(
             func,
             depth=0,
             function_name="len",
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index 4645882e..88f27509 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -151,9 +151,6 @@ class DuckDBLazyFrame(CompliantLazyFrame):
         *exprs: DuckDBExpr,
     ) -> Self:
         new_columns_map = evaluate_exprs(self, *exprs)
-        if not new_columns_map:
-            # TODO(marco): return empty relation with 0 columns?
-            return self._from_native_frame(self._native_frame.limit(0))
         return self._from_native_frame(
             self._native_frame.select(*(val.alias(col) for col, val in new_columns_map)),
         )
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index 8a5e0cb9..8bd1319d 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -29,7 +29,6 @@ if TYPE_CHECKING:
     import pyarrow as pa
     from sqlframe.base.column import Column
     from sqlframe.base.dataframe import BaseDataFrame
-    from sqlframe.base.session import _BaseSession
     from sqlframe.base.window import Window
     from typing_extensions import Self
     from typing_extensions import TypeAlias
@@ -41,7 +40,6 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
     SQLFrameDataFrame = BaseDataFrame[Any, Any, Any, Any, Any]
-    SQLFrameSession = _BaseSession[Any, Any, Any, Any, Any, Any, Any]
 
 Incomplete: TypeAlias = Any  # pragma: no cover
 """Marker for working code that fails type checking."""
@@ -90,14 +88,6 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         else:
             return import_window(self._implementation)
 
-    @property
-    def _session(self: Self) -> SQLFrameSession:
-        if TYPE_CHECKING:
-            return self._native_frame.session
-        if self._implementation is Implementation.PYSPARK:
-            return self._native_frame.sparkSession
-        return self._native_frame.session
-
     def __native_namespace__(self: Self) -> ModuleType:  # pragma: no cover
         return self._implementation.to_native_namespace()
 
@@ -240,13 +230,6 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         *exprs: SparkLikeExpr,
     ) -> Self:
         new_columns = evaluate_exprs(self, *exprs)
-
-        if not new_columns:
-            # return empty dataframe, like Polars does
-            schema = self._native_dtypes.StructType([])
-            spark_df = self._session.createDataFrame([], schema)
-            return self._from_native_frame(spark_df)
-
         new_columns_list = [col.alias(col_name) for (col_name, col) in new_columns]
         return self._from_native_frame(self._native_frame.select(*new_columns_list))
 
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 2bbeb391..294607f4 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -278,9 +278,6 @@ class BaseFrame(Generic[_FrameT]):
             )
         )
 
-    def clone(self: Self) -> Self:
-        return self._from_compliant_dataframe(self._compliant_frame.clone())
-
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         return self._from_compliant_dataframe(
             self._compliant_frame.gather_every(n=n, offset=offset)
@@ -1875,7 +1872,7 @@ class DataFrame(BaseFrame[DataFrameT]):
         Returns:
             An identical copy of the original dataframe.
         """
-        return super().clone()
+        return self._from_compliant_dataframe(self._compliant_frame.clone())
 
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         r"""Take every nth row in the DataFrame and return as a new DataFrame.
@@ -2520,6 +2517,9 @@ class LazyFrame(BaseFrame[FrameT]):
             |└───────┴──────────────┴───────┘|
             └────────────────────────────────┘
         """
+        if not exprs and not named_exprs:
+            msg = "At least one expression must be passed to LazyFrame.with_columns"
+            raise ValueError(msg)
         return super().with_columns(*exprs, **named_exprs)
 
     def select(
@@ -2561,6 +2561,9 @@ class LazyFrame(BaseFrame[FrameT]):
             |└───────┴──────────┘|
             └────────────────────┘
         """
+        if not exprs and not named_exprs:
+            msg = "At least one expression must be passed to LazyFrame.select"
+            raise ValueError(msg)
         return super().select(*exprs, **named_exprs)
 
     def rename(self: Self, mapping: dict[str, str]) -> Self:
@@ -3055,14 +3058,6 @@ class LazyFrame(BaseFrame[FrameT]):
             suffix=suffix,
         )
 
-    def clone(self: Self) -> Self:
-        r"""Create a copy of this DataFrame.
-
-        Returns:
-            An identical copy of the original LazyFrame.
-        """
-        return super().clone()
-
     def lazy(self: Self) -> Self:
         """Restrict available API methods to lazy-only ones.
 
diff --git a/narwhals/functions.py b/narwhals/functions.py
index f2512dfe..8d6b405c 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -53,6 +53,8 @@ if TYPE_CHECKING:
     from narwhals.typing import IntoExpr
     from narwhals.typing import IntoFrameT
     from narwhals.typing import IntoSeriesT
+    from narwhals.typing import NativeFrame
+    from narwhals.typing import NativeLazyFrame
     from narwhals.typing import _2DArray
 
     class ArrowStreamExportable(Protocol):
@@ -790,6 +792,7 @@ def _read_csv_impl(
 ) -> DataFrame[Any]:
     eager_backend = Implementation.from_backend(backend)
     native_namespace = eager_backend.to_native_namespace()
+    native_frame: NativeFrame
     if eager_backend in {
         Implementation.POLARS,
         Implementation.PANDAS,
@@ -851,6 +854,7 @@ def _scan_csv_impl(
     source: str, *, native_namespace: ModuleType, **kwargs: Any
 ) -> LazyFrame[Any]:
     implementation = Implementation.from_native_namespace(native_namespace)
+    native_frame: NativeFrame | NativeLazyFrame
     if implementation is Implementation.POLARS:
         native_frame = native_namespace.scan_csv(source, **kwargs)
     elif implementation in {
@@ -917,6 +921,7 @@ def _read_parquet_impl(
     source: str, *, native_namespace: ModuleType, **kwargs: Any
 ) -> DataFrame[Any]:
     implementation = Implementation.from_native_namespace(native_namespace)
+    native_frame: NativeFrame
     if implementation in {
         Implementation.POLARS,
         Implementation.PANDAS,
@@ -980,6 +985,7 @@ def _scan_parquet_impl(
     source: str, *, native_namespace: ModuleType, **kwargs: Any
 ) -> LazyFrame[Any]:
     implementation = Implementation.from_native_namespace(native_namespace)
+    native_frame: NativeFrame | NativeLazyFrame
     if implementation is Implementation.POLARS:
         native_frame = native_namespace.scan_parquet(source, **kwargs)
     elif implementation in {
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index e8e1af32..b45d4b5b 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -91,6 +91,7 @@ if TYPE_CHECKING:
     from narwhals.functions import ArrowStreamExportable
     from narwhals.typing import IntoExpr
     from narwhals.typing import IntoFrame
+    from narwhals.typing import IntoLazyFrameT
     from narwhals.typing import IntoSeries
     from narwhals.typing import _1DArray
     from narwhals.typing import _2DArray
@@ -1326,6 +1327,19 @@ def from_native(
 ) -> Series[IntoSeriesT]: ...
 
 
+@overload
+def from_native(
+    native_object: IntoLazyFrameT,
+    *,
+    strict: Literal[True] = ...,
+    eager_only: Literal[False] = ...,
+    eager_or_interchange_only: Literal[False] = ...,
+    series_only: Literal[False] = ...,
+    allow_series: None = ...,
+) -> LazyFrame[IntoLazyFrameT]: ...
+
+
+# NOTE: `pl.LazyFrame` originally matched here
 @overload
 def from_native(
     native_object: IntoFrameT,
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 4ec86bac..a482dd8c 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -46,6 +46,7 @@ if TYPE_CHECKING:
     from narwhals.typing import IntoDataFrameT
     from narwhals.typing import IntoFrame
     from narwhals.typing import IntoFrameT
+    from narwhals.typing import IntoLazyFrameT
     from narwhals.typing import IntoSeries
     from narwhals.typing import IntoSeriesT
 
@@ -194,13 +195,13 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoFrameT | IntoSeriesT,
+    native_object: IntoFrameT | IntoLazyFrameT | IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT]: ...
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoLazyFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
@@ -214,6 +215,21 @@ def from_native(
 ) -> Series[IntoSeriesT]: ...
 
 
+# NOTE: Seems like `mypy` is giving a false positive
+# Following this advice will introduce overlapping overloads?
+# > note: Flipping the order of overloads will fix this error
+@overload
+def from_native(  # type: ignore[overload-overlap]
+    native_object: IntoLazyFrameT,
+    *,
+    pass_through: Literal[False] = ...,
+    eager_only: Literal[False] = ...,
+    series_only: Literal[False] = ...,
+    allow_series: None = ...,
+) -> LazyFrame[IntoLazyFrameT]: ...
+
+
+# NOTE: `pl.LazyFrame` originally matched here
 @overload
 def from_native(
     native_object: IntoDataFrameT,
@@ -260,13 +276,13 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoFrameT,
+    native_object: IntoFrameT | IntoLazyFrameT,
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: None = ...,
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: ...
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoLazyFrameT]: ...
 
 
 # All params passed in as variables
@@ -282,14 +298,14 @@ def from_native(
 
 
 def from_native(
-    native_object: IntoFrameT | IntoSeriesT | IntoFrame | IntoSeries | T,
+    native_object: IntoLazyFrameT | IntoFrameT | IntoSeriesT | IntoFrame | IntoSeries | T,
     *,
     strict: bool | None = None,
     pass_through: bool | None = None,
     eager_only: bool = False,
     series_only: bool = False,
     allow_series: bool | None = None,
-) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
+) -> LazyFrame[IntoLazyFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
     """Convert `native_object` to Narwhals Dataframe, Lazyframe, or Series.
 
     Arguments:
diff --git a/narwhals/typing.py b/narwhals/typing.py
index 49ad6683..0209583d 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -34,6 +34,9 @@ if TYPE_CHECKING:
 
         def join(self, *args: Any, **kwargs: Any) -> Any: ...
 
+    class NativeLazyFrame(NativeFrame, Protocol):
+        def explain(self, *args: Any, **kwargs: Any) -> Any: ...
+
     class NativeSeries(Sized, Iterable[Any], Protocol):
         def filter(self, *args: Any, **kwargs: Any) -> Any: ...
 
@@ -67,6 +70,8 @@ Examples:
     ...     return df.shape
 """
 
+IntoLazyFrame: TypeAlias = "NativeLazyFrame | LazyFrame[Any]"
+
 IntoFrame: TypeAlias = Union[
     "NativeFrame", "DataFrame[Any]", "LazyFrame[Any]", "DataFrameLike"
 ]
@@ -140,6 +145,8 @@ Examples:
     ...     return df.with_columns(c=df["a"] + 1).to_native()
 """
 
+IntoLazyFrameT = TypeVar("IntoLazyFrameT", bound="IntoLazyFrame")
+
 FrameT = TypeVar("FrameT", bound="Frame")
 """TypeVar bound to Narwhals DataFrame or Narwhals LazyFrame.
 
@@ -168,6 +175,8 @@ Examples:
     ...     return df.with_columns(c=df["a"] + 1)
 """
 
+LazyFrameT = TypeVar("LazyFrameT", bound="LazyFrame[Any]")
+
 IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
 """TypeVar bound to object convertible to Narwhals Series.
 


Test patch:
diff --git a/tests/expr_and_series/len_test.py b/tests/expr_and_series/len_test.py
index 021abf15..22179471 100644
--- a/tests/expr_and_series/len_test.py
+++ b/tests/expr_and_series/len_test.py
@@ -36,7 +36,7 @@ def test_namespace_len(constructor: Constructor) -> None:
     assert_equal_data(df, expected)
     df = (
         nw.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
-        .select()
+        .filter(nw.col("a") < 0)
         .select(nw.len(), a=nw.len())
     )
     expected = {"len": [0], "a": [0]}
diff --git a/tests/frame/clone_test.py b/tests/frame/clone_test.py
index 316638c0..e95c39cc 100644
--- a/tests/frame/clone_test.py
+++ b/tests/frame/clone_test.py
@@ -3,20 +3,18 @@ from __future__ import annotations
 import pytest
 
 import narwhals.stable.v1 as nw
-from tests.utils import Constructor
+from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 
-def test_clone(request: pytest.FixtureRequest, constructor: Constructor) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-    if ("pyspark" in str(constructor)) or "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-    if "pyarrow_table" in str(constructor):
+def test_clone(
+    request: pytest.FixtureRequest, constructor_eager: ConstructorEager
+) -> None:
+    if "pyarrow_table" in str(constructor_eager):
         request.applymarker(pytest.mark.xfail)
 
     expected = {"a": [1, 2], "b": [3, 4]}
-    df = nw.from_native(constructor(expected))
+    df = nw.from_native(constructor_eager(expected), eager_only=True)
     df_clone = df.clone()
     assert df is not df_clone
     assert df._compliant_frame is not df_clone._compliant_frame
diff --git a/tests/frame/invalid_test.py b/tests/frame/invalid_test.py
index e9ba91a1..0b179ef3 100644
--- a/tests/frame/invalid_test.py
+++ b/tests/frame/invalid_test.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING
+from typing import TypeVar
 
 import pandas as pd
 import polars as pl
@@ -16,6 +17,9 @@ if TYPE_CHECKING:
     from narwhals.typing import Frame
 
 
+T = TypeVar("T")
+
+
 @pytest.mark.skipif(
     POLARS_VERSION < (1,), reason="Polars would raise unrecoverable panic."
 )
@@ -70,12 +74,18 @@ def test_memmap() -> None:
     pytest.importorskip("sklearn")
     # the headache this caused me...
     from sklearn.utils import check_X_y
-    from sklearn.utils._testing import create_memmap_backed_data
+
+    if TYPE_CHECKING:
+
+        def create_memmap_backed_data(data: T) -> T:
+            return data
+    else:
+        from sklearn.utils._testing import create_memmap_backed_data
 
     x_any = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
     y_any = create_memmap_backed_data(x_any["b"])
 
-    x_any, y_any = create_memmap_backed_data([x_any, y_any])
+    x_any, y_any = create_memmap_backed_data((x_any, y_any))
 
     x = nw.from_native(x_any)
     x = x.with_columns(y=nw.from_native(y_any, series_only=True))
diff --git a/tests/frame/select_test.py b/tests/frame/select_test.py
index c490a9f0..32ef82ae 100644
--- a/tests/frame/select_test.py
+++ b/tests/frame/select_test.py
@@ -29,11 +29,9 @@ def test_select(constructor: Constructor) -> None:
     assert_equal_data(result, expected)
 
 
-def test_empty_select(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if "duckdb" in str(constructor) or "sqlframe" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-    result = nw.from_native(constructor({"a": [1, 2, 3]})).lazy().select()
-    assert result.collect().shape == (0, 0)
+def test_empty_select(constructor_eager: ConstructorEager) -> None:
+    result = nw.from_native(constructor_eager({"a": [1, 2, 3]}), eager_only=True).select()
+    assert result.shape == (0, 0)
 
 
 def test_non_string_select() -> None:
diff --git a/tests/frame/with_columns_test.py b/tests/frame/with_columns_test.py
index 7badea6a..5756bb9b 100644
--- a/tests/frame/with_columns_test.py
+++ b/tests/frame/with_columns_test.py
@@ -7,6 +7,7 @@ import pytest
 import narwhals.stable.v1 as nw
 from tests.utils import PYARROW_VERSION
 from tests.utils import Constructor
+from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 
@@ -30,13 +31,22 @@ def test_with_columns_order(constructor: Constructor) -> None:
     assert_equal_data(result, expected)
 
 
-def test_with_columns_empty(constructor: Constructor) -> None:
+def test_with_columns_empty(constructor_eager: ConstructorEager) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8.0, 9.0]}
-    df = nw.from_native(constructor(data))
+    df = nw.from_native(constructor_eager(data))
     result = df.select().with_columns()
     assert_equal_data(result, {})
 
 
+def test_select_with_columns_empty_lazy(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)).lazy()
+    with pytest.raises(ValueError, match="At least one"):
+        df.with_columns()
+    with pytest.raises(ValueError, match="At least one"):
+        df.select()
+
+
 def test_with_columns_order_single_row(constructor: Constructor) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8.0, 9.0], "i": [0, 1, 2]}
     df = nw.from_native(constructor(data)).filter(nw.col("i") < 1).drop("i")
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index 1be46c5f..cbc9de58 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -312,3 +312,16 @@ def test_from_native_strict_native_series() -> None:
 
     with pytest.raises(TypeError, match="got.+numpy.ndarray"):
         nw.from_native(np_array, series_only=True)  # type: ignore[call-overload]
+
+
+def test_from_native_lazyframe() -> None:
+    stable_lazy = nw.from_native(lf_pl)
+    unstable_lazy = unstable_nw.from_native(lf_pl)
+    if TYPE_CHECKING:
+        from typing_extensions import assert_type
+
+        assert_type(stable_lazy, nw.LazyFrame[pl.LazyFrame])
+        assert_type(unstable_lazy, unstable_nw.LazyFrame[pl.LazyFrame])
+
+    assert isinstance(stable_lazy, nw.LazyFrame)
+    assert isinstance(unstable_lazy, unstable_nw.LazyFrame)