Repository: narwhals-dev/narwhals
Pull request #1086 (2024-09-28 11:30:49)

fix: make `narwhalify` part of stable api
<!--
# 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 # 
- Closes #

## 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/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index fd40e876..ed001e10 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -98,9 +98,6 @@ class DaskLazyFrame:
         mask = expr._call(self)[0]
         return self._from_native_frame(self._native_frame.loc[mask])
 
-    def lazy(self) -> Self:
-        return self
-
     def select(
         self: Self,
         *exprs: IntoDaskExpr,
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 24c147dd..04a48370 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -107,12 +107,6 @@ class BaseFrame(Generic[FrameT]):
     def columns(self) -> list[str]:
         return self._compliant_frame.columns  # type: ignore[no-any-return]
 
-    def lazy(self) -> LazyFrame[Any]:
-        return LazyFrame(
-            self._compliant_frame.lazy(),
-            level=self._level,
-        )
-
     def with_columns(
         self, *exprs: IntoExpr | Iterable[IntoExpr], **named_exprs: IntoExpr
     ) -> Self:
@@ -318,6 +312,16 @@ class DataFrame(BaseFrame[FrameT]):
     `narwhals.from_native`.
     """
 
+    @property
+    def _series(self) -> type[Series]:
+        from narwhals.series import Series
+
+        return Series
+
+    @property
+    def _lazyframe(self) -> type[LazyFrame[Any]]:
+        return LazyFrame
+
     def __init__(
         self,
         df: Any,
@@ -418,7 +422,7 @@ class DataFrame(BaseFrame[FrameT]):
             bar: [[6,7,8]]
             ham: [["a","b","c"]]
         """
-        return super().lazy()
+        return self._lazyframe(self._compliant_frame.lazy(), level=self._level)
 
     def to_native(self) -> FrameT:
         """
@@ -678,9 +682,7 @@ class DataFrame(BaseFrame[FrameT]):
                 2
             ]
         """
-        from narwhals.series import Series
-
-        return Series(
+        return self._series(
             self._compliant_frame.get_column(name),
             level=self._level,
         )
@@ -830,9 +832,7 @@ class DataFrame(BaseFrame[FrameT]):
                 return self._from_compliant_dataframe(self._compliant_frame[item[0]])
             return self._from_compliant_dataframe(self._compliant_frame[item])
         if isinstance(item, str) or (isinstance(item, tuple) and len(item) == 2):
-            from narwhals.series import Series
-
-            return Series(
+            return self._series(
                 self._compliant_frame[item],
                 level=self._level,
             )
@@ -898,11 +898,9 @@ class DataFrame(BaseFrame[FrameT]):
             >>> func(df_pa)
             {'A': [1, 2, 3, 4, 5], 'fruits': ['banana', 'banana', 'apple', 'apple', 'banana'], 'B': [5, 4, 3, 2, 1], 'animals': ['beetle', 'fly', 'beetle', 'beetle', 'beetle'], 'optional': [28, 300, None, 2, -30]}
         """
-        from narwhals.series import Series
-
         if as_series:
             return {
-                key: Series(
+                key: self._series(
                     value,
                     level=self._level,
                 )
@@ -2298,9 +2296,7 @@ class DataFrame(BaseFrame[FrameT]):
                 true
             ]
         """
-        from narwhals.series import Series
-
-        return Series(
+        return self._series(
             self._compliant_frame.is_duplicated(),
             level=self._level,
         )
@@ -2381,9 +2377,7 @@ class DataFrame(BaseFrame[FrameT]):
                 false
             ]
         """
-        from narwhals.series import Series
-
-        return Series(
+        return self._series(
             self._compliant_frame.is_unique(),
             level=self._level,
         )
@@ -2743,6 +2737,10 @@ class LazyFrame(BaseFrame[FrameT]):
     `narwhals.from_native`.
     """
 
+    @property
+    def _dataframe(self) -> type[DataFrame[Any]]:
+        return DataFrame
+
     def __init__(
         self,
         df: Any,
@@ -2810,7 +2808,7 @@ class LazyFrame(BaseFrame[FrameT]):
             │ c   ┆ 6   ┆ 1   │
             └─────┴─────┴─────┘
         """
-        return DataFrame(
+        return self._dataframe(
             self._compliant_frame.collect(),
             level=self._level,
         )
@@ -4233,7 +4231,7 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> func(df_pl)
             <LazyFrame ...>
         """
-        return super().lazy()  # type: ignore[return-value]
+        return self
 
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         r"""
diff --git a/narwhals/series.py b/narwhals/series.py
index bd99fc5c..5a84a9a5 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -33,6 +33,12 @@ class Series:
     `series_only=True`.
     """
 
+    @property
+    def _dataframe(self) -> type[DataFrame[Any]]:
+        from narwhals.dataframe import DataFrame
+
+        return DataFrame
+
     def __init__(
         self,
         series: Any,
@@ -440,9 +446,7 @@ class Series:
             │ 3   │
             └─────┘
         """
-        from narwhals.dataframe import DataFrame
-
-        return DataFrame(
+        return self._dataframe(
             self._compliant_series.to_frame(),
             level=self._level,
         )
@@ -2021,9 +2025,7 @@ class Series:
             │ 3   ┆ 1     │
             └─────┴───────┘
         """
-        from narwhals.dataframe import DataFrame
-
-        return DataFrame(
+        return self._dataframe(
             self._compliant_series.value_counts(
                 sort=sort, parallel=parallel, name=name, normalize=normalize
             ),
@@ -2350,9 +2352,7 @@ class Series:
             │ 0   ┆ 1   │
             └─────┴─────┘
         """
-        from narwhals.dataframe import DataFrame
-
-        return DataFrame(
+        return self._dataframe(
             self._compliant_series.to_dummies(separator=separator, drop_first=drop_first),
             level=self._level,
         )
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1/__init__.py
similarity index 94%
rename from narwhals/stable/v1.py
rename to narwhals/stable/v1/__init__.py
index d170c4eb..8c4bd877 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1/__init__.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from functools import wraps
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
@@ -44,7 +45,6 @@ from narwhals.functions import show_versions
 from narwhals.schema import Schema as NwSchema
 from narwhals.series import Series as NwSeries
 from narwhals.translate import get_native_namespace as nw_get_native_namespace
-from narwhals.translate import narwhalify as nw_narwhalify
 from narwhals.translate import to_native
 from narwhals.typing import IntoDataFrameT
 from narwhals.typing import IntoFrameT
@@ -75,6 +75,17 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     `narwhals.from_native`.
     """
 
+    # We need to override any method which don't return Self so that type
+    # annotations are correct.
+
+    @property
+    def _series(self) -> type[Series]:
+        return Series
+
+    @property
+    def _lazyframe(self) -> type[LazyFrame[Any]]:
+        return LazyFrame
+
     @overload
     def __getitem__(self, item: tuple[Sequence[int], slice]) -> Self: ...
     @overload
@@ -110,7 +121,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
 
     def __getitem__(self, item: Any) -> Any:
-        return _stableify(super().__getitem__(item))
+        return super().__getitem__(item)
 
     def lazy(self) -> LazyFrame[Any]:
         """
@@ -155,7 +166,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             bar: [[6,7,8]]
             ham: [["a","b","c"]]
         """
-        return _stableify(super().lazy())  # type: ignore[no-any-return]
+        return super().lazy()  # type: ignore[return-value]
 
     # Not sure what mypy is complaining about, probably some fancy
     # thing that I need to understand category theory for
@@ -206,9 +217,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             >>> func(df_pa)
             {'A': [1, 2, 3, 4, 5], 'fruits': ['banana', 'banana', 'apple', 'apple', 'banana'], 'B': [5, 4, 3, 2, 1], 'animals': ['beetle', 'fly', 'beetle', 'beetle', 'beetle'], 'optional': [28, 300, None, 2, -30]}
         """
-        if as_series:
-            return {key: _stableify(value) for key, value in super().to_dict().items()}
-        return super().to_dict(as_series=False)
+        return super().to_dict(as_series=as_series)  # type: ignore[return-value]
 
     def is_duplicated(self: Self) -> Series:
         r"""
@@ -256,7 +265,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
                 true
             ]
         """
-        return _stableify(super().is_duplicated())
+        return super().is_duplicated()  # type: ignore[return-value]
 
     def is_unique(self: Self) -> Series:
         r"""
@@ -304,7 +313,11 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
                 false
             ]
         """
-        return _stableify(super().is_unique())
+        return super().is_unique()  # type: ignore[return-value]
+
+    def _l1_norm(self: Self) -> Self:
+        """Private, just used to test the stable API."""
+        return self.select(all()._l1_norm())
 
 
 class LazyFrame(NwLazyFrame[IntoFrameT]):
@@ -317,6 +330,10 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
     `narwhals.from_native`.
     """
 
+    @property
+    def _dataframe(self) -> type[DataFrame[Any]]:
+        return DataFrame
+
     def collect(self) -> DataFrame[Any]:
         r"""
         Materialize this LazyFrame into a DataFrame.
@@ -353,7 +370,11 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
             │ c   ┆ 6   ┆ 1   │
             └─────┴─────┴─────┘
         """
-        return _stableify(super().collect())  # type: ignore[no-any-return]
+        return super().collect()  # type: ignore[return-value]
+
+    def _l1_norm(self: Self) -> Self:
+        """Private, just used to test the stable API."""
+        return self.select(all()._l1_norm())
 
 
 class Series(NwSeries):
@@ -367,6 +388,13 @@ class Series(NwSeries):
     `series_only=True`.
     """
 
+    # We need to override any method which don't return Self so that type
+    # annotations are correct.
+
+    @property
+    def _dataframe(self) -> type[DataFrame[Any]]:
+        return DataFrame
+
     def to_frame(self) -> DataFrame[Any]:
         """
         Convert to dataframe.
@@ -404,7 +432,7 @@ class Series(NwSeries):
             │ 3   │
             └─────┘
         """
-        return _stableify(super().to_frame())  # type: ignore[no-any-return]
+        return super().to_frame()  # type: ignore[return-value]
 
     def value_counts(
         self: Self,
@@ -458,10 +486,8 @@ class Series(NwSeries):
             │ 3   ┆ 1     │
             └─────┴───────┘
         """
-        return _stableify(  # type: ignore[no-any-return]
-            super().value_counts(
-                sort=sort, parallel=parallel, name=name, normalize=normalize
-            )
+        return super().value_counts(  # type: ignore[return-value]
+            sort=sort, parallel=parallel, name=name, normalize=normalize
         )
 
 
@@ -865,14 +891,54 @@ def narwhalify(
         allow_series: Whether to allow series (default is only dataframe / lazyframe).
     """
 
-    return nw_narwhalify(
-        func=func,
-        strict=strict,
-        eager_only=eager_only,
-        eager_or_interchange_only=eager_or_interchange_only,
-        series_only=series_only,
-        allow_series=allow_series,
-    )
+    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            args = [
+                from_native(
+                    arg,
+                    strict=strict,
+                    eager_only=eager_only,
+                    eager_or_interchange_only=eager_or_interchange_only,
+                    series_only=series_only,
+                    allow_series=allow_series,
+                )
+                for arg in args
+            ]  # type: ignore[assignment]
+
+            kwargs = {
+                name: from_native(
+                    value,
+                    strict=strict,
+                    eager_only=eager_only,
+                    eager_or_interchange_only=eager_or_interchange_only,
+                    series_only=series_only,
+                    allow_series=allow_series,
+                )
+                for name, value in kwargs.items()
+            }
+
+            backends = {
+                b()
+                for v in (*args, *kwargs.values())
+                if (b := getattr(v, "__native_namespace__", None))
+            }
+
+            if backends.__len__() > 1:
+                msg = "Found multiple backends. Make sure that all dataframe/series inputs come from the same backend."
+                raise ValueError(msg)
+
+            result = func(*args, **kwargs)
+
+            return to_native(result, strict=strict)
+
+        return wrapper
+
+    if func is None:
+        return decorator
+    else:
+        # If func is not None, it means the decorator is used without arguments
+        return decorator(func)
 
 
 def all() -> Expr:


Test patch:
diff --git a/tests/frame/lazy_test.py b/tests/frame/lazy_test.py
index 14b9d148..09ca734c 100644
--- a/tests/frame/lazy_test.py
+++ b/tests/frame/lazy_test.py
@@ -1,9 +1,13 @@
 from typing import Any
 
-import narwhals.stable.v1 as nw
+import narwhals as nw
+import narwhals.stable.v1 as nw_v1
 
 
 def test_lazy(constructor_eager: Any) -> None:
     df = nw.from_native(constructor_eager({"a": [1, 2, 3]}), eager_only=True)
     result = df.lazy()
     assert isinstance(result, nw.LazyFrame)
+    df = nw_v1.from_native(constructor_eager({"a": [1, 2, 3]}), eager_only=True)
+    result = df.lazy()
+    assert isinstance(result, nw_v1.LazyFrame)
diff --git a/tests/stable_api_test.py b/tests/stable_api_test.py
index 375af4f4..d579b818 100644
--- a/tests/stable_api_test.py
+++ b/tests/stable_api_test.py
@@ -1,3 +1,5 @@
+from typing import Any
+
 import polars as pl
 import pytest
 
@@ -36,6 +38,33 @@ def test_renamed_taxicab_norm(constructor: Constructor) -> None:
     compare_dicts(result, expected)
 
 
+def test_renamed_taxicab_norm_dataframe(constructor: Constructor) -> None:
+    # Suppose we have `DataFrame._l1_norm` in `stable.v1`, but remove it
+    # in the main namespace. Here, we check that it's still usable from
+    # the stable api.
+    def func(df_any: Any) -> Any:
+        df = nw_v1.from_native(df_any)
+        df = df._l1_norm()
+        return df.to_native()
+
+    result = nw_v1.from_native(func(constructor({"a": [1, 2, 3, -4, 5]})))
+    expected = {"a": [15]}
+    compare_dicts(result, expected)
+
+
+def test_renamed_taxicab_norm_dataframe_narwhalify(constructor: Constructor) -> None:
+    # Suppose we have `DataFrame._l1_norm` in `stable.v1`, but remove it
+    # in the main namespace. Here, we check that it's still usable from
+    # the stable api when using `narwhalify`.
+    @nw_v1.narwhalify
+    def func(df: Any) -> Any:
+        return df._l1_norm()
+
+    result = nw_v1.from_native(func(constructor({"a": [1, 2, 3, -4, 5]})))
+    expected = {"a": [15]}
+    compare_dicts(result, expected)
+
+
 def test_stable_api_completeness() -> None:
     v_1_api = nw_v1.__all__
     main_namespace_api = nw.__all__