Repository: narwhals-dev/narwhals

Issue:
Feature: support `.over()` expressions.
I have this one function that doesn't work in narwhals because it uses `.over()`.

```python
def remove_bots(dataf, max_session_hours=24):
    return (dataf.filter(nw.col("session_length").max().over("player_id") < (max_session_hours * 6)))
```

It gave me this error:

```
Cell In[101], line 10, in remove_bots(dataf, max_session_hours)
      9 def remove_bots(dataf, max_session_hours=24):
---> 10     max_sess = nw.col("session_length").max().over("player_id")
     11     return (dataf.filter(max_sess < (max_session_hours * 6)))

AttributeError: 'Expr' object has no attribute 'over'
```

Is that maybe a feature to add to this project? I can imagine this one could get tricky on the pandas side because it might require a bunch of wrangling with `.groupby().transform()`, but figured I'd ask. 

Discussion:

Patch:
diff --git a/docs/api-reference/expressions.md b/docs/api-reference/expressions.md
index 4a776c6d..2a4966e4 100644
--- a/docs/api-reference/expressions.md
+++ b/docs/api-reference/expressions.md
@@ -17,6 +17,7 @@
         - mean
         - min
         - n_unique
+        - over
         - unique
         - sample
         - sort
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 882417e6..8ff9df8d 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -219,6 +219,28 @@ class PandasExpr:
             implementation=self._implementation,
         )
 
+    def over(self, keys: list[str]) -> Self:
+        def func(df: PandasDataFrame) -> list[PandasSeries]:
+            if self._output_names is None:
+                msg = (
+                    "Anonymous expressions are not supported in over.\n"
+                    "Instead of `nw.all()`, try using a named expression, such as "
+                    "`nw.col('a', 'b')`\n"
+                )
+                raise ValueError(msg)
+            tmp = df.group_by(keys).agg(self)
+            tmp = df.select(keys).join(tmp, how="left", left_on=keys, right_on=keys)
+            return [tmp[name] for name in self._output_names]
+
+        return self.__class__(
+            func,
+            depth=self._depth + 1,
+            function_name=self._function_name + "->over",
+            root_names=self._root_names,
+            output_names=self._output_names,
+            implementation=self._implementation,
+        )
+
     @property
     def str(self) -> PandasExprStringNamespace:
         return PandasExprStringNamespace(self)
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 2754b2de..2a7f52bd 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -620,6 +620,53 @@ class Expr:
             )
         )
 
+    def over(self, *keys: str | Iterable[str]) -> Expr:
+        """
+        Compute expressions over the given groups.
+
+        Arguments:
+            keys: Names of columns to compute window expression over.
+                  Must be names of columns, as opposed to expressions -
+                  so, this is a bit less flexible than Polars' `Expr.over`.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {'a': [1, 2, 3], 'b': [1, 1, 2]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> def func(df_any):
+            ...     df = nw.from_native(df_any)
+            ...     df = 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:
+
+            >>> func(df_pd)
+               a  b  a_min_per_group
+            0  1  1                1
+            1  2  1                1
+            2  3  2                3
+            >>> func(df_pl)
+            shape: (3, 3)
+            ┌─────┬─────┬─────────────────┐
+            │ a   ┆ b   ┆ a_min_per_group │
+            │ --- ┆ --- ┆ ---             │
+            │ i64 ┆ i64 ┆ i64             │
+            ╞═════╪═════╪═════════════════╡
+            │ 1   ┆ 1   ┆ 1               │
+            │ 2   ┆ 1   ┆ 1               │
+            │ 3   ┆ 2   ┆ 3               │
+            └─────┴─────┴─────────────────┘
+        """
+        return self.__class__(lambda plx: self._call(plx).over(flatten(keys)))
+
     @property
     def str(self) -> ExprStringNamespace:
         return ExprStringNamespace(self)
@@ -648,6 +695,11 @@ class ExprStringNamespace:
             in pandas, with no ability to set any other one. The ability to
             set the time unit in pandas, if the version permits, will arrive.
 
+        Arguments:
+            format: Format to parse strings with. Must be passed, as different
+                    dataframe libraries have different ways of auto-inferring
+                    formats.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
diff --git a/tests/expr/str/__init__.py b/tests/expr/str/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/expr/test_to_datetime.py b/tests/expr/str/to_datetime_test.py
similarity index 100%
rename from tests/expr/test_to_datetime.py
rename to tests/expr/str/to_datetime_test.py
diff --git a/tests/expr/test_over.py b/tests/expr/test_over.py
new file mode 100644
index 00000000..e69de29b
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index a95463d0..ac26591d 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -126,7 +126,7 @@ expr = [i for i in nw.Expr(lambda: 0).__dir__() if not i[0].isupper() and i[0] !
 series = [
     i for i in nw.Series(pl.Series()).__dir__() if not i[0].isupper() and i[0] != "_"
 ]
-if missing := set(expr).difference(series):
+if missing := set(expr).difference(series).difference({"over"}):
     print("In expr but not in series")  # noqa: T201
     print(missing)  # noqa: T201
     ret = 1


Test patch:
diff --git a/tests/expr/over_test.py b/tests/expr/over_test.py
new file mode 100644
index 00000000..daeb47a4
--- /dev/null
+++ b/tests/expr/over_test.py
@@ -0,0 +1,47 @@
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+data = {
+    "a": ["a", "a", "b", "b", "b"],
+    "b": [1, 2, 3, 5, 3],
+    "c": [5, 4, 3, 2, 1],
+}
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_over_single(constructor: Any) -> None:
+    df = nw.from_native(constructor(data))
+    result = df.with_columns(c_max=nw.col("c").max().over("a"))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "c_max": [5, 5, 3, 3, 3],
+    }
+    compare_dicts(result, expected)
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_over_multiple(constructor: Any) -> None:
+    df = nw.from_native(constructor(data))
+    result = df.with_columns(c_min=nw.col("c").min().over("a", "b"))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "c_min": [5, 4, 1, 2, 1],
+    }
+    compare_dicts(result, expected)
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame])
+def test_over_invalid(constructor: Any) -> None:
+    df = nw.from_native(constructor(data))
+    with pytest.raises(ValueError, match="Anonymous expressions"):
+        df.with_columns(c_min=nw.all().min().over("a", "b"))