Repository: narwhals-dev/narwhals (python)

Issue:
[Bug]: `shift -> cum_sum -> over` results in all-null for pandas
### Describe the bug

something's broken here

### Steps or code to reproduce the bug

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

df = pd.DataFrame({'a': [1,2,3,4,5,6], 'b': ['x','x','x','y','y','y']})
print(nw.from_native(df).select(nw.col('a').shift(1).cum_sum().over('b'), 'b'))
print(nw.from_native(pl.from_pandas(df)).select(nw.col('a').shift(1).cum_sum().over('b'), 'b'))
```

### Expected results

same as polars

### Actual results

```
┌──────────────────┐
|Narwhals DataFrame|
|------------------|
|         a  b     |
|     0 NaN  x     |
|     1 NaN  x     |
|     2 NaN  x     |
|     3 NaN  y     |
|     4 NaN  y     |
|     5 NaN  y     |
└──────────────────┘
┌──────────────────┐
|Narwhals DataFrame|
|------------------|
|  shape: (6, 2)   |
|  ┌──────┬─────┐  |
|  │ a    ┆ b   │  |
|  │ ---  ┆ --- │  |
|  │ i64  ┆ str │  |
|  ╞══════╪═════╡  |
|  │ null ┆ x   │  |
|  │ 1    ┆ x   │  |
|  │ 3    ┆ x   │  |
|  │ null ┆ y   │  |
|  │ 4    ┆ y   │  |
|  │ 9    ┆ y   │  |
|  └──────┴─────┘  |
└──────────────────┘
```

### Please run narwhals.show_version() and enter the output below.

```shell
System:
    python: 3.12.6 (main, Sep  9 2024, 22:11:19) [Clang 18.1.8 ]
executable: /home/marcogorelli/polars-api-compat-dev/.venv/bin/python3
   machine: Linux-5.15.167.4-microsoft-standard-WSL2-x86_64-with-glibc2.35

Python dependencies:
     narwhals: 1.27.1
       pandas: 2.2.3
       polars: 1.10.0
         cudf: 
        modin: 0.32.0
      pyarrow: 19.0.0
        numpy: 2.2.3
```

### Relevant log output

```shell

```

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


Patch:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index d3378ea8..3b6baee0 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -409,7 +409,13 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
             self, "clip", lower_bound=lower_bound, upper_bound=upper_bound
         )
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
+        if kind is ExprKind.TRANSFORM:
+            msg = (
+                "Elementwise operations in `over` context are not supported for PyArrow."
+            )
+            raise NotImplementedError(msg)
+
         def func(df: ArrowDataFrame) -> list[ArrowSeries]:
             output_names, aliases = evaluate_output_names_and_aliases(self, df, [])
             if overlap := set(output_names).intersection(keys):
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index c45acfb2..c4aac7ca 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -48,7 +48,9 @@ class DaskExpr(CompliantExpr["dx.Series"]):
         alias_output_names: Callable[[Sequence[str]], Sequence[str]] | None,
         backend_version: tuple[int, ...],
         version: Version,
-        kwargs: dict[str, Any],
+        # Kwargs with metadata which we may need in group-by agg
+        # (e.g. `ddof` for `std` and `var`).
+        call_kwargs: dict[str, Any] | None = None,
     ) -> None:
         self._call = call
         self._depth = depth
@@ -57,7 +59,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
         self._alias_output_names = alias_output_names
         self._backend_version = backend_version
         self._version = version
-        self._kwargs = kwargs
+        self._call_kwargs = call_kwargs or {}
 
     def __call__(self: Self, df: DaskLazyFrame) -> Sequence[dx.Series]:
         return self._call(df)
@@ -82,7 +84,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             alias_output_names=self._alias_output_names,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs=self._kwargs,
+            call_kwargs=self._call_kwargs,
         )
 
     @classmethod
@@ -110,7 +112,6 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             alias_output_names=None,
             backend_version=backend_version,
             version=version,
-            kwargs={},
         )
 
     @classmethod
@@ -133,7 +134,6 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             alias_output_names=None,
             backend_version=backend_version,
             version=version,
-            kwargs={},
         )
 
     def _from_call(
@@ -141,6 +141,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
         # First argument to `call` should be `dx.Series`
         call: Callable[..., dx.Series],
         expr_name: str,
+        call_kwargs: dict[str, Any] | None = None,
         **expressifiable_args: Self | Any,
     ) -> Self:
         def func(df: DaskLazyFrame) -> list[dx.Series]:
@@ -163,7 +164,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             alias_output_names=self._alias_output_names,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={**self._kwargs, **expressifiable_args},
+            call_kwargs=call_kwargs,
         )
 
     def alias(self: Self, name: str) -> Self:
@@ -181,7 +182,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             alias_output_names=alias_output_names,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={**self._kwargs, "name": name},
+            call_kwargs=self._call_kwargs,
         )
 
     def __add__(self: Self, other: Any) -> Self:
@@ -310,12 +311,16 @@ class DaskExpr(CompliantExpr["dx.Series"]):
 
     def std(self: Self, ddof: int) -> Self:
         return self._from_call(
-            lambda _input, ddof: _input.std(ddof=ddof).to_series(), "std", ddof=ddof
+            lambda _input: _input.std(ddof=ddof).to_series(),
+            "std",
+            call_kwargs={"ddof": ddof},
         )
 
     def var(self: Self, ddof: int) -> Self:
         return self._from_call(
-            lambda _input, ddof: _input.var(ddof=ddof).to_series(), "var", ddof=ddof
+            lambda _input: _input.var(ddof=ddof).to_series(),
+            "var",
+            call_kwargs={"ddof": ddof},
         )
 
     def skew(self: Self) -> Self:
@@ -530,7 +535,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             lambda _input: _input.isna().sum().to_series(), "null_count"
         )
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
         def func(df: DaskLazyFrame) -> list[Any]:
             output_names, aliases = evaluate_output_names_and_aliases(self, df, [])
             if overlap := set(output_names).intersection(keys):
@@ -563,7 +568,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             alias_output_names=self._alias_output_names,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={**self._kwargs, "keys": keys},
+            call_kwargs=self._call_kwargs,
         )
 
     def cast(self: Self, dtype: DType | type[DType]) -> Self:
diff --git a/narwhals/_dask/expr_name.py b/narwhals/_dask/expr_name.py
index da1c3a73..40d6d5e6 100644
--- a/narwhals/_dask/expr_name.py
+++ b/narwhals/_dask/expr_name.py
@@ -64,5 +64,5 @@ class DaskExprNameNamespace:
             alias_output_names=alias_output_names,
             backend_version=self._compliant_expr._backend_version,
             version=self._compliant_expr._version,
-            kwargs=self._compliant_expr._kwargs,
+            call_kwargs=self._compliant_expr._call_kwargs,
         )
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index f9cbb2d5..39f79145 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -47,11 +47,11 @@ def n_unique() -> dd.Aggregation:
     return dd.Aggregation(name="nunique", chunk=chunk, agg=agg)
 
 
-def var(ddof: int = 1) -> _AggFn:
+def var(ddof: int) -> _AggFn:
     return partial(_DaskGroupBy.var, ddof=ddof)
 
 
-def std(ddof: int = 1) -> _AggFn:
+def std(ddof: int) -> _AggFn:
     return partial(_DaskGroupBy.std, ddof=ddof)
 
 
@@ -145,14 +145,12 @@ def agg_dask(
 
             # e.g. agg(nw.mean('a')) # noqa: ERA001
             function_name = re.sub(r"(\w+->)", "", expr._function_name)
-            kwargs: dict[str, Any] = (
-                {"ddof": expr._kwargs["ddof"]} if function_name in {"std", "var"} else {}  # type: ignore[attr-defined]
-            )
-
             agg_function = POLARS_TO_DASK_AGGREGATIONS.get(function_name, function_name)
             # deal with n_unique case in a "lazy" mode to not depend on dask globally
             agg_function = (
-                agg_function(**kwargs) if callable(agg_function) else agg_function
+                agg_function(**expr._call_kwargs)  # type: ignore[attr-defined]
+                if callable(agg_function)
+                else agg_function
             )
 
             simple_aggregations.update(
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index a8c3e417..1ef2d7e8 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -61,7 +61,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=None,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def col(self: Self, *column_names: str) -> DaskExpr:
@@ -93,7 +92,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=None,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def len(self: Self) -> DaskExpr:
@@ -116,7 +114,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=None,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def all_horizontal(self: Self, *exprs: DaskExpr) -> DaskExpr:
@@ -134,7 +131,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=combine_alias_output_names(*exprs),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"exprs": exprs},
         )
 
     def any_horizontal(self: Self, *exprs: DaskExpr) -> DaskExpr:
@@ -152,7 +148,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=combine_alias_output_names(*exprs),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"exprs": exprs},
         )
 
     def sum_horizontal(self: Self, *exprs: DaskExpr) -> DaskExpr:
@@ -170,7 +165,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=combine_alias_output_names(*exprs),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"exprs": exprs},
         )
 
     def concat(
@@ -253,7 +247,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=combine_alias_output_names(*exprs),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"exprs": exprs},
         )
 
     def min_horizontal(self: Self, *exprs: DaskExpr) -> DaskExpr:
@@ -272,7 +265,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=combine_alias_output_names(*exprs),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"exprs": exprs},
         )
 
     def max_horizontal(self: Self, *exprs: DaskExpr) -> DaskExpr:
@@ -291,7 +283,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=combine_alias_output_names(*exprs),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"exprs": exprs},
         )
 
     def when(self: Self, predicate: DaskExpr) -> DaskWhen:
@@ -342,11 +333,6 @@ class DaskNamespace(CompliantNamespace["dx.Series"]):
             alias_output_names=getattr(exprs[0], "_alias_output_names", None),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={
-                "exprs": exprs,
-                "separator": separator,
-                "ignore_nulls": ignore_nulls,
-            },
         )
 
 
@@ -401,7 +387,6 @@ class DaskWhen:
             alias_output_names=getattr(value, "_alias_output_names", None),
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={"value": value},
         )
 
 
@@ -416,7 +401,7 @@ class DaskThen(DaskExpr):
         alias_output_names: Callable[[Sequence[str]], Sequence[str]] | None,
         backend_version: tuple[int, ...],
         version: Version,
-        kwargs: dict[str, Any],
+        call_kwargs: dict[str, Any] | None = None,
     ) -> None:
         self._backend_version = backend_version
         self._version = version
@@ -425,7 +410,7 @@ class DaskThen(DaskExpr):
         self._function_name = function_name
         self._evaluate_output_names = evaluate_output_names  # pyright: ignore[reportAttributeAccessIssue]
         self._alias_output_names = alias_output_names
-        self._kwargs = kwargs
+        self._call_kwargs = call_kwargs or {}
 
     def otherwise(self: Self, value: DaskExpr | Any) -> DaskExpr:
         # type ignore because we are setting the `_call` attribute to a
diff --git a/narwhals/_dask/selectors.py b/narwhals/_dask/selectors.py
index 123da121..a0e6f49b 100644
--- a/narwhals/_dask/selectors.py
+++ b/narwhals/_dask/selectors.py
@@ -147,7 +147,6 @@ class DaskSelector(DaskExpr):
             alias_output_names=self._alias_output_names,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def __sub__(self: Self, other: DaskSelector | Any) -> DaskSelector | Any:
@@ -226,5 +225,4 @@ def selector(
         alias_output_names=None,
         backend_version=context._backend_version,
         version=context._version,
-        kwargs={},
     )
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index b725a71c..773c281f 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -58,7 +58,7 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
         implementation: Implementation,
         backend_version: tuple[int, ...],
         version: Version,
-        kwargs: dict[str, Any],
+        kwargs: dict[str, Any] | None = None,
     ) -> None:
         self._call = call
         self._depth = depth
@@ -68,7 +68,7 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
         self._implementation = implementation
         self._backend_version = backend_version
         self._version = version
-        self._kwargs = kwargs
+        self._kwargs = kwargs or {}
 
     def __call__(self: Self, df: PandasLikeDataFrame) -> Sequence[PandasLikeSeries]:
         return self._call(df)
@@ -145,7 +145,6 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
             implementation=implementation,
             backend_version=backend_version,
             version=version,
-            kwargs={},
         )
 
     @classmethod
@@ -176,7 +175,6 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
             implementation=implementation,
             backend_version=backend_version,
             version=version,
-            kwargs={},
         )
 
     def cast(self: Self, dtype: DType | type[DType]) -> Self:
@@ -421,7 +419,7 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
             kwargs={**self._kwargs, "name": name},
         )
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
         if (
             is_simple_aggregation(self)
             and (function_name := re.sub(r"(\w+->)", "", self._function_name))
@@ -471,7 +469,13 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
                     )
                 )
                 return [result_frame[name] for name in aliases]
-
+        elif kind is ExprKind.TRANSFORM:
+            msg = (
+                "Elementwise operations are only supported in `over` context "
+                "for pandas if they are elementary "
+                "(e.g. `nw.col('a').cum_sum().over('b'))`)."
+            )
+            raise NotImplementedError(msg)
         else:
 
             def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index a0c8a3ac..d930630e 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -93,7 +93,6 @@ class PandasLikeNamespace(CompliantNamespace[PandasLikeSeries]):
             implementation=self._implementation,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def _create_compliant_series(self: Self, value: Any) -> PandasLikeSeries:
@@ -139,7 +138,6 @@ class PandasLikeNamespace(CompliantNamespace[PandasLikeSeries]):
             implementation=self._implementation,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def lit(self: Self, value: Any, dtype: DType | None) -> PandasLikeExpr:
@@ -165,7 +163,6 @@ class PandasLikeNamespace(CompliantNamespace[PandasLikeSeries]):
             implementation=self._implementation,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     def len(self: Self) -> PandasLikeExpr:
@@ -187,7 +184,6 @@ class PandasLikeNamespace(CompliantNamespace[PandasLikeSeries]):
             implementation=self._implementation,
             backend_version=self._backend_version,
             version=self._version,
-            kwargs={},
         )
 
     # --- horizontal ---
diff --git a/narwhals/_pandas_like/selectors.py b/narwhals/_pandas_like/selectors.py
index 4b7a2ef2..9e4c93ec 100644
--- a/narwhals/_pandas_like/selectors.py
+++ b/narwhals/_pandas_like/selectors.py
@@ -218,5 +218,4 @@ def selector(
         implementation=context._implementation,
         backend_version=context._backend_version,
         version=context._version,
-        kwargs={},
     )
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 52afb834..d39d2cdb 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -97,6 +97,9 @@ class PolarsExpr:
             )
         return self._from_native_expr(self._native_expr.is_nan())
 
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
+        return self._from_native_expr(self._native_expr.over(keys))
+
     def rolling_var(
         self: Self,
         window_size: int,
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index 97c17606..dc4db7c6 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -454,7 +454,7 @@ class SparkLikeExpr(CompliantExpr["Column"]):
 
         return self._from_call(_n_unique, "n_unique")
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
         def func(df: SparkLikeLazyFrame) -> list[Column]:
             return [expr.over(self._Window.partitionBy(*keys)) for expr in self._call(df)]
 
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 2c323329..67b7cfdd 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1602,7 +1602,9 @@ class Expr:
             msg = "`.over()` can not be used for expressions which change length."
             raise LengthChangingExprError(msg)
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).over(flatten(keys)),
+            lambda plx: self._to_compliant_expr(plx).over(
+                flatten(keys), kind=self._metadata["kind"]
+            ),
             ExprMetadata(
                 kind=ExprKind.TRANSFORM,
                 is_order_dependent=self._metadata["is_order_dependent"],
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 8d5719c3..22634f98 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -320,12 +320,12 @@ def from_dict(
         backend: specifies which eager backend instantiate to. Only
             necessary if inputs are not Narwhals Series.
 
-                `backend` can be specified in various ways:
+            `backend` can be specified in various ways:
 
-                - As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
-                    `POLARS`, `MODIN` or `CUDF`.
-                - As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
-                - Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
+            - As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
+                `POLARS`, `MODIN` or `CUDF`.
+            - As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
+            - Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
         native_namespace: The native library to use for DataFrame creation.
 
             **Deprecated** (v1.26.0):
@@ -743,26 +743,37 @@ def get_level(
 def read_csv(
     source: str,
     *,
-    native_namespace: ModuleType,
+    backend: ModuleType | Implementation | str | None = None,
+    native_namespace: ModuleType | None = None,
     **kwargs: Any,
 ) -> DataFrame[Any]:
     """Read a CSV file into a DataFrame.
 
     Arguments:
         source: Path to a file.
+        backend: The eager backend for DataFrame creation.
+            `backend` can be specified in various ways:
+
+            - As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
+                `POLARS`, `MODIN` or `CUDF`.
+            - As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
+            - Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
         native_namespace: The native library to use for DataFrame creation.
+
+            **Deprecated** (v1.27.2):
+                Please use `backend` instead. Note that `native_namespace` is still available
+                (and won't emit a deprecation warning) if you use `narwhals.stable.v1`,
+                see [perfect backwards compatibility policy](../backcompat.md/).
         kwargs: Extra keyword arguments which are passed to the native CSV reader.
             For example, you could use
-            `nw.read_csv('file.csv', native_namespace=pd, engine='pyarrow')`.
+            `nw.read_csv('file.csv', backend='pandas', engine='pyarrow')`.
 
     Returns:
         DataFrame.
 
     Examples:
-        >>> import pandas as pd
         >>> import narwhals as nw
-        >>>
-        >>> nw.read_csv("file.csv", native_namespace=pd)  # doctest:+SKIP
+        >>> nw.read_csv("file.csv", backend="pandas")  # doctest:+SKIP
         ┌──────────────────┐
         |Narwhals DataFrame|
         |------------------|
@@ -771,21 +782,28 @@ def read_csv(
         |     1  2   5     |
         └──────────────────┘
     """
-    return _read_csv_impl(source, native_namespace=native_namespace, **kwargs)
+    backend = validate_native_namespace_and_backend(
+        backend, native_namespace, emit_deprecation_warning=True
+    )
+    if backend is None:  # pragma: no cover
+        msg = "`backend` must be specified in `read_csv`."
+        raise ValueError(msg)
+    return _read_csv_impl(source, backend=backend, **kwargs)
 
 
 def _read_csv_impl(
-    source: str, *, native_namespace: ModuleType, **kwargs: Any
+    source: str, *, backend: ModuleType | Implementation | str, **kwargs: Any
 ) -> DataFrame[Any]:
-    implementation = Implementation.from_native_namespace(native_namespace)
-    if implementation in (
+    eager_backend = Implementation.from_backend(backend)
+    native_namespace = eager_backend.to_native_namespace()
+    if eager_backend in (
         Implementation.POLARS,
         Implementation.PANDAS,
         Implementation.MODIN,
         Implementation.CUDF,
     ):
         native_frame = native_namespace.read_csv(source, **kwargs)
-    elif implementation is Implementation.PYARROW:
+    elif eager_backend is Implementation.PYARROW:
         from pyarrow import csv  # ignore-banned-import
 
         native_frame = csv.read_csv(source, **kwargs)
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 5a64fb1a..5291e125 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -2191,12 +2191,12 @@ def from_dict(
         backend: specifies which eager backend instantiate to. Only
             necessary if inputs are not Narwhals Series.
 
-                `backend` can be specified in various ways:
+            `backend` can be specified in various ways:
 
-                - As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
-                    `POLARS`, `MODIN` or `CUDF`.
-                - As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
-                - Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
+            - As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
+                `POLARS`, `MODIN` or `CUDF`.
+            - As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
+            - Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
         native_namespace: The native library to use for DataFrame creation.
 
             **Deprecated** (v1.26.0):
@@ -2241,22 +2241,43 @@ def from_numpy(
 
 
 def read_csv(
-    source: str, *, native_namespace: ModuleType, **kwargs: Any
+    source: str,
+    *,
+    backend: ModuleType | Implementation | str | None = None,
+    native_namespace: ModuleType | None = None,
+    **kwargs: Any,
 ) -> DataFrame[Any]:
     """Read a CSV file into a DataFrame.
 
     Arguments:
         source: Path to a file.
+        backend: The eager backend for DataFrame creation.
+            `backend` can be specified in various ways:
+
+            - As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
+                `POLARS`, `MODIN` or `CUDF`.
+            - As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
+            - Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
         native_namespace: The native library to use for DataFrame creation.
+
+            **Deprecated** (v1.27.2):
+                Please use `backend` instead. Note that `native_namespace` is still available
+                (and won't emit a deprecation warning) if you use `narwhals.stable.v1`,
+                see [perfect backwards compatibility policy](../backcompat.md/).
         kwargs: Extra keyword arguments which are passed to the native CSV reader.
             For example, you could use
-            `nw.read_csv('file.csv', native_namespace=pd, engine='pyarrow')`.
+            `nw.read_csv('file.csv', backend='pandas', engine='pyarrow')`.
 
     Returns:
         DataFrame.
     """
+    backend = validate_native_namespace_and_backend(
+        backend, native_namespace, emit_deprecation_warning=True
+    )
+    if backend is None:  # pragma: no cover
+        raise AssertionError
     return _stableify(  # type: ignore[no-any-return]
-        _read_csv_impl(source, native_namespace=native_namespace, **kwargs)
+        _read_csv_impl(source, backend=backend, **kwargs)
     )
 
 
diff --git a/narwhals/typing.py b/narwhals/typing.py
index ebd89e77..4923bbed 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
     from narwhals.expr import Expr
     from narwhals.series import Series
     from narwhals.utils import Implementation
+    from narwhals.utils import Version
 
     # All dataframes supported by Narwhals have a
     # `columns` property. Their similarities don't extend
@@ -79,6 +80,7 @@ CompliantSeriesT_co = TypeVar(
 class CompliantExpr(Protocol, Generic[CompliantSeriesT_co]):
     _implementation: Implementation
     _backend_version: tuple[int, ...]
+    _version: Version
     _evaluate_output_names: Callable[
         [CompliantDataFrame | CompliantLazyFrame], Sequence[str]
     ]


Test patch:
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index 600bde17..65dba7f6 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -4,6 +4,7 @@ import re
 from contextlib import nullcontext as does_not_raise
 
 import pandas as pd
+import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -259,3 +260,13 @@ def test_over_raise_len_change(constructor: Constructor) -> None:
         match=re.escape("`.over()` can not be used for expressions which change length."),
     ):
         nw.from_native(df).select(nw.col("b").drop_nulls().over("a"))
+
+
+def test_unsupported_over() -> None:
+    data = {"a": [1, 2, 3, 4, 5, 6], "b": ["x", "x", "x", "y", "y", "y"]}
+    df = pd.DataFrame(data)
+    with pytest.raises(NotImplementedError, match="elementary"):
+        nw.from_native(df).select(nw.col("a").shift(1).cum_sum().over("b"))
+    tbl = pa.table(data)  # type: ignore[arg-type]
+    with pytest.raises(NotImplementedError, match="Elementwise"):
+        nw.from_native(tbl).select(nw.col("a").shift(1).cum_sum().over("b"))
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index 3ec5ecda..869544cd 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -6,6 +6,7 @@ from typing import Mapping
 
 import pandas as pd
 import polars as pl
+import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -31,6 +32,10 @@ def test_group_by_complex() -> None:
             df.group_by("a").agg((nw.col("b") - nw.col("c").mean()).mean()).sort("a")
         )
     assert_equal_data(result_pd, expected)
+    with pytest.raises(ValueError, match="complex aggregation"):
+        nw.from_native(pa.table({"a": [1, 1, 2], "b": [4, 5, 6]})).group_by("a").agg(
+            (nw.col("b") - nw.col("c").mean()).mean()
+        )
 
     lf = nw.from_native(df_lazy).lazy()
     result_pl = lf.group_by("a").agg((nw.col("b") - nw.col("c").mean()).mean()).sort("a")
diff --git a/tests/read_scan_test.py b/tests/read_scan_test.py
index 46c6d732..1f72250b 100644
--- a/tests/read_scan_test.py
+++ b/tests/read_scan_test.py
@@ -9,37 +9,50 @@ import pytest
 
 import narwhals as nw
 import narwhals.stable.v1 as nw_v1
+from narwhals.utils import Implementation
 from tests.utils import PANDAS_VERSION
 from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 data: Mapping[str, Any] = {"a": [1, 2, 3], "b": [4.5, 6.7, 8.9], "z": ["x", "y", "w"]}
-
-
+TEST_EAGER_BACKENDS = [
+    Implementation.POLARS,
+    Implementation.PANDAS,
+    Implementation.PYARROW,
+    "polars",
+    "pandas",
+    "pyarrow",
+]
+
+
+@pytest.mark.parametrize(
+    "backend",
+    TEST_EAGER_BACKENDS,
+)
 def test_read_csv(
     tmpdir: pytest.TempdirFactory,
-    constructor_eager: ConstructorEager,
+    backend: Implementation | str,
 ) -> None:
     df_pl = pl.DataFrame(data)
     filepath = str(tmpdir / "file.csv")  # type: ignore[operator]
     df_pl.write_csv(filepath)
-    df = nw.from_native(constructor_eager(data))
-    native_namespace = nw.get_native_namespace(df)
-    result = nw.read_csv(filepath, native_namespace=native_namespace)
+    result = nw.read_csv(filepath, backend=backend)
     assert_equal_data(result, data)
     assert isinstance(result, nw.DataFrame)
 
 
+@pytest.mark.parametrize(
+    "backend",
+    TEST_EAGER_BACKENDS,
+)
 def test_read_csv_v1(
-    tmpdir: pytest.TempdirFactory, constructor_eager: ConstructorEager
+    tmpdir: pytest.TempdirFactory, backend: Implementation | str
 ) -> None:
     df_pl = pl.DataFrame(data)
     filepath = str(tmpdir / "file.csv")  # type: ignore[operator]
     df_pl.write_csv(filepath)
-    df = nw_v1.from_native(constructor_eager(data))
-    native_namespace = nw_v1.get_native_namespace(df)
-    result = nw_v1.read_csv(filepath, native_namespace=native_namespace)
+    result = nw_v1.read_csv(filepath, backend=backend)
     assert_equal_data(result, data)
     assert isinstance(result, nw_v1.DataFrame)
 
@@ -49,7 +62,7 @@ def test_read_csv_kwargs(tmpdir: pytest.TempdirFactory) -> None:
     df_pl = pl.DataFrame(data)
     filepath = str(tmpdir / "file.csv")  # type: ignore[operator]
     df_pl.write_csv(filepath)
-    result = nw.read_csv(filepath, native_namespace=pd, engine="pyarrow")
+    result = nw.read_csv(filepath, backend=pd, engine="pyarrow")
     assert_equal_data(result, data)