Repository: narwhals-dev/narwhals
Pull request #266 (2024-06-06 18:22:02)

feat: add `narwhalify` decorator
<!--
# 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
- [x] ✨ 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.



MarcoGorelli: looks like pandas' series repr was a bit different in older versions (you can reproduce this locally by pip-installing the pandas version from the minimum-versions ci job) for string dtype

maybe only include bool and int in the example?

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 51868fa5..c1c6c55f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,6 +23,7 @@ This will automatically format and lint your code before each commit, and it wil
 ## Running tests
 
 To run tests, run `pytest`. To check coverage: `pytest --cov=narwhals`.
+To run tests on the docset-module, use `pytest narwhals --doctest-modules`.
 
 ## Building docs
 
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 863d3ad9..ca43bf04 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -14,9 +14,12 @@ Here are the top-level functions available in Narwhals.
         - len
         - maybe_align_index
         - maybe_set_index
+        - maybe_convert_dtypes
         - max
         - mean
         - min
+        - narwhalify
+        - narwhalify_method
         - sum
         - sum_horizontal
         - to_native
diff --git a/docs/basics/column.md b/docs/basics/column.md
index 56c60935..26582262 100644
--- a/docs/basics/column.md
+++ b/docs/basics/column.md
@@ -16,10 +16,9 @@ This can stay lazy, so we just use `nw.from_native` and expressions:
 ```python exec="1" source="above" session="ex1"
 import narwhals as nw
 
+@nw.narwhalify
 def my_func(df):
-    df_s = nw.from_native(df)
-    df_s = df_s.filter(nw.col('a') > 0)
-    return nw.to_native(df_s)
+    return df.filter(nw.col('a') > 0)
 ```
 
 === "pandas"
@@ -54,10 +53,9 @@ Let's write a dataframe-agnostic function which multiplies the values in column
 ```python exec="1" source="above" session="ex2"
 import narwhals as nw
 
+@nw.narwhalify
 def my_func(df):
-    df_s = nw.from_native(df)
-    df_s = df_s.with_columns(nw.col('a')*2)
-    return nw.to_native(df_s)
+    return df.with_columns(nw.col('a')*2)
 ```
 
 === "pandas"
@@ -90,10 +88,9 @@ values multiplied by 2, we could have used `Expr.alias`:
 ```python exec="1" source="above" session="ex2.1"
 import narwhals as nw
 
+@nw.narwhalify
 def my_func(df):
-    df_s = nw.from_native(df)
-    df_s = df_s.with_columns((nw.col('a')*2).alias('c'))
-    return nw.to_native(df_s)
+    return df.with_columns((nw.col('a')*2).alias('c'))
 ```
 
 === "pandas"
@@ -150,3 +147,9 @@ def my_func(df):
     df = pl.DataFrame({'a': [-1, 1, 3], 'b': [3, 5, -3]})
     print(my_func(df))
     ```
+
+Note that, this time, we couldn't use `@nw.narwhalify`, as the final step in
+our function wasn't `nw.to_native`, so we had to explicitly use `nw.from_native`
+as the first step. In general, we recommend using the decorator where possible,
+as it looks a lot cleaner, and only using `nw.from_native` / `nw.to_native` explicitly
+when you need them.
diff --git a/docs/basics/complete_example.md b/docs/basics/complete_example.md
index 6d61a581..cfbae39b 100644
--- a/docs/basics/complete_example.md
+++ b/docs/basics/complete_example.md
@@ -16,45 +16,45 @@ stored them in attributes `self.means` and `self.std_devs`.
 
 ## Transform method
 
-The general strategy will be:
-
-1. Initialise a Narwhals DataFrame or LazyFrame by passing your dataframe to `nw.from_native`.
-   
-    Note: if you need eager execution, make sure to pass `eager_only=True` to `nw.from_native`.
-
-2. Express your logic using the subset of the Polars API supported by Narwhals.
-3. If you need to return a dataframe to the user in its original library, call `nw.to_native`.
+We're going to take in a dataframe, and return a dataframe of the same type.
+Therefore, we use `@nw.narwhalify_method` (the counterpart to `@nw.narwhalify` which is
+meant to be used for methods):
 
 ```python
 import narwhals as nw
 
-class StandardScalar:
+class StandardScaler:
+    @nw.narwhalify_method
     def transform(self, df):
-        df = nw.from_native(df)
-        df = df.with_columns(
+        return df.with_columns(
             (nw.col(col) - self._means[col]) / self._std_devs[col]
             for col in df.columns
         )
-        return nw.to_native(df)
 ```
 
-Note that all the calculations here can stay lazy if the underlying library permits it.
+Note that all the calculations here can stay lazy if the underlying library permits it,
+so we don't pass in any extra keyword-arguments such as `eager_only`, we just use the
+default `eager_only=False`.
 
 ## Fit method
 
 Unlike the `transform` method, `fit` cannot stay lazy, as we need to compute concrete values
 for the means and standard deviations.
 
-To be able to get `Series` out of our `DataFrame`, we'll pass `eager_only=True` to `narwhals.from_native`.
+To be able to get `Series` out of our `DataFrame`, we'll pass `eager_only=True` to `nw.from_native`.
 This is because Polars doesn't have a concept of lazy `Series`, and so Narwhals
 doesn't either.
 
+Note how here, we're not returning a dataframe to the user - we just take a dataframe in, and
+store some internal state. Therefore, we use `nw.from_native` explicitly, as opposed to using the
+utility `@nw.narwhalify_method` decorator.
+
 ```python
 import narwhals as nw
 
-class StandardScalar:
-    def fit(self, df):
-        df = nw.from_native(df, eager_only=True)
+class StandardScaler:
+    def fit(self, df_any):
+        df = nw.from_native(df_any, eager_only=True)
         self._means = {col: df[col].mean() for col in df.columns}
         self._std_devs = {col: df[col].std() for col in df.columns}
 ```
@@ -66,18 +66,17 @@ Here is our dataframe-agnostic standard scaler:
 import narwhals as nw
 
 class StandardScaler:
-    def fit(self, df):
-        df = nw.from_native(df, eager_only=True)
+    def fit(self, df_any):
+        df = nw.from_native(df_any, eager_only=True)
         self._means = {col: df[col].mean() for col in df.columns}
         self._std_devs = {col: df[col].std() for col in df.columns}
 
+    @nw.narwhalify_method
     def transform(self, df):
-        df = nw.from_native(df)
-        df = df.with_columns(
+        return df.with_columns(
             (nw.col(col) - self._means[col]) / self._std_devs[col]
             for col in df.columns
         )
-        return nw.to_native(df)
 ```
 
 Next, let's try running it. Notice how, as `transform` doesn't use
diff --git a/docs/basics/dataframe.md b/docs/basics/dataframe.md
index 2dc041dd..a8bb1556 100644
--- a/docs/basics/dataframe.md
+++ b/docs/basics/dataframe.md
@@ -10,7 +10,10 @@ To write a dataframe-agnostic function, the steps you'll want to follow are:
 2. Express your logic using the subset of the Polars API supported by Narwhals.
 3. If you need to return a dataframe to the user in its original library, call `nw.to_native`.
 
-Let's try writing a simple example.
+Steps 1 and 3 are so common that we provide a utility `@nw.narwhalify` decorator, which allows you
+to only explicitly write step 2.
+
+Let's explore this with some simple examples.
 
 ## Example 1: descriptive statistics
 
@@ -21,14 +24,13 @@ Make a Python file with the following content:
 ```python exec="1" source="above" session="df_ex1"
 import narwhals as nw
 
-def func(df_any):
-    df = nw.from_native(df_any)
-    df = df.select(
+@nw.narwhalify
+def func(df):
+    return df.select(
         a_sum=nw.col('a').sum(),
         a_mean=nw.col('a').mean(),
         a_std=nw.col('a').std(),
     )
-    return nw.to_native(df)
 ```
 Let's try it out:
 
@@ -56,6 +58,21 @@ Let's try it out:
     print(func(df).collect())
     ```
 
+Alternatively, we could have opted for the more explicit version:
+```python
+import narwhals as nw
+
+def func(df_any):
+    df = nw.from_native(df_any)
+    df = df.select(
+        a_sum=nw.col('a').sum(),
+        a_mean=nw.col('a').mean(),
+        a_std=nw.col('a').std(),
+    )
+    return nw.to_native(df)
+```
+
+In general, we think `@nw.narwhalify` is more legible, so we'll use that wherever possible.
 
 ## Example 2: group-by and mean
 
@@ -64,10 +81,9 @@ Make a Python file with the following content:
 ```python exec="1" source="above" session="df_ex2"
 import narwhals as nw
 
-def func(df_any):
-    df = nw.from_native(df_any)
-    df = df.group_by('a').agg(nw.col('b').mean()).sort('a')
-    return nw.to_native(df)
+@nw.narwhalify
+def func(df):
+    return df.group_by('a').agg(nw.col('b').mean()).sort('a')
 ```
 Let's try it out:
 
@@ -105,10 +121,9 @@ Make a Python file with the following content:
 ```python exec="1" source="above" session="df_ex3"
 import narwhals as nw
 
-def func(df_any):
-    df = nw.from_native(df_any)
-    df = df.with_columns(a_plus_b=nw.sum_horizontal('a', 'b'))
-    return nw.to_native(df)
+@nw.narwhalify
+def func(df):
+    return df.with_columns(a_plus_b=nw.sum_horizontal('a', 'b'))
 ```
 Let's try it out:
 
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 7e4700d4..1ec14f30 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -28,8 +28,11 @@ from narwhals.functions import concat
 from narwhals.series import Series
 from narwhals.translate import from_native
 from narwhals.translate import get_native_namespace
+from narwhals.translate import narwhalify
+from narwhals.translate import narwhalify_method
 from narwhals.translate import to_native
 from narwhals.utils import maybe_align_index
+from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_set_index
 
 __version__ = "0.9.2"
@@ -40,6 +43,7 @@ __all__ = [
     "to_native",
     "from_native",
     "maybe_align_index",
+    "maybe_convert_dtypes",
     "maybe_set_index",
     "get_native_namespace",
     "all",
@@ -68,4 +72,6 @@ __all__ = [
     "Categorical",
     "String",
     "Datetime",
+    "narwhalify",
+    "narwhalify_method",
 ]
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 37c69173..ca9e1e98 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -51,7 +51,7 @@ def validate_column_comparand(index: Any, other: Any) -> Any:
         if other.len() == 1:
             # broadcast
             return other.item()
-        if other._series.index is not index and not (other._series.index == index).all():
+        if other._series.index is not index:
             return other._series.set_axis(index, axis=0)
         return other._series
     return other
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 2ff62c4c..20614cd7 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -195,17 +195,22 @@ class DataFrame(BaseFrame):
     Examples:
         Constructing a DataFrame from a dictionary:
 
+        >>> import pandas as pd
         >>> import polars as pl
         >>> import narwhals as nw
         >>> data = {"a": [1, 2], "b": [3, 4]}
         >>> df_pl = pl.DataFrame(data)
-        >>> df = nw.DataFrame(df_pl)
-        >>> df
-        ┌───────────────────────────────────────────────┐
-        | Narwhals DataFrame                            |
-        | Use `narwhals.to_native` to see native output |
-        └───────────────────────────────────────────────┘
-        >>> nw.to_native(df)
+        >>> df_pd = pd.DataFrame(data)
+
+        we define a library-agnostic function:
+
+        >>> def func(df_any):
+        ...     df = nw.from_native(df_any)
+        ...     return nw.to_native(df)
+
+        You can pass either pandas or Polars to the function `func`:
+
+        >>> func(df_pl)
         shape: (2, 2)
         ┌─────┬─────┐
         │ a   ┆ b   │
@@ -215,6 +220,12 @@ class DataFrame(BaseFrame):
         │ 1   ┆ 3   │
         │ 2   ┆ 4   │
         └─────┴─────┘
+
+        >>> func(df_pd)
+           a  b
+        0  1  3
+        1  2  4
+
     """
 
     def __init__(
@@ -779,10 +790,9 @@ class DataFrame(BaseFrame):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.rename({"foo": "apple"})
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.rename({"foo": "apple"})
 
             We can then pass either pandas or Polars to `func`:
 
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 2ce9ac82..df1ed0f4 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -259,10 +259,9 @@ class Expr:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a', 'b').any())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a', 'b').any())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -294,10 +293,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a', 'b').all())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a', 'b').all())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -329,10 +327,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(nw.col('a', 'b').mean())
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(nw.col('a', 'b').mean())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -368,10 +365,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(nw.col('a', 'b').std(ddof=0))
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(nw.col('a', 'b').std(ddof=0))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -404,10 +400,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a', 'b').sum())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a', 'b').sum())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -438,10 +433,9 @@ class Expr:
             >>> df_pl = pl.DataFrame({'a': [1, 2], 'b': [4, 3]})
 
             Let's define a dataframe-agnostic function:
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.min('a','b'))
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.min('a','b'))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -474,10 +468,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(nw.max('a', 'b'))
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(nw.max('a', 'b'))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -509,10 +502,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(nw.col('a', 'b').n_unique())
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(nw.col('a', 'b').n_unique())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -544,10 +536,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(nw.col('a', 'b').unique())
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(nw.col('a', 'b').unique())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -583,10 +574,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(nw.col('a', 'b').cum_sum())
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(nw.col('a', 'b').cum_sum())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -637,10 +627,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(a_diff=nw.col('a').diff())
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(a_diff=nw.col('a').diff())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -691,10 +680,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.select(a_shift=nw.col('a').shift(n=1))
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.select(a_shift=nw.col('a').shift(n=1))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -738,10 +726,9 @@ class Expr:
 
             Let's define dataframe-agnostic functions:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a').sort())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a').sort())
 
             >>> def func_descend(df_any):
             ...     df = nw.from_native(df_any)
@@ -812,10 +799,10 @@ class Expr:
             >>> df_pl = pl.DataFrame({'a': [1, 2, 3, 4, 5]})
 
             Let's define a dataframe-agnostic function:
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a').is_between(2,4,'right'))
-            ...     return nw.to_native(df)
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a').is_between(2,4,'right'))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -860,10 +847,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...    df = nw.from_native(df_any)
-            ...    df = df.with_columns(b = nw.col('a').is_in([1, 2]))
-            ...    return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...    return df.with_columns(b = nw.col('a').is_in([1, 2]))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -906,13 +892,13 @@ class Expr:
             >>> df_pl = pl.DataFrame({'a': [2, 3, 4, 5, 6, 7], 'b': [10, 11, 12, 13, 14, 15]})
 
             Let's define a dataframe-agnostic function:
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(
             ...             nw.col("a").filter(nw.col("a") > 4),
             ...             nw.col("b").filter(nw.col("b") < 13)
             ...             )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -966,13 +952,12 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         a_is_null = nw.col('a').is_null(),
             ...         b_is_null = nw.col('b').is_null()
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1027,10 +1012,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(nw.col('a', 'b').fill_null(0))
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(nw.col('a', 'b').fill_null(0))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1077,10 +1061,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col("a").drop_nulls())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("a").drop_nulls())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1133,10 +1116,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a').sample(fraction=1.0, with_replacement=True))
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a').sample(fraction=1.0, with_replacement=True))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1182,12 +1164,11 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         a_min_per_group = nw.col('a').min().over('b')
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars:
 
@@ -1224,10 +1205,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     duplicated = df.select(nw.all().is_duplicated())
-            ...     return nw.to_native(duplicated)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.all().is_duplicated())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1266,10 +1246,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     unique = df.select(nw.all().is_unique())
-            ...     return nw.to_native(unique)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.all().is_unique())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1313,10 +1292,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     nulls = df.select(nw.all().null_count())
-            ...     return nw.to_native(nulls)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.all().null_count())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1349,10 +1327,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     first_distinct = df.select(nw.all().is_first_distinct())
-            ...     return nw.to_native(first_distinct)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.all().is_first_distinct())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1390,10 +1367,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     last_distinct = df.select(nw.all().is_last_distinct())
-            ...     return nw.to_native(last_distinct)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.all().is_last_distinct())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1444,10 +1420,9 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     result = df.select(nw.col('a', 'b').quantile(0.5, interpolation='linear'))
-            ...     return nw.to_native(result)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a', 'b').quantile(0.5, interpolation='linear'))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1504,10 +1479,9 @@ class ExprStringNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(lyrics_head = nw.col('lyrics').str.head())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(lyrics_head = nw.col('lyrics').str.head())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1562,10 +1536,9 @@ class ExprStringNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.select(nw.col('a').str.to_datetime(format='%Y-%m-%d'))
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a').str.to_datetime(format='%Y-%m-%d'))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1616,12 +1589,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.year().alias("year")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1667,13 +1639,12 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.year().alias("year"),
             ...         nw.col("datetime").dt.month().alias("month")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1719,14 +1690,13 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.year().alias("year"),
             ...         nw.col("datetime").dt.month().alias("month"),
             ...         nw.col("datetime").dt.day().alias("day")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1772,12 +1742,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1823,13 +1792,12 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1873,14 +1841,13 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1924,15 +1891,14 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
             ...         nw.col("datetime").dt.millisecond().alias("millisecond")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1976,15 +1942,14 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
             ...         nw.col("datetime").dt.microsecond().alias("microsecond")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2028,15 +1993,14 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
             ...         nw.col("datetime").dt.nanosecond().alias("nanosecond")
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2074,10 +2038,9 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(a_ordinal_day=nw.col('a').dt.ordinal_day())
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(a_ordinal_day=nw.col('a').dt.ordinal_day())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2118,12 +2081,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...       a_total_minutes = nw.col('a').dt.total_minutes()
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2165,12 +2127,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...       a_total_seconds = nw.col('a').dt.total_seconds()
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2212,12 +2173,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...       a_total_milliseconds = nw.col('a').dt.total_milliseconds()
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2261,12 +2221,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...       a_total_microseconds = nw.col('a').dt.total_microseconds()
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2311,12 +2270,11 @@ class ExprDateTimeNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_columns(
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
             ...       a_diff_total_nanoseconds = nw.col('a').diff().dt.total_nanoseconds()
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2345,8 +2303,7 @@ def col(*names: str | Iterable[str]) -> Expr:
     Creates an expression that references one or more columns by their name(s).
 
     Arguments:
-        columns: str or list of str
-            The name(s) of the column(s) to use.
+        names: Name(s) of the columns to use in the aggregation function.
 
     Examples:
         >>> import pandas as pd
@@ -2355,14 +2312,13 @@ def col(*names: str | Iterable[str]) -> Expr:
         >>> df_pl = pl.DataFrame({"a": [1, 2], "b": [3, 4]})
         >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
 
-    We define a dataframe-agnostic function:
+        We define a dataframe-agnostic function:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(nw.col('a') * nw.col('b'))
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.col('a') * nw.col('b'))
 
-    We can then pass either pandas or polars to `func`:
+        We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
            a
@@ -2402,10 +2358,9 @@ def len() -> Expr:
 
         Let's define a dataframe-agnostic function:
 
-        >>> def func(df_any):
-        ...    df = nw.from_native(df_any)
-        ...    df = df.select(nw.len())
-        ...    return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...    return df.select(nw.len())
 
         We can then pass either pandas or Polars to `func`:
 
@@ -2452,14 +2407,13 @@ def sum(*columns: str) -> Expr:
         >>> df_pl = pl.DataFrame({"a": [1, 2]})
         >>> df_pd = pd.DataFrame({"a": [1, 2]})
 
-    We define a dataframe-agnostic function:
+        We define a dataframe-agnostic function:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(nw.sum('a'))
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.sum('a'))
 
-    We can then pass either pandas or polars to `func`:
+        We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
            a
@@ -2495,14 +2449,14 @@ def mean(*columns: str) -> Expr:
         >>> df_pl = pl.DataFrame({"a": [1, 8, 3]})
         >>> df_pd = pd.DataFrame({"a": [1, 8, 3]})
 
-    We define a dataframe agnostic function:
+        We define a dataframe agnostic function:
 
         >>> def func(df_any):
         ...     df = nw.from_native(df_any)
         ...     df = df.select(nw.mean('a'))
         ...     return nw.to_native(df)
 
-    We can then pass either pandas or Polars to `func`:
+        We can then pass either pandas or Polars to `func`:
 
         >>> func(df_pd)
              a
@@ -2540,10 +2494,9 @@ def min(*columns: str) -> Expr:
 
         Let's define a dataframe-agnostic function:
 
-        >>> def func(df_any):
-        ...    df = nw.from_native(df_any)
-        ...    df = df.select(nw.min('b'))
-        ...    return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...    return df.select(nw.min('b'))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -2582,10 +2535,9 @@ def max(*columns: str) -> Expr:
 
         Let's define a dataframe-agnostic function:
 
-        >>> def func(df_any):
-        ...    df = nw.from_native(df_any)
-        ...    df = df.select(nw.max('a'))
-        ...    return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...    return df.select(nw.max('a'))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -2610,7 +2562,7 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     Sum all values horizontally across columns
 
     Arguments:
-        columns: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
 
     Examples:
         >>> import pandas as pd
@@ -2619,14 +2571,13 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
         >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
 
-    We define a dataframe-agnostic function:
+        We define a dataframe-agnostic function:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(nw.sum_horizontal('a', 'b'))
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.sum_horizontal('a', 'b'))
 
-    We can then pass either pandas or polars to `func`:
+        We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
             a
diff --git a/narwhals/series.py b/narwhals/series.py
index 459964e6..eced4855 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -482,10 +482,9 @@ class Series:
 
             We define a library agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.is_in([3, 2, 8])
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_in([3, 2, 8])
 
             We can then pass either pandas or Polars to `func`:
 
@@ -526,10 +525,9 @@ class Series:
 
           Now define a dataframe-agnostic function with a `column` argument for the column to evaluate :
 
-          >>> def func(s_any):
-          ...   s = nw.from_native(s_any, series_only=True)
-          ...   s = s.drop_nulls()
-          ...   return nw.to_native(s)
+          >>> @nw.narwhalify(series_only=True)
+          ... def func(s):
+          ...   return s.drop_nulls()
 
           Then we can pass either Series (polars or pandas) to `func`:
 
@@ -565,10 +563,9 @@ class Series:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.cum_sum()
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.cum_sum()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -602,10 +599,9 @@ class Series:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...    s = nw.from_native(s_any, series_only=True)
-            ...    s = s.unique()
-            ...    return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...    return s.unique()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -650,10 +646,9 @@ class Series:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.diff()
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.diff()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -702,10 +697,9 @@ class Series:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.shift(1)
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.shift(1)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -757,10 +751,9 @@ class Series:
 
             We define a library agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.sample(fraction=1.0, with_replacement=True)
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.sample(fraction=1.0, with_replacement=True)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -801,10 +794,9 @@ class Series:
 
             We define a library agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.alias("bar")
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.alias("bar")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -841,15 +833,13 @@ class Series:
 
             We define library agnostic functions:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.sort()
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.sort()
 
-            >>> def func_descend(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.sort(descending=True)
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func_descend(s):
+            ...     return s.sort(descending=True)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -904,10 +894,9 @@ class Series:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.is_null()
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_null()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -948,10 +937,9 @@ class Series:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.fill_null(5)
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.fill_null(5)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -997,10 +985,9 @@ class Series:
 
             We define a library agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.is_between(2, 4, 'right')
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_between(2, 4, 'right')
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1157,10 +1144,9 @@ class Series:
 
             We define a library agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.filter(s > 10)
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.filter(s > 10)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1194,10 +1180,9 @@ class Series:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     series = nw.from_native(s_any, allow_series=True)
-            ...     duplicated = series.is_duplicated()
-            ...     return nw.to_native(duplicated)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_duplicated()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1262,10 +1247,9 @@ class Series:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     series = nw.from_native(s_any, allow_series=True)
-            ...     unique = series.is_unique()
-            ...     return nw.to_native(unique)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_unique()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1332,10 +1316,9 @@ class Series:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     series = nw.from_native(s_any, allow_series=True)
-            ...     first_distinct = series.is_first_distinct()
-            ...     return nw.to_native(first_distinct)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_first_distinct()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1373,10 +1356,9 @@ class Series:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     series = nw.from_native(s_any, allow_series=True)
-            ...     last_distinct = series.is_last_distinct()
-            ...     return nw.to_native(last_distinct)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.is_last_distinct()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1454,10 +1436,9 @@ class Series:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     series = nw.from_native(s_any, allow_series=True)
-            ...     val_count = series.value_counts(sort=True)
-            ...     return nw.to_native(val_count)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.value_counts(sort=True)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1609,10 +1590,9 @@ class SeriesStringNamespace:
 
             We define a dataframe-agnostic function:
 
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     s = s.str.head()
-            ...     return nw.to_native(s)
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.str.head()
 
             We can then pass either pandas or Polars to `func`:
 
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 9ce98932..e44bb35a 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -1,7 +1,10 @@
 from __future__ import annotations
 
+import inspect
+from functools import wraps
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Callable
 from typing import Literal
 from typing import overload
 
@@ -64,6 +67,17 @@ def from_native(
 ) -> DataFrame | Series: ...
 
 
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: bool = ...,
+    eager_only: Literal[True],
+    series_only: None = ...,
+    allow_series: None = ...,
+) -> DataFrame: ...
+
+
 @overload
 def from_native(
     native_dataframe: Any,
@@ -91,21 +105,21 @@ def from_native(
     native_dataframe: Any,
     *,
     strict: bool = ...,
-    eager_only: Literal[True],
+    eager_only: None = ...,
     series_only: None = ...,
     allow_series: None = ...,
-) -> DataFrame: ...
+) -> DataFrame | LazyFrame: ...
 
 
 @overload
 def from_native(
     native_dataframe: Any,
     *,
-    strict: bool = ...,
-    eager_only: None = ...,
-    series_only: None = ...,
-    allow_series: None = ...,
-) -> DataFrame | LazyFrame: ...
+    strict: bool,
+    eager_only: bool | None,
+    series_only: bool | None,
+    allow_series: bool | None,
+) -> DataFrame | LazyFrame | Series: ...
 
 
 def from_native(
@@ -223,6 +237,186 @@ def get_native_namespace(obj: Any) -> Any:
     return obj.__native_namespace__()
 
 
+def narwhalify(
+    func: Callable[..., Any] | None = None,
+    *,
+    strict: bool = True,
+    eager_only: bool | None = None,
+    series_only: bool | None = None,
+    allow_series: bool | None = None,
+) -> Callable[..., Any]:
+    """
+    Decorate function so it becomes dataframe-agnostic.
+
+    Instead of writing
+
+    ```python
+    import narwhals as nw
+
+    def func(df_any):
+        df = nw.from_native(df_any, strict=False)
+        df = df.group_by('a').agg(nw.col('b').sum())
+        return nw.to_native(df)
+    ```
+
+    you can just write
+
+    ```python
+    import narwhals as nw
+
+    @nw.narwhalify
+    def func(df):
+        return df.group_by('a').agg(nw.col('b').sum())
+    ```
+
+    You can also pass in extra arguments, e.g.
+
+    ```python
+    @nw.narhwalify(eager_only=True)
+    ```
+
+    that will get passed down to `nw.from_native`.
+
+    Arguments:
+        func: Function to wrap in a `from_native`-`to_native` block.
+        strict: Whether to raise if object can't be converted (default) or
+            to just leave it as-is.
+        eager_only: Whether to only allow eager objects.
+        series_only: Whether to only allow series.
+        allow_series: Whether to allow series (default is only dataframe / lazyframe).
+
+    See Also:
+        narwhalify_method: If you want to narwhalify a class method, use that instead.
+    """
+
+    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            if args:
+                df_any = args[0]
+            elif kwargs:
+                params = list(inspect.signature(func).parameters.keys())
+                first_key = params[0]
+                df_any = kwargs[first_key]
+            else:
+                raise TypeError("Expected function which takes at least one argument.")
+            df = from_native(
+                df_any,
+                strict=strict,
+                eager_only=eager_only,
+                series_only=series_only,
+                allow_series=allow_series,
+            )
+            if args:
+                result = func(df, *args[1:], **kwargs)
+            else:
+                kwargs[first_key] = df
+                result = func(**kwargs)
+            return to_native(result, strict=strict)
+
+        return wrapper
+
+    # If func is None, it means the decorator is used with arguments
+    if func is None:
+        return decorator
+    else:
+        # If func is not None, it means the decorator is used without arguments
+        return decorator(func)
+
+
+def narwhalify_method(
+    func: Callable[..., Any] | None = None,
+    *,
+    strict: bool = True,
+    eager_only: bool | None = None,
+    series_only: bool | None = None,
+    allow_series: bool | None = None,
+) -> Callable[..., Any]:
+    """
+    Decorate method so it becomes dataframe-agnostic.
+
+    Instead of writing
+
+    ```python
+    import narwhals as nw
+
+    class Foo:
+        def func(self, df_any):
+            df = nw.from_native(df_any, strict=False)
+            df = df.group_by('a').agg(nw.col('b').sum())
+            return nw.to_native(df)
+    ```
+
+    you can just write
+
+    ```python
+    import narwhals as nw
+
+    class Foo:
+        @nw.narwhalify_method
+        def func(self, df):
+            return df.group_by('a').agg(nw.col('b').sum())
+    ```
+
+    You can also pass in extra arguments, e.g.
+
+    ```python
+    @nw.narhwalify_method(eager_only=True)
+    ```
+
+    that will get passed down to `nw.from_native`.
+
+    Arguments:
+        func: Function to wrap in a `from_native`-`to_native` block.
+        strict: Whether to raise if object can't be converted (default) or
+            to just leave it as-is.
+        eager_only: Whether to only allow eager objects.
+        series_only: Whether to only allow series.
+        allow_series: Whether to allow series (default is only dataframe / lazyframe).
+    """
+
+    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(func)
+        def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
+            if args:
+                df_any = args[0]
+            elif kwargs:
+                params = list(inspect.signature(func).parameters.keys())
+                if params[0] not in ("cls", "self"):
+                    msg = (
+                        "`@nw.narwhalify_method` is meant to be called on class methods, "
+                        "where the first argument is typically `cls` or `self` - however, yours "
+                        f"is: {params[0]}."
+                    )
+                    raise TypeError(msg)
+                first_key = params[1]
+                df_any = kwargs[first_key]
+            else:
+                raise TypeError("Expected function which takes at least one argument.")
+            df = from_native(
+                df_any,
+                strict=strict,
+                eager_only=eager_only,
+                series_only=series_only,
+                allow_series=allow_series,
+            )
+            if args:
+                result = func(self, df, *args[1:], **kwargs)
+            else:
+                kwargs[first_key] = df
+                result = func(self, **kwargs)
+            return to_native(result, strict=strict)
+
+        return wrapper
+
+    # If func is None, it means the decorator is used with arguments
+    if func is None:
+        return decorator
+    else:
+        # If func is not None, it means the decorator is used without arguments
+        return decorator(func)
+
+
 __all__ = [
     "get_pandas",
     "get_polars",
@@ -230,4 +424,5 @@ __all__ = [
     "get_cudf",
     "get_native_namespace",
     "to_native",
+    "narwhalify",
 ]
diff --git a/narwhals/utils.py b/narwhals/utils.py
index a17b6cf4..77c1307b 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -214,3 +214,41 @@ def maybe_set_index(df: T, column_names: str | list[str]) -> T:
             )
         )
     return df
+
+
+def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
+    """
+    Convert columns to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
+
+    Notes:
+        For non-pandas-like inputs, this is a no-op.
+        Also, `args` and `kwargs` just get passed down to the underlying library as-is.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals as nw
+        >>> import numpy as np
+        >>> df_pd = pd.DataFrame(
+        ...     {
+        ...         "a": pd.Series([1, 2, 3], dtype=np.dtype("int32")),
+        ...         "b": pd.Series([True, False, np.nan], dtype=np.dtype("O"))
+        ...     }
+        ... )
+        >>> df = nw.from_native(df_pd)
+        >>> nw.to_native(nw.maybe_convert_dtypes(df)).dtypes  # doctest: +NORMALIZE_WHITESPACE
+        a             Int32
+        b           boolean
+        dtype: object
+    """
+    from narwhals._pandas_like.dataframe import PandasDataFrame
+    from narwhals.dataframe import DataFrame
+
+    df_any = cast(Any, df)
+    if isinstance(getattr(df_any, "_dataframe", None), PandasDataFrame):
+        return DataFrame(  # type: ignore[return-value]
+            df_any._dataframe._from_dataframe(
+                df_any._dataframe._dataframe.convert_dtypes(*args, **kwargs)
+            )
+        )
+    return df
diff --git a/tpch/notebooks/q1/execute.ipynb b/tpch/notebooks/q1/execute.ipynb
index 49cc4bdb..538f25df 100755
--- a/tpch/notebooks/q1/execute.ipynb
+++ b/tpch/notebooks/q1/execute.ipynb
@@ -47,7 +47,7 @@
     }
    ],
    "source": [
-    "!pip install -U polars pyarrow narwhals>=0.7.2 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
+    "!pip install -U polars pyarrow git+https://github.com/narwhals-dev/narwhals@cf3f27b137630529b1580c4961f14cb069779975 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
    ]
   },
   {
@@ -149,10 +149,10 @@
     "from datetime import datetime\n",
     "import narwhals as nw\n",
     "\n",
-    "def q1(df_raw: Any) -> Any:\n",
+    "@nw.narwhalify\n",
+    "def q1(df: Any) -> Any:\n",
     "    var_1 = datetime(1998, 9, 2)\n",
-    "    df = nw.from_native(df_raw)\n",
-    "    result = (\n",
+    "    return (\n",
     "        df.filter(nw.col(\"l_shipdate\") <= var_1)\n",
     "        .with_columns(\n",
     "            disc_price=nw.col(\"l_extendedprice\") * (1 - nw.col(\"l_discount\")),\n",
@@ -176,8 +176,7 @@
     "            ],\n",
     "        )\n",
     "        .sort([\"l_returnflag\", \"l_linestatus\"])\n",
-    "    )\n",
-    "    return nw.to_native(result)"
+    "    )"
    ]
   },
   {
@@ -287,7 +286,7 @@
    "source": [
     "tool = 'pandas[pyarrow]'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1_pandas_native(fn(lineitem))\n",
+    "timings = %timeit -o q1_pandas_native(lineitem=fn(lineitem))\n",
     "results[tool+'[native]'] = timings.best"
    ]
   },
@@ -350,7 +349,7 @@
    "source": [
     "tool = 'pandas'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1(fn(lineitem))\n",
+    "timings = %timeit -o q1(df=fn(lineitem))\n",
     "results[tool] = timings.best"
    ]
   },
diff --git a/tpch/notebooks/q2/execute.ipynb b/tpch/notebooks/q2/execute.ipynb
index f9a92e2f..9780126d 100755
--- a/tpch/notebooks/q2/execute.ipynb
+++ b/tpch/notebooks/q2/execute.ipynb
@@ -47,7 +47,7 @@
     }
    ],
    "source": [
-    "!pip install -U polars pyarrow narwhals>=0.7.2 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
+    "!pip install -U polars pyarrow git+https://github.com/narwhals-dev/narwhals@cf3f27b137630529b1580c4961f14cb069779975 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
    ]
   },
   {
diff --git a/tpch/notebooks/q3/execute.ipynb b/tpch/notebooks/q3/execute.ipynb
index 3c6ead59..33935da5 100755
--- a/tpch/notebooks/q3/execute.ipynb
+++ b/tpch/notebooks/q3/execute.ipynb
@@ -47,7 +47,7 @@
     }
    ],
    "source": [
-    "!pip install -U polars pyarrow narwhals>=0.7.2 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
+    "!pip install -U polars pyarrow git+https://github.com/narwhals-dev/narwhals@cf3f27b137630529b1580c4961f14cb069779975 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
    ]
   },
   {
diff --git a/tpch/notebooks/q4/execute.ipynb b/tpch/notebooks/q4/execute.ipynb
index e06538c0..ad4b8242 100755
--- a/tpch/notebooks/q4/execute.ipynb
+++ b/tpch/notebooks/q4/execute.ipynb
@@ -47,7 +47,7 @@
     }
    ],
    "source": [
-    "!pip install -U polars pyarrow narwhals>=0.7.2 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
+    "!pip install -U polars pyarrow git+https://github.com/narwhals-dev/narwhals@cf3f27b137630529b1580c4961f14cb069779975 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
    ]
   },
   {
diff --git a/tpch/notebooks/q5/execute.ipynb b/tpch/notebooks/q5/execute.ipynb
index c7529618..b0f68316 100755
--- a/tpch/notebooks/q5/execute.ipynb
+++ b/tpch/notebooks/q5/execute.ipynb
@@ -47,7 +47,7 @@
     }
    ],
    "source": [
-    "!pip install -U polars pyarrow narwhals>=0.7.2 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
+    "!pip install -U polars pyarrow git+https://github.com/narwhals-dev/narwhals@cf3f27b137630529b1580c4961f14cb069779975 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
    ]
   },
   {
diff --git a/tpch/notebooks/q6/execute.ipynb b/tpch/notebooks/q6/execute.ipynb
index 3e65496a..13082667 100755
--- a/tpch/notebooks/q6/execute.ipynb
+++ b/tpch/notebooks/q6/execute.ipynb
@@ -47,7 +47,7 @@
     }
    ],
    "source": [
-    "!pip install -U polars pyarrow narwhals>=0.7.2 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
+    "!pip install -U polars pyarrow git+https://github.com/narwhals-dev/narwhals@cf3f27b137630529b1580c4961f14cb069779975 && pip uninstall pandas -y && pip install --pre --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pandas"
    ]
   },
   {
diff --git a/tpch/notebooks/q7/execute.ipynb b/tpch/notebooks/q7/execute.ipynb
index 8b884df6..76fbf02b 100755
--- a/tpch/notebooks/q7/execute.ipynb
+++ b/tpch/notebooks/q7/execute.ipynb
@@ -57,7 +57,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "!pip install git+https://github.com/MarcoGorelli/narwhals@3fb34c59fbe41374fb05ea9ee31c65ac271efe8e"
+    "!pip install git+https://github.com/MarcoGorelli/narwhals@cf3f27b137630529b1580c4961f14cb069779975"
    ]
   },
   {
@@ -650,7 +650,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.8"
+   "version": "3.8.19"
   },
   "papermill": {
    "default_parameters": {},


Test patch:
diff --git a/tests/narwhalify_test.py b/tests/narwhalify_test.py
new file mode 100644
index 00000000..f12b8f3e
--- /dev/null
+++ b/tests/narwhalify_test.py
@@ -0,0 +1,90 @@
+from typing import Any
+
+import pandas as pd
+import pytest
+
+import narwhals as nw
+
+
+def test_narwhalify() -> None:
+    @nw.narwhalify
+    def func(df: nw.DataFrame) -> nw.DataFrame:
+        return df.with_columns(nw.all() + 1)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+    result = func(df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+
+
+def test_narwhalify_called() -> None:
+    @nw.narwhalify()
+    def func(df: nw.DataFrame, a: int = 1) -> nw.DataFrame:
+        return df.with_columns(nw.all() + a)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+    result = func(df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+    result = func(a=1, df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+
+
+def test_narwhalify_method() -> None:
+    class Foo:
+        @nw.narwhalify_method
+        def func(self, df: nw.DataFrame, a: int = 1) -> nw.DataFrame:
+            return df.with_columns(nw.all() + a)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = Foo().func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+    result = Foo().func(a=1, df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+
+
+def test_narwhalify_method_called() -> None:
+    class Foo:
+        @nw.narwhalify_method(eager_only=True)
+        def func(self, df: nw.DataFrame, a: int = 1) -> nw.DataFrame:
+            return df.with_columns(nw.all() + a)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = Foo().func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+    result = Foo().func(df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+    result = Foo().func(a=1, df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame({"a": [2, 3, 4]}))
+
+
+def test_narwhalify_method_invalid() -> None:
+    class Foo:
+        @nw.narwhalify_method(eager_only=True)
+        def func(self) -> nw.DataFrame:  # pragma: no cover
+            return self  # type: ignore[return-value]
+
+        @nw.narwhalify(eager_only=True)
+        def fun2(self, df: Any) -> nw.DataFrame:  # pragma: no cover
+            return df  # type: ignore[no-any-return]
+
+    with pytest.raises(TypeError):
+        Foo().func()
+
+    @nw.narwhalify_method(eager_only=True)
+    def func(_df: Any, a: int = 1) -> nw.DataFrame:  # pragma: no cover
+        return a  # type: ignore[return-value]
+
+    with pytest.raises(TypeError, match="is meant to be called"):
+        func(pd.DataFrame(), a=pd.DataFrame())
+
+
+def test_narwhalify_invalid() -> None:
+    @nw.narwhalify(eager_only=True)
+    def func() -> nw.DataFrame:  # pragma: no cover
+        return None  # type: ignore[return-value]
+
+    with pytest.raises(TypeError):
+        func()
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 228e7ec7..91a56792 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -60,3 +60,20 @@ def test_maybe_set_index_polars() -> None:
     df = nw.from_native(pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
     result = nw.maybe_set_index(df, "b")
     assert result is df
+
+
+def test_maybe_convert_dtypes_pandas() -> None:
+    import numpy as np
+
+    df = nw.from_native(pd.DataFrame({"a": [1, np.nan]}, dtype=np.dtype("float64")))
+    result = nw.to_native(nw.maybe_convert_dtypes(df))
+    expected = pd.DataFrame({"a": [1, pd.NA]}, dtype="Int64")
+    pd.testing.assert_frame_equal(result, expected)
+
+
+def test_maybe_convert_dtypes_polars() -> None:
+    import numpy as np
+
+    df = nw.from_native(pl.DataFrame({"a": [1.1, np.nan]}))
+    result = nw.maybe_convert_dtypes(df)
+    assert result is df