Repository: narwhals-dev/narwhals
Pull request #2086 (2025-02-24 15:24:37)

chore: concise_docstrings (#2068)
  Partial implementation of the issue to:
  * expr_str
  * series_str


<!--
# 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
- [x] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below


skritsotalakis: made a draft to get some recommendations for improvement

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 41f89cf7..6b97c230 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -181,7 +181,7 @@ class ArrowDataFrame(CompliantDataFrame, CompliantLazyFrame):
             version=self._version,
         )
 
-    def __array__(self: Self, dtype: Any, copy: bool | None) -> _2DArray:
+    def __array__(self: Self, dtype: Any, *, copy: bool | None) -> _2DArray:
         return self._native_frame.__array__(dtype, copy=copy)
 
     @overload
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 669e535d..677a6e9f 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -14,6 +14,7 @@ from narwhals._arrow.expr_str import ArrowExprStringNamespace
 from narwhals._arrow.series import ArrowSeries
 from narwhals._expression_parsing import ExprKind
 from narwhals._expression_parsing import evaluate_output_names_and_aliases
+from narwhals._expression_parsing import is_scalar_like
 from narwhals._expression_parsing import reuse_series_implementation
 from narwhals.dependencies import get_numpy
 from narwhals.dependencies import is_numpy_array
@@ -414,10 +415,8 @@ class ArrowExpr(CompliantExpr["ArrowDataFrame", ArrowSeries]):
         )
 
     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."
-            )
+        if not is_scalar_like(kind):
+            msg = "Only aggregation or literal operations are supported in `over` context for PyArrow."
             raise NotImplementedError(msg)
 
         def func(df: ArrowDataFrame) -> list[ArrowSeries]:
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index a1810a30..877cee60 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -27,18 +27,20 @@ from narwhals._expression_parsing import combine_evaluate_output_names
 from narwhals.typing import CompliantNamespace
 from narwhals.utils import Implementation
 from narwhals.utils import import_dtypes_module
-from narwhals.utils import is_compliant_expr
 
 if TYPE_CHECKING:
     from typing import Callable
 
     from typing_extensions import Self
+    from typing_extensions import TypeAlias
 
     from narwhals._arrow.typing import Incomplete
     from narwhals._arrow.typing import IntoArrowExpr
     from narwhals.dtypes import DType
     from narwhals.utils import Version
 
+    _Scalar: TypeAlias = Any
+
 
 class ArrowNamespace(CompliantNamespace[ArrowDataFrame, ArrowSeries]):
     def _create_expr_from_callable(
@@ -385,15 +387,15 @@ class ArrowWhen:
         self: Self,
         condition: ArrowExpr,
         backend_version: tuple[int, ...],
-        then_value: Any = None,
-        otherwise_value: Any = None,
+        then_value: ArrowExpr | _Scalar = None,
+        otherwise_value: ArrowExpr | _Scalar = None,
         *,
         version: Version,
     ) -> None:
         self._backend_version = backend_version
-        self._condition = condition
-        self._then_value = then_value
-        self._otherwise_value = otherwise_value
+        self._condition: ArrowExpr = condition
+        self._then_value: ArrowExpr | _Scalar = then_value
+        self._otherwise_value: ArrowExpr | _Scalar = otherwise_value
         self._version = version
 
     def __call__(self: Self, df: ArrowDataFrame) -> Sequence[ArrowSeries]:
@@ -401,10 +403,9 @@ class ArrowWhen:
         condition = self._condition(df)[0]
         condition_native = condition._native_series
 
-        if is_compliant_expr(self._then_value):
-            value_series: ArrowSeries = self._then_value(df)[0]
+        if isinstance(self._then_value, ArrowExpr):
+            value_series = self._then_value(df)[0]
         else:
-            # `self._then_value` is a scalar
             value_series = plx._create_series_from_scalar(
                 self._then_value, reference_series=condition.alias("literal")
             )
@@ -420,10 +421,9 @@ class ArrowWhen:
                     pc.if_else(condition_native, value_series_native, otherwise_null)
                 )
             ]
-        if is_compliant_expr(self._otherwise_value):
-            otherwise_series: ArrowSeries = self._otherwise_value(df)[0]
+        if isinstance(self._otherwise_value, ArrowExpr):
+            otherwise_series = self._otherwise_value(df)[0]
         else:
-            # `self._otherwise_value` is a scalar
             otherwise_series = plx._create_series_from_scalar(
                 self._otherwise_value, reference_series=condition.alias("literal")
             )
@@ -438,7 +438,7 @@ class ArrowWhen:
             )
         ]
 
-    def then(self: Self, value: ArrowExpr | ArrowSeries | Any) -> ArrowThen:
+    def then(self: Self, value: ArrowExpr | ArrowSeries | _Scalar) -> ArrowThen:
         self._then_value = value
 
         return ArrowThen(
@@ -469,17 +469,14 @@ class ArrowThen(ArrowExpr):
     ) -> None:
         self._backend_version = backend_version
         self._version = version
-        self._call = call
+        self._call: ArrowWhen = call
         self._depth = depth
         self._function_name = function_name
         self._evaluate_output_names = evaluate_output_names
         self._alias_output_names = alias_output_names
         self._call_kwargs = call_kwargs or {}
 
-    def otherwise(self: Self, value: ArrowExpr | ArrowSeries | Any) -> ArrowExpr:
-        # type ignore because we are setting the `_call` attribute to a
-        # callable object of type `PandasWhen`, base class has the attribute as
-        # only a `Callable`
-        self._call._otherwise_value = value  # type: ignore[attr-defined]
+    def otherwise(self: Self, value: ArrowExpr | ArrowSeries | _Scalar) -> ArrowExpr:
+        self._call._otherwise_value = value
         self._function_name = "whenotherwise"
         return self
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 8335573a..32350b98 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -419,7 +419,7 @@ class ArrowSeries(CompliantSeries):
     def to_list(self: Self) -> list[Any]:
         return self._native_series.to_pylist()
 
-    def __array__(self: Self, dtype: Any = None, copy: bool | None = None) -> _1DArray:
+    def __array__(self: Self, dtype: Any = None, *, copy: bool | None = None) -> _1DArray:
         return self._native_series.__array__(dtype=dtype, copy=copy)
 
     def to_numpy(self: Self) -> _1DArray:
@@ -997,7 +997,7 @@ class ArrowSeries(CompliantSeries):
         )
 
         cum_sum_sq = (
-            padded_series.__pow__(2)
+            pow(padded_series, 2)
             .cum_sum(reverse=False)
             .fill_null(value=None, strategy="forward", limit=None)
         )
@@ -1091,7 +1091,6 @@ class ArrowSeries(CompliantSeries):
         def _hist_from_bin_count(bin_count: int):  # type: ignore[no-untyped-def] # noqa: ANN202
             d = pc.min_max(self._native_series)
             lower, upper = d["min"], d["max"]
-            pad_lowest_bin = False
             pa_float = pa.type_for_alias("float")
             if lower == upper:
                 range_ = lit(1.0)
@@ -1100,7 +1099,6 @@ class ArrowSeries(CompliantSeries):
                 lower = pc.subtract(lower, mid)
                 upper = pc.add(upper, mid)
             else:
-                pad_lowest_bin = True
                 range_ = pc.subtract(upper, lower)
                 width = pc.divide(pc.cast(range_, pa_float), lit(float(bin_count)))
 
@@ -1151,15 +1149,7 @@ class ArrowSeries(CompliantSeries):
             # extract left/right side of the intervals
             bin_left = pc.add(lower, pc.multiply(counts.column("values"), width))
             bin_right = pc.add(bin_left, width)
-            if pad_lowest_bin:
-                # pad lowest bin by 1% of range
-                lowest_padded = [
-                    pc.subtract(
-                        bin_left[0], pc.multiply(pc.cast(range_, pa_float), lit(0.001))
-                    )
-                ]
-                bin_left = chunked_array([lowest_padded, cast("Any", bin_left[1:])])
-            return counts.column("counts"), bin_left, bin_right
+            return counts.column("counts"), bin_right
 
         def _hist_from_bins(bins: Sequence[int | float]):  # type: ignore[no-untyped-def] # noqa: ANN202
             bin_indices = np.searchsorted(bins, self._native_series, side="left")
@@ -1169,20 +1159,19 @@ class ArrowSeries(CompliantSeries):
             counts[np.isin(obj_cats, obs_cats)] = obs_counts[np.isin(obs_cats, obj_cats)]
 
             bin_right = bins[1:]
-            bin_left = bins[:-1]
-            return counts, bin_left, bin_right
+            return counts, bin_right
 
         if bins is not None:
             if len(bins) < 2:
-                counts, bin_left, bin_right = [], [], []
+                counts, bin_right = [], []
             else:
-                counts, bin_left, bin_right = _hist_from_bins(bins)
+                counts, bin_right = _hist_from_bins(bins)
 
         elif bin_count is not None:
             if bin_count == 0:
-                counts, bin_left, bin_right = [], [], []
+                counts, bin_right = [], []
             else:
-                counts, bin_left, bin_right = _hist_from_bin_count(bin_count)
+                counts, bin_right = _hist_from_bin_count(bin_count)
 
         else:  # pragma: no cover
             # caller guarantees that either bins or bin_count is specified
diff --git a/narwhals/_arrow/typing.py b/narwhals/_arrow/typing.py
index 69e2596d..103dbf2d 100644
--- a/narwhals/_arrow/typing.py
+++ b/narwhals/_arrow/typing.py
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
     TieBreaker: TypeAlias = Literal["min", "max", "first", "dense"]
     NullPlacement: TypeAlias = Literal["at_start", "at_end"]
 
-    StringArray: TypeAlias = "pc.StringArray"
+    StringArray: TypeAlias = pc.StringArray
     ArrowChunkedArray: TypeAlias = pa.ChunkedArray[Any]
     ArrowArray: TypeAlias = pa.Array[Any]
     _AsPyType = TypeVar("_AsPyType")
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 935624c4..91a2aead 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -43,7 +43,7 @@ class DaskLazyFrame(CompliantLazyFrame):
         version: Version,
         validate_column_names: bool,
     ) -> None:
-        self._native_frame = native_dataframe
+        self._native_frame: dd.DataFrame = native_dataframe
         self._backend_version = backend_version
         self._implementation = Implementation.DASK
         self._version = version
@@ -138,7 +138,7 @@ class DaskLazyFrame(CompliantLazyFrame):
 
     @property
     def columns(self: Self) -> list[str]:
-        return self._native_frame.columns.tolist()  # type: ignore[no-any-return]
+        return self._native_frame.columns.tolist()
 
     def filter(self: Self, predicate: DaskExpr) -> Self:
         # `[0]` is safe as the predicate's expression only returns a single column
@@ -426,7 +426,7 @@ class DaskLazyFrame(CompliantLazyFrame):
         return (
             self.with_row_index(row_index_token)
             .filter(
-                (plx.col(row_index_token) >= offset)  # type: ignore[operator]
+                (plx.col(row_index_token) >= offset)
                 & ((plx.col(row_index_token) - offset) % n == 0)
             )
             .drop([row_index_token], strict=False)
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 538529e5..22fb068d 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -35,7 +35,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
+class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):  # pyright: ignore[reportInvalidTypeArguments] (#2044)
     _implementation: Implementation = Implementation.DASK
 
     def __init__(
@@ -255,7 +255,7 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
             lambda _input, other: _input.__ne__(other), "__ne__", other=other
         )
 
-    def __ge__(self: Self, other: DaskExpr) -> Self:
+    def __ge__(self: Self, other: DaskExpr | Any) -> Self:
         return self._from_call(
             lambda _input, other: _input.__ge__(other), "__ge__", other=other
         )
@@ -275,7 +275,7 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
             lambda _input, other: _input.__lt__(other), "__lt__", other=other
         )
 
-    def __and__(self: Self, other: DaskExpr) -> Self:
+    def __and__(self: Self, other: DaskExpr | Any) -> Self:
         return self._from_call(
             lambda _input, other: _input.__and__(other), "__and__", other=other
         )
@@ -454,7 +454,7 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
                 _input.dtype, self._version, self._implementation
             )
             if dtype.is_numeric():
-                return _input != _input  # noqa: PLR0124
+                return _input != _input  # pyright: ignore[reportReturnType] # noqa: PLR0124
             msg = f"`.is_nan` only supported for numeric dtypes and not {dtype}, did you mean `.is_null`?"
             raise InvalidOperationError(msg)
 
@@ -487,16 +487,11 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
         def func(_input: dx.Series) -> dx.Series:
             _name = _input.name
             col_token = generate_temporary_column_name(n_bytes=8, columns=[_name])
-            _input = add_row_index(
-                _input.to_frame(),
-                col_token,
-                backend_version=self._backend_version,
-                implementation=self._implementation,
+            frame = add_row_index(
+                _input.to_frame(), col_token, self._backend_version, self._implementation
             )
-            first_distinct_index = _input.groupby(_name).agg({col_token: "min"})[
-                col_token
-            ]
-            return _input[col_token].isin(first_distinct_index)
+            first_distinct_index = frame.groupby(_name).agg({col_token: "min"})[col_token]
+            return frame[col_token].isin(first_distinct_index)
 
         return self._from_call(func, "is_first_distinct")
 
@@ -504,14 +499,11 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
         def func(_input: dx.Series) -> dx.Series:
             _name = _input.name
             col_token = generate_temporary_column_name(n_bytes=8, columns=[_name])
-            _input = add_row_index(
-                _input.to_frame(),
-                col_token,
-                backend_version=self._backend_version,
-                implementation=self._implementation,
+            frame = add_row_index(
+                _input.to_frame(), col_token, self._backend_version, self._implementation
             )
-            last_distinct_index = _input.groupby(_name).agg({col_token: "max"})[col_token]
-            return _input[col_token].isin(last_distinct_index)
+            last_distinct_index = frame.groupby(_name).agg({col_token: "max"})[col_token]
+            return frame[col_token].isin(last_distinct_index)
 
         return self._from_call(func, "is_last_distinct")
 
diff --git a/narwhals/_dask/expr_dt.py b/narwhals/_dask/expr_dt.py
index c569d7dc..b7355dad 100644
--- a/narwhals/_dask/expr_dt.py
+++ b/narwhals/_dask/expr_dt.py
@@ -96,9 +96,9 @@ class DaskExprDateTimeNamespace:
                 s.dtype, self._compliant_expr._version, Implementation.DASK
             )
             if dtype.time_zone is None:  # type: ignore[attr-defined]
-                return s.dt.tz_localize("UTC").dt.tz_convert(time_zone)
+                return s.dt.tz_localize("UTC").dt.tz_convert(time_zone)  # pyright: ignore[reportAttributeAccessIssue]
             else:
-                return s.dt.tz_convert(time_zone)
+                return s.dt.tz_convert(time_zone)  # pyright: ignore[reportAttributeAccessIssue]
 
         return self._compliant_expr._from_call(func, "tz_convert", time_zone=time_zone)
 
@@ -125,7 +125,7 @@ class DaskExprDateTimeNamespace:
             else:
                 msg = "Input should be either of Date or Datetime type"
                 raise TypeError(msg)
-            return result.where(~mask_na)
+            return result.where(~mask_na)  # pyright: ignore[reportReturnType]
 
         return self._compliant_expr._from_call(func, "datetime", time_unit=time_unit)
 
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index 13b57796..6a972c6a 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -26,9 +26,8 @@ if TYPE_CHECKING:
 
     from narwhals._dask.dataframe import DaskLazyFrame
     from narwhals._dask.expr import DaskExpr
-    from narwhals.typing import CompliantExpr
 
-    PandasSeriesGroupBy: TypeAlias = "_PandasSeriesGroupBy[Any, Any]"
+    PandasSeriesGroupBy: TypeAlias = _PandasSeriesGroupBy[Any, Any]
     _AggFn: TypeAlias = Callable[..., Any]
     Aggregation: TypeAlias = "str | _AggFn"
 
@@ -73,7 +72,7 @@ class DaskLazyGroupBy:
     def __init__(
         self: Self, df: DaskLazyFrame, keys: list[str], *, drop_null_keys: bool
     ) -> None:
-        self._df = df
+        self._df: DaskLazyFrame = df
         self._keys = keys
         self._grouped = self._df._native_frame.groupby(
             list(self._keys),
@@ -93,11 +92,11 @@ class DaskLazyGroupBy:
             self._from_native_frame,
         )
 
-    def _from_native_frame(self: Self, df: DaskLazyFrame) -> DaskLazyFrame:
+    def _from_native_frame(self: Self, df: dd.DataFrame) -> DaskLazyFrame:
         from narwhals._dask.dataframe import DaskLazyFrame
 
         return DaskLazyFrame(
-            df,  # pyright: ignore[reportArgumentType]
+            df,
             backend_version=self._df._backend_version,
             version=self._df._version,
             validate_column_names=True,
@@ -107,7 +106,7 @@ class DaskLazyGroupBy:
 def agg_dask(
     df: DaskLazyFrame,
     grouped: Any,
-    exprs: Sequence[CompliantExpr[DaskLazyFrame, dx.Series]],
+    exprs: Sequence[DaskExpr],
     keys: list[str],
     from_dataframe: Callable[[Any], DaskLazyFrame],
 ) -> DaskLazyFrame:
@@ -139,7 +138,7 @@ def agg_dask(
                     expr._function_name, expr._function_name
                 )
                 simple_aggregations.update(
-                    {alias: (keys[0], function_name) for alias in aliases}
+                    dict.fromkeys(aliases, (keys[0], function_name))
                 )
                 continue
 
@@ -148,7 +147,7 @@ def agg_dask(
             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(**expr._call_kwargs)  # type: ignore[attr-defined]
+                agg_function(**expr._call_kwargs)
                 if callable(agg_function)
                 else agg_function
             )
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 4232ae21..8522dc86 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -8,7 +8,6 @@ from typing import Callable
 from typing import Iterable
 from typing import Literal
 from typing import Sequence
-from typing import cast
 
 import dask.dataframe as dd
 import pandas as pd
@@ -24,7 +23,6 @@ from narwhals._dask.utils import validate_comparand
 from narwhals._expression_parsing import combine_alias_output_names
 from narwhals._expression_parsing import combine_evaluate_output_names
 from narwhals.typing import CompliantNamespace
-from narwhals.utils import is_compliant_expr
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -38,7 +36,7 @@ if TYPE_CHECKING:
         import dask_expr as dx
 
 
-class DaskNamespace(CompliantNamespace[DaskLazyFrame, "dx.Series"]):
+class DaskNamespace(CompliantNamespace[DaskLazyFrame, "dx.Series"]):  # pyright: ignore[reportInvalidTypeArguments] (#2044)
     @property
     def selectors(self: Self) -> DaskSelectorNamespace:
         return DaskSelectorNamespace(self)
@@ -347,17 +345,16 @@ class DaskWhen:
         version: Version,
     ) -> None:
         self._backend_version = backend_version
-        self._condition = condition
-        self._then_value = then_value
-        self._otherwise_value = otherwise_value
+        self._condition: DaskExpr = condition
+        self._then_value: DaskExpr | Any = then_value
+        self._otherwise_value: DaskExpr | Any = otherwise_value
         self._version = version
 
     def __call__(self: Self, df: DaskLazyFrame) -> Sequence[dx.Series]:
         condition = self._condition(df)[0]
-        condition = cast("dx.Series", condition)
 
-        if is_compliant_expr(self._then_value):
-            then_value: dx.Series | object = self._then_value(df)[0]
+        if isinstance(self._then_value, DaskExpr):
+            then_value = self._then_value(df)[0]
         else:
             then_value = self._then_value
         (then_series,) = align_series_full_broadcast(df, then_value)
@@ -366,13 +363,13 @@ class DaskWhen:
         if self._otherwise_value is None:
             return [then_series.where(condition)]
 
-        if is_compliant_expr(self._otherwise_value):
-            otherwise_value: dx.Series | object = self._otherwise_value(df)[0]
+        if isinstance(self._otherwise_value, DaskExpr):
+            otherwise_value = self._otherwise_value(df)[0]
         else:
             otherwise_value = self._otherwise_value
         (otherwise_series,) = align_series_full_broadcast(df, otherwise_value)
         validate_comparand(condition, otherwise_series)
-        return [then_series.where(condition, otherwise_series)]
+        return [then_series.where(condition, otherwise_series)]  # pyright: ignore[reportArgumentType]
 
     def then(self: Self, value: DaskExpr | Any) -> DaskThen:
         self._then_value = value
@@ -405,7 +402,7 @@ class DaskThen(DaskExpr):
     ) -> None:
         self._backend_version = backend_version
         self._version = version
-        self._call = call
+        self._call: DaskWhen = call
         self._depth = depth
         self._function_name = function_name
         self._evaluate_output_names = evaluate_output_names
@@ -413,9 +410,6 @@ class DaskThen(DaskExpr):
         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
-        # callable object of type `DaskWhen`, base class has the attribute as
-        # only a `Callable`
-        self._call._otherwise_value = value  # type: ignore[attr-defined]
+        self._call._otherwise_value = value
         self._function_name = "whenotherwise"
         return self
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index 5c1c4c07..a98241ec 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -62,7 +62,7 @@ def align_series_full_broadcast(
     return [
         s if isinstance(s, dx.Series) else df._native_frame.assign(_tmp=s)["_tmp"]
         for s in series
-    ]
+    ]  # pyright: ignore[reportReturnType]
 
 
 def add_row_index(
@@ -155,8 +155,8 @@ def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> An
 
 
 def name_preserving_sum(s1: dx.Series, s2: dx.Series) -> dx.Series:
-    return (s1 + s2).rename(s1.name)
+    return (s1 + s2).rename(s1.name)  # pyright: ignore[reportOperatorIssue]
 
 
 def name_preserving_div(s1: dx.Series, s2: dx.Series) -> dx.Series:
-    return (s1 / s2).rename(s1.name)
+    return (s1 / s2).rename(s1.name)  # pyright: ignore[reportOperatorIssue]
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index 1aafde05..b997d364 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -295,7 +295,7 @@ class DuckDBLazyFrame(CompliantLazyFrame):
                 other._native_frame.set_alias("rhs"), condition=condition, how=how
             )
 
-        if how in ("inner", "left", "cross"):
+        if how in {"inner", "left", "cross"}:
             select = [f'lhs."{x}"' for x in self._native_frame.columns]
             for col in other._native_frame.columns:
                 if col in self._native_frame.columns and (
@@ -341,10 +341,10 @@ class DuckDBLazyFrame(CompliantLazyFrame):
         select = ["lhs.*"]
         for col in rhs.columns:
             if col in lhs.columns and (
-                right_on is None or col not in (right_on, *by_right)
+                right_on is None or col not in {right_on, *by_right}
             ):
                 select.append(f'rhs."{col}" as "{col}{suffix}"')
-            elif right_on is None or col not in (right_on, *by_right):
+            elif right_on is None or col not in {right_on, *by_right}:
                 select.append(col)
         query = f"""
             SELECT {",".join(select)}
diff --git a/narwhals/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index 5e5a0b82..7bf585ba 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -1,10 +1,12 @@
 from __future__ import annotations
 
+import operator
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Literal
 from typing import Sequence
+from typing import cast
 
 from duckdb import CaseExpression
 from duckdb import CoalesceOperator
@@ -280,7 +282,8 @@ class DuckDBExpr(CompliantExpr["DuckDBLazyFrame", "duckdb.Expression"]):  # type
         )
 
     def __invert__(self: Self) -> Self:
-        return self._from_call(lambda _input: ~_input, "__invert__")
+        invert = cast("Callable[..., duckdb.Expression]", operator.invert)
+        return self._from_call(invert, "__invert__")
 
     def alias(self: Self, name: str) -> Self:
         def alias_output_names(names: Sequence[str]) -> Sequence[str]:
diff --git a/narwhals/_duckdb/group_by.py b/narwhals/_duckdb/group_by.py
index 3ba9a5c9..c9c75de5 100644
--- a/narwhals/_duckdb/group_by.py
+++ b/narwhals/_duckdb/group_by.py
@@ -36,7 +36,7 @@ class DuckDBGroupBy:
             native_expressions = expr(df)
             exclude = (
                 self._keys
-                if expr._function_name.split("->", maxsplit=1)[0] in ("all", "selector")
+                if expr._function_name.split("->", maxsplit=1)[0] in {"all", "selector"}
                 else []
             )
             agg_columns.extend(
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index 148259d8..b567af23 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -9,6 +9,7 @@ from itertools import chain
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
+from typing import Literal
 from typing import Sequence
 from typing import TypeVar
 from typing import overload
@@ -331,8 +332,8 @@ class ExprKind(Enum):
     Commutative composition rules are:
     - LITERAL vs LITERAL -> LITERAL
     - CHANGES_LENGTH vs (LITERAL | AGGREGATION) -> CHANGES_LENGTH
-    - CHANGES_LENGTH vs (CHANGES_LENGTH | TRANSFORM) -> raise
-    - TRANSFORM vs (LITERAL | AGGREGATION) -> TRANSFORM
+    - CHANGES_LENGTH vs (CHANGES_LENGTH | TRANSFORM | WINDOW) -> raise
+    - (TRANSFORM | WINDOW) vs (LITERAL | AGGREGATION) -> TRANSFORM
     - AGGREGATION vs (LITERAL | AGGREGATION) -> AGGREGATION
     """
 
@@ -343,18 +344,49 @@ class ExprKind(Enum):
     """e.g. `nw.col('a').mean()`"""
 
     TRANSFORM = auto()
-    """length-preserving, e.g. `nw.col('a').round()`"""
+    """preserves length, e.g. `nw.col('a').round()`"""
+
+    WINDOW = auto()
+    """transform in which last node is order-dependent
+
+    examples:
+    - `nw.col('a').cum_sum()`
+    - `(nw.col('a')+1).cum_sum()`
+
+    non-examples:
+    - `nw.col('a').cum_sum()+1`
+    - `nw.col('a').cum_sum().mean()`
+    """
 
     CHANGES_LENGTH = auto()
     """e.g. `nw.col('a').drop_nulls()`"""
 
+    def preserves_length(self) -> bool:
+        return self in {ExprKind.TRANSFORM, ExprKind.WINDOW}
+
+    def is_window(self) -> bool:
+        return self is ExprKind.WINDOW
+
+    def is_changes_length(self) -> bool:
+        return self is ExprKind.CHANGES_LENGTH
+
+    def is_scalar_like(self) -> bool:
+        return is_scalar_like(self)
+
+
+def is_scalar_like(
+    kind: ExprKind,
+) -> TypeIs[Literal[ExprKind.AGGREGATION, ExprKind.LITERAL]]:
+    # Like ExprKind.is_scalar_like, but uses TypeIs for better type checking.
+    return kind in {ExprKind.AGGREGATION, ExprKind.LITERAL}
+
 
 class ExprMetadata:
-    __slots__ = ("_kind", "_order_dependent")
+    __slots__ = ("_kind", "_n_open_windows")
 
-    def __init__(self, kind: ExprKind, /, *, order_dependent: bool) -> None:
+    def __init__(self, kind: ExprKind, /, *, n_open_windows: int) -> None:
         self._kind: ExprKind = kind
-        self._order_dependent: bool = order_dependent
+        self._n_open_windows = n_open_windows
 
     def __init_subclass__(cls, /, *args: Any, **kwds: Any) -> Never:  # pragma: no cover
         msg = f"Cannot subclass {cls.__name__!r}"
@@ -364,50 +396,42 @@ class ExprMetadata:
     def kind(self) -> ExprKind:
         return self._kind
 
-    def is_order_dependent(self) -> bool:
-        return self._order_dependent
-
-    def is_transform(self) -> bool:
-        return self.kind is ExprKind.TRANSFORM
-
-    def is_aggregation_or_literal(self) -> bool:
-        return self.kind in {ExprKind.AGGREGATION, ExprKind.LITERAL}
-
-    def is_changes_length(self) -> bool:
-        return self.kind is ExprKind.CHANGES_LENGTH
+    @property
+    def n_open_windows(self) -> int:
+        return self._n_open_windows
 
     def with_kind(self, kind: ExprKind, /) -> ExprMetadata:
         """Change metadata kind, leaving all other attributes the same."""
-        return ExprMetadata(kind, order_dependent=self.is_order_dependent())
+        return ExprMetadata(kind, n_open_windows=self._n_open_windows)
 
-    def with_order_dependence(self) -> ExprMetadata:
-        """Set `order_dependent` to True, leaving all other attributes the same."""
-        return ExprMetadata(self.kind, order_dependent=True)
+    def with_extra_open_window(self) -> ExprMetadata:
+        """Increment `n_open_windows` leaving other attributes the same."""
+        return ExprMetadata(self.kind, n_open_windows=self._n_open_windows + 1)
 
-    def with_kind_and_order_dependence(self, kind: ExprKind, /) -> ExprMetadata:
-        """Change kind and set `order_dependent` to True."""
-        return ExprMetadata(kind, order_dependent=True)
+    def with_kind_and_extra_open_window(self, kind: ExprKind, /) -> ExprMetadata:
+        """Change metadata kind and increment `n_open_windows`."""
+        return ExprMetadata(kind, n_open_windows=self._n_open_windows + 1)
 
     @staticmethod
     def selector() -> ExprMetadata:
-        return ExprMetadata(ExprKind.TRANSFORM, order_dependent=False)
+        return ExprMetadata(ExprKind.TRANSFORM, n_open_windows=0)
 
 
 def combine_metadata(*args: IntoExpr | object | None, str_as_lit: bool) -> ExprMetadata:
     # Combine metadata from `args`.
 
     n_changes_length = 0
-    has_transforms = False
+    has_transforms_or_windows = False
     has_aggregations = False
     has_literals = False
-    result_is_order_dependent = False
+    result_n_open_windows = 0
 
     for arg in args:
         if isinstance(arg, str) and not str_as_lit:
-            has_transforms = True
+            has_transforms_or_windows = True
         elif is_expr(arg):
-            if arg._metadata.is_order_dependent():
-                result_is_order_dependent = True
+            if arg._metadata.n_open_windows:
+                result_n_open_windows += 1
             kind = arg._metadata.kind
             if kind is ExprKind.AGGREGATION:
                 has_aggregations = True
@@ -415,54 +439,55 @@ def combine_metadata(*args: IntoExpr | object | None, str_as_lit: bool) -> ExprM
                 has_literals = True
             elif kind is ExprKind.CHANGES_LENGTH:
                 n_changes_length += 1
-            elif kind is ExprKind.TRANSFORM:
-                has_transforms = True
+            elif kind.preserves_length():
+                has_transforms_or_windows = True
             else:  # pragma: no cover
                 msg = "unreachable code"
                 raise AssertionError(msg)
     if (
         has_literals
         and not has_aggregations
-        and not has_transforms
+        and not has_transforms_or_windows
         and not n_changes_length
     ):
         result_kind = ExprKind.LITERAL
     elif n_changes_length > 1:
         msg = "Length-changing expressions can only be used in isolation, or followed by an aggregation"
         raise LengthChangingExprError(msg)
-    elif n_changes_length and has_transforms:
+    elif n_changes_length and has_transforms_or_windows:
         msg = "Cannot combine length-changing expressions with length-preserving ones or aggregations"
         raise ShapeError(msg)
     elif n_changes_length:
         result_kind = ExprKind.CHANGES_LENGTH
-    elif has_transforms:
+    elif has_transforms_or_windows:
         result_kind = ExprKind.TRANSFORM
     else:
         result_kind = ExprKind.AGGREGATION
 
-    return ExprMetadata(result_kind, order_dependent=result_is_order_dependent)
+    return ExprMetadata(result_kind, n_open_windows=result_n_open_windows)
 
 
-def check_expressions_transform(*args: IntoExpr, function_name: str) -> None:
+def check_expressions_preserve_length(*args: IntoExpr, function_name: str) -> None:
     # Raise if any argument in `args` isn't length-preserving.
     # For Series input, we don't raise (yet), we let such checks happen later,
     # as this function works lazily and so can't evaluate lengths.
     from narwhals.series import Series
 
     if not all(
-        (is_expr(x) and x._metadata.is_transform()) or isinstance(x, (str, Series))
+        (is_expr(x) and x._metadata.kind.preserves_length())
+        or isinstance(x, (str, Series))
         for x in args
     ):
         msg = f"Expressions which aggregate or change length cannot be passed to '{function_name}'."
         raise ShapeError(msg)
 
 
-def all_exprs_are_aggs_or_literals(*args: IntoExpr, **kwargs: IntoExpr) -> bool:
+def all_exprs_are_scalar_like(*args: IntoExpr, **kwargs: IntoExpr) -> bool:
     # Raise if any argument in `args` isn't an aggregation or literal.
     # For Series input, we don't raise (yet), we let such checks happen later,
     # as this function works lazily and so can't evaluate lengths.
     exprs = chain(args, kwargs.values())
-    return all(is_expr(x) and x._metadata.is_aggregation_or_literal() for x in exprs)
+    return all(is_expr(x) and x._metadata.kind.is_scalar_like() for x in exprs)
 
 
 def infer_kind(obj: IntoExpr | _1DArray | object, *, str_as_lit: bool) -> ExprKind:
@@ -489,7 +514,7 @@ def apply_n_ary_operation(
     )
     kinds = [infer_kind(comparand, str_as_lit=str_as_lit) for comparand in comparands]
 
-    broadcast = any(kind is ExprKind.TRANSFORM for kind in kinds)
+    broadcast = any(kind.preserves_length() for kind in kinds)
     compliant_exprs = (
         compliant_expr.broadcast(kind)
         if broadcast
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 88684787..d716d20d 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -157,7 +157,7 @@ class PandasLikeDataFrame(CompliantDataFrame, CompliantLazyFrame):
             version=self._version,
         )
 
-    def __array__(self: Self, dtype: Any = None, copy: bool | None = None) -> _2DArray:
+    def __array__(self: Self, dtype: Any = None, *, copy: bool | None = None) -> _2DArray:
         return self.to_numpy(dtype=dtype, copy=copy)
 
     @overload
@@ -825,7 +825,7 @@ class PandasLikeDataFrame(CompliantDataFrame, CompliantLazyFrame):
             }
         return self._native_frame.to_dict(orient="list")
 
-    def to_numpy(self: Self, dtype: Any = None, copy: bool | None = None) -> _2DArray:
+    def to_numpy(self: Self, dtype: Any = None, *, copy: bool | None = None) -> _2DArray:
         native_dtypes = self._native_frame.dtypes
 
         if copy is None:
@@ -979,7 +979,7 @@ class PandasLikeDataFrame(CompliantDataFrame, CompliantLazyFrame):
         elif aggregate_function == "len":
             result = (
                 frame.groupby([*on, *index])
-                .agg({v: "size" for v in values})
+                .agg(dict.fromkeys(values, "size"))
                 .reset_index()
                 .pivot(columns=on, index=index, values=values)
             )
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index ce98c382..d2269b91 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -9,6 +9,7 @@ from typing import Sequence
 
 from narwhals._expression_parsing import ExprKind
 from narwhals._expression_parsing import evaluate_output_names_and_aliases
+from narwhals._expression_parsing import is_scalar_like
 from narwhals._expression_parsing import is_simple_aggregation
 from narwhals._expression_parsing import reuse_series_implementation
 from narwhals._pandas_like.expr_cat import PandasLikeExprCatNamespace
@@ -475,9 +476,9 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
                     )
                 )
                 return [result_frame[name] for name in aliases]
-        elif kind is ExprKind.TRANSFORM:
+        elif not is_scalar_like(kind):
             msg = (
-                "Elementwise operations are only supported in `over` context "
+                "Length-preserving operations are only supported in `over` context "
                 "for pandas if they are elementary "
                 "(e.g. `nw.col('a').cum_sum().over('b'))`)."
             )
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 7b16d5d2..02e49698 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -23,15 +23,17 @@ from narwhals._pandas_like.utils import horizontal_concat
 from narwhals._pandas_like.utils import vertical_concat
 from narwhals.typing import CompliantNamespace
 from narwhals.utils import import_dtypes_module
-from narwhals.utils import is_compliant_expr
 
 if TYPE_CHECKING:
     from typing_extensions import Self
+    from typing_extensions import TypeAlias
 
     from narwhals.dtypes import DType
     from narwhals.utils import Implementation
     from narwhals.utils import Version
 
+    _Scalar: TypeAlias = Any
+
 
 class PandasLikeNamespace(CompliantNamespace[PandasLikeDataFrame, PandasLikeSeries]):
     @property
@@ -405,16 +407,16 @@ class PandasWhen:
         condition: PandasLikeExpr,
         implementation: Implementation,
         backend_version: tuple[int, ...],
-        then_value: Any = None,
-        otherwise_value: Any = None,
+        then_value: PandasLikeExpr | _Scalar = None,
+        otherwise_value: PandasLikeExpr | _Scalar = None,
         *,
         version: Version,
     ) -> None:
         self._implementation = implementation
         self._backend_version = backend_version
-        self._condition = condition
-        self._then_value = then_value
-        self._otherwise_value = otherwise_value
+        self._condition: PandasLikeExpr = condition
+        self._then_value: PandasLikeExpr | _Scalar = then_value
+        self._otherwise_value: PandasLikeExpr | _Scalar = otherwise_value
         self._version = version
 
     def __call__(self: Self, df: PandasLikeDataFrame) -> Sequence[PandasLikeSeries]:
@@ -422,10 +424,9 @@ class PandasWhen:
         condition = self._condition(df)[0]
         condition_native = condition._native_series
 
-        if is_compliant_expr(self._then_value):
-            value_series: PandasLikeSeries = self._then_value(df)[0]
+        if isinstance(self._then_value, PandasLikeExpr):
+            value_series = self._then_value(df)[0]
         else:
-            # `self._then_value` is a scalar
             value_series = plx._create_series_from_scalar(
                 self._then_value, reference_series=condition.alias("literal")
             )
@@ -441,10 +442,9 @@ class PandasWhen:
                 )
             ]
 
-        if is_compliant_expr(self._otherwise_value):
-            otherwise_series: PandasLikeSeries = self._otherwise_value(df)[0]
+        if isinstance(self._otherwise_value, PandasLikeExpr):
+            otherwise_series = self._otherwise_value(df)[0]
         else:
-            # `self._then_value` is a scalar
             otherwise_series = plx._create_series_from_scalar(
                 self._otherwise_value, reference_series=condition.alias("literal")
             )
@@ -458,7 +458,9 @@ class PandasWhen:
             )
         ]
 
-    def then(self: Self, value: PandasLikeExpr | PandasLikeSeries | Any) -> PandasThen:
+    def then(
+        self: Self, value: PandasLikeExpr | PandasLikeSeries | _Scalar
+    ) -> PandasThen:
         self._then_value = value
 
         return PandasThen(
@@ -492,7 +494,7 @@ class PandasThen(PandasLikeExpr):
         self._implementation = implementation
         self._backend_version = backend_version
         self._version = version
-        self._call = call
+        self._call: PandasWhen = call
         self._depth = depth
         self._function_name = function_name
         self._evaluate_output_names = evaluate_output_names
@@ -500,11 +502,8 @@ class PandasThen(PandasLikeExpr):
         self._call_kwargs = call_kwargs or {}
 
     def otherwise(
-        self: Self, value: PandasLikeExpr | PandasLikeSeries | Any
+        self: Self, value: PandasLikeExpr | PandasLikeSeries | _Scalar
     ) -> PandasLikeExpr:
-        # type ignore because we are setting the `_call` attribute to a
-        # callable object of type `PandasWhen`, base class has the attribute as
-        # only a `Callable`
-        self._call._otherwise_value = value  # type: ignore[attr-defined]
+        self._call._otherwise_value = value
         self._function_name = "whenotherwise"
         return self
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 4c850283..bc5a33c8 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -645,13 +645,13 @@ class PandasLikeSeries(CompliantSeries):
             )
         return self
 
-    def __array__(self: Self, dtype: Any, copy: bool | None) -> _1DArray:
+    def __array__(self: Self, dtype: Any, *, copy: bool | None) -> _1DArray:
         # pandas used to always return object dtype for nullable dtypes.
         # So, we intercept __array__ and pass to `to_numpy` ourselves to make
         # sure an appropriate numpy dtype is returned.
         return self.to_numpy(dtype=dtype, copy=copy)
 
-    def to_numpy(self: Self, dtype: Any = None, copy: bool | None = None) -> _1DArray:
+    def to_numpy(self: Self, dtype: Any = None, *, copy: bool | None = None) -> _1DArray:
         # the default is meant to be None, but pandas doesn't allow it?
         # https://numpy.org/doc/stable/reference/generated/numpy.ndarray.__array__.html
         copy = copy or self._implementation is Implementation.CUDF
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 3386d56d..51fa5108 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -701,12 +701,12 @@ def convert_str_slice_to_int_slice(
 ) -> tuple[int | None, int | None, int | None]:
     # We can safely cast to int because we know that `columns` doesn't contain duplicates.
     start = (
-        cast(int, columns.get_loc(str_slice.start))
+        cast("int", columns.get_loc(str_slice.start))
         if str_slice.start is not None
         else None
     )
     stop = (
-        cast(int, columns.get_loc(str_slice.stop)) + 1
+        cast("int", columns.get_loc(str_slice.stop)) + 1
         if str_slice.stop is not None
         else None
     )
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 070d6583..cd18b316 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -128,7 +128,7 @@ class PolarsDataFrame:
         return func
 
     def __array__(
-        self: Self, dtype: Any | None = None, copy: bool | None = None
+        self: Self, dtype: Any | None = None, *, copy: bool | None = None
     ) -> _2DArray:
         if self._backend_version < (0, 20, 28) and copy is not None:
             msg = "`copy` in `__array__` is only supported for Polars>=0.20.28"
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 461932e5..1f91fbdb 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Sequence
-from typing import Union
 from typing import cast
 from typing import overload
 
@@ -146,7 +145,7 @@ class PolarsSeries:
             raise NotImplementedError(msg)
         return self._from_native_series(ser.replace_strict(old, new, return_dtype=dtype))
 
-    def __array__(self: Self, dtype: Any, copy: bool | None) -> _1DArray:
+    def __array__(self: Self, dtype: Any, *, copy: bool | None) -> _1DArray:
         if self._backend_version < (0, 20, 29):
             return self._native_series.__array__(dtype=dtype)
         return self._native_series.__array__(dtype=dtype, copy=copy)
@@ -513,8 +512,8 @@ class PolarsSeries:
             and (bin_count is not None)
             and (self._native_series.count() > 0)
         ):  # pragma: no cover
-            lower = cast(Union[int, float], self._native_series.min())
-            upper = cast(Union[int, float], self._native_series.max())
+            lower = cast("float", self._native_series.min())
+            upper = cast("float", self._native_series.max())
             pad_lowest_bin = False
             if lower == upper:
                 width = 1 / bin_count
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index 1739be09..6ee7a86b 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import warnings
+from importlib import import_module
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Literal
@@ -13,6 +14,7 @@ from narwhals.typing import CompliantDataFrame
 from narwhals.typing import CompliantLazyFrame
 from narwhals.utils import Implementation
 from narwhals.utils import check_column_exists
+from narwhals.utils import check_column_names_are_unique
 from narwhals.utils import find_stacklevel
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import parse_columns_to_drop
@@ -23,6 +25,7 @@ if TYPE_CHECKING:
     from types import ModuleType
 
     import pyarrow as pa
+    from pyspark.sql import Column
     from pyspark.sql import DataFrame
     from typing_extensions import Self
 
@@ -41,7 +44,10 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         backend_version: tuple[int, ...],
         version: Version,
         implementation: Implementation,
+        validate_column_names: bool,
     ) -> None:
+        if validate_column_names:
+            check_column_names_are_unique(native_dataframe.columns)
         self._native_frame = native_dataframe
         self._backend_version = backend_version
         self._implementation = implementation
@@ -51,9 +57,12 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
     @property
     def _F(self: Self) -> Any:  # noqa: N802
         if self._implementation is Implementation.SQLFRAME:
-            from sqlframe.duckdb import functions
+            from sqlframe.base.session import _BaseSession
+
+            return import_module(
+                f"sqlframe.{_BaseSession().execution_dialect_name}.functions"
+            )
 
-            return functions
         from pyspark.sql import functions
 
         return functions
@@ -61,9 +70,12 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
     @property
     def _native_dtypes(self: Self) -> Any:
         if self._implementation is Implementation.SQLFRAME:
-            from sqlframe.duckdb import types
+            from sqlframe.base.session import _BaseSession
+
+            return import_module(
+                f"sqlframe.{_BaseSession().execution_dialect_name}.types"
+            )
 
-            return types
         from pyspark.sql import types
 
         return types
@@ -71,13 +83,24 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
     @property
     def _Window(self: Self) -> Any:  # noqa: N802
         if self._implementation is Implementation.SQLFRAME:
-            from sqlframe.duckdb import Window
+            from sqlframe.base.session import _BaseSession
+
+            _window = import_module(
+                f"sqlframe.{_BaseSession().execution_dialect_name}.window"
+            )
+            return _window.Window
 
-            return Window
         from pyspark.sql import Window
 
         return Window
 
+    @property
+    def _session(self: Self) -> Any:
+        if self._implementation is Implementation.SQLFRAME:
+            return self._native_frame.session
+
+        return self._native_frame.sparkSession
+
     def __native_namespace__(self: Self) -> ModuleType:  # pragma: no cover
         return self._implementation.to_native_namespace()
 
@@ -99,14 +122,18 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
             backend_version=self._backend_version,
             version=version,
             implementation=self._implementation,
+            validate_column_names=False,
         )
 
-    def _from_native_frame(self: Self, df: DataFrame) -> Self:
+    def _from_native_frame(
+        self: Self, df: DataFrame, *, validate_column_names: bool = True
+    ) -> Self:
         return self.__class__(
             df,
             backend_version=self._backend_version,
             version=self._version,
             implementation=self._implementation,
+            validate_column_names=validate_column_names,
         )
 
     def _collect_to_arrow(self) -> pa.Table:
@@ -205,7 +232,9 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         raise ValueError(msg)  # pragma: no cover
 
     def simple_select(self: Self, *column_names: str) -> Self:
-        return self._from_native_frame(self._native_frame.select(*column_names))
+        return self._from_native_frame(
+            self._native_frame.select(*column_names), validate_column_names=False
+        )
 
     def aggregate(
         self: Self,
@@ -214,7 +243,9 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         new_columns = parse_exprs(self, *exprs)
 
         new_columns_list = [col.alias(col_name) for col_name, col in new_columns.items()]
-        return self._from_native_frame(self._native_frame.agg(*new_columns_list))
+        return self._from_native_frame(
+            self._native_frame.agg(*new_columns_list), validate_column_names=False
+        )
 
     def select(
         self: Self,
@@ -224,17 +255,18 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
 
         if not new_columns:
             # return empty dataframe, like Polars does
-            spark_session = self._native_frame.sparkSession
-            spark_df = spark_session.createDataFrame(
+            spark_df = self._session.createDataFrame(
                 [], self._native_dtypes.StructType([])
             )
 
-            return self._from_native_frame(spark_df)
+            return self._from_native_frame(spark_df, validate_column_names=False)
 
         new_columns_list = [
             col.alias(col_name) for (col_name, col) in new_columns.items()
         ]
-        return self._from_native_frame(self._native_frame.select(*new_columns_list))
+        return self._from_native_frame(
+            self._native_frame.select(*new_columns_list), validate_column_names=False
+        )
 
     def with_columns(self: Self, *exprs: SparkLikeExpr) -> Self:
         new_columns = parse_exprs(self, *exprs)
@@ -244,7 +276,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         # `[0]` is safe as the predicate's expression only returns a single column
         condition = predicate._call(self)[0]
         spark_df = self._native_frame.where(condition)
-        return self._from_native_frame(spark_df)
+        return self._from_native_frame(spark_df, validate_column_names=False)
 
     @property
     def schema(self: Self) -> dict[str, DType]:
@@ -264,13 +296,13 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         columns_to_drop = parse_columns_to_drop(
             compliant_frame=self, columns=columns, strict=strict
         )
-        return self._from_native_frame(self._native_frame.drop(*columns_to_drop))
+        return self._from_native_frame(
+            self._native_frame.drop(*columns_to_drop), validate_column_names=False
+        )
 
     def head(self: Self, n: int) -> Self:
-        spark_session = self._native_frame.sparkSession
-
         return self._from_native_frame(
-            spark_session.createDataFrame(self._native_frame.take(num=n))
+            self._native_frame.limit(num=n), validate_column_names=False
         )
 
     def group_by(self: Self, *keys: str, drop_null_keys: bool) -> SparkLikeLazyGroupBy:
@@ -301,10 +333,14 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
             )
 
         sort_cols = [sort_f(col) for col, sort_f in zip(by, sort_funcs)]
-        return self._from_native_frame(self._native_frame.sort(*sort_cols))
+        return self._from_native_frame(
+            self._native_frame.sort(*sort_cols), validate_column_names=False
+        )
 
     def drop_nulls(self: Self, subset: list[str] | None) -> Self:
-        return self._from_native_frame(self._native_frame.dropna(subset=subset))
+        return self._from_native_frame(
+            self._native_frame.dropna(subset=subset), validate_column_names=False
+        )
 
     def rename(self: Self, mapping: dict[str, str]) -> Self:
         rename_mapping = {
@@ -326,7 +362,9 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
             msg = "`LazyFrame.unique` with PySpark backend only supports `keep='any'`."
             raise ValueError(msg)
         check_column_exists(self.columns, subset)
-        return self._from_native_frame(self._native_frame.dropDuplicates(subset=subset))
+        return self._from_native_frame(
+            self._native_frame.dropDuplicates(subset=subset), validate_column_names=False
+        )
 
     def join(
         self: Self,
@@ -357,7 +395,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
                 for colname in list(set(right_columns).difference(set(right_on or [])))
             },
         }
-        other = other_native.select(
+        other_native = other_native.select(
             [self._F.col(old).alias(new) for old, new in rename_mapping.items()]
         )
 
@@ -375,7 +413,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
                 ]
             )
         return self._from_native_frame(
-            self_native.join(other, on=left_on, how=how).select(col_order)
+            self_native.join(other_native, on=left_on, how=how).select(col_order)
         )
 
     def explode(self: Self, columns: list[str]) -> Self:
@@ -402,16 +440,51 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
             )
             raise NotImplementedError(msg)
 
-        return self._from_native_frame(
-            native_frame.select(
-                *[
-                    self._F.col(col_name).alias(col_name)
-                    if col_name != columns[0]
-                    else self._F.explode_outer(col_name).alias(col_name)
-                    for col_name in column_names
-                ]
+        if self._implementation.is_pyspark():
+            return self._from_native_frame(
+                native_frame.select(
+                    *[
+                        self._F.col(col_name).alias(col_name)
+                        if col_name != columns[0]
+                        else self._F.explode_outer(col_name).alias(col_name)
+                        for col_name in column_names
+                    ]
+                ),
+                validate_column_names=False,
             )
-        )
+        elif self._implementation.is_sqlframe():
+            # Not every sqlframe dialect supports `explode_outer` function
+            # (see https://github.com/eakmanrq/sqlframe/blob/3cb899c515b101ff4c197d84b34fae490d0ed257/sqlframe/base/functions.py#L2288-L2289)
+            # therefore we simply explode the array column which will ignore nulls and
+            # zero sized arrays, and append these specific condition with nulls (to
+            # match polars behavior).
+
+            def null_condition(col_name: str) -> Column:
+                return self._F.isnull(col_name) | (self._F.array_size(col_name) == 0)
+
+            return self._from_native_frame(
+                native_frame.select(
+                    *[
+                        self._F.col(col_name).alias(col_name)
+                        if col_name != columns[0]
+                        else self._F.explode(col_name).alias(col_name)
+                        for col_name in column_names
+                    ]
+                ).union(
+                    native_frame.filter(null_condition(columns[0])).select(
+                        *[
+                            self._F.col(col_name).alias(col_name)
+                            if col_name != columns[0]
+                            else self._F.lit(None).alias(col_name)
+                            for col_name in column_names
+                        ]
+                    )
+                ),
+                validate_column_names=False,
+            )
+        else:  # pragma: no cover
+            msg = "Unreachable code, please report an issue at https://github.com/narwhals-dev/narwhals/issues"
+            raise AssertionError(msg)
 
     def unpivot(
         self: Self,
@@ -420,11 +493,25 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         variable_name: str,
         value_name: str,
     ) -> Self:
-        return self._from_native_frame(
-            self._native_frame.unpivot(
-                ids=index,
-                values=on,
-                variableColumnName=variable_name,
-                valueColumnName=value_name,
-            )
+        if self._implementation.is_sqlframe():
+            if variable_name == "":
+                msg = "`variable_name` cannot be empty string for sqlframe backend."
+                raise NotImplementedError(msg)
+
+            if value_name == "":
+                msg = "`value_name` cannot be empty string for sqlframe backend."
+                raise NotImplementedError(msg)
+
+        ids = tuple(self.columns) if index is None else tuple(index)
+        values = (
+            tuple(set(self.columns).difference(set(ids))) if on is None else tuple(on)
+        )
+        unpivoted_native_frame = self._native_frame.unpivot(
+            ids=ids,
+            values=values,
+            variableColumnName=variable_name,
+            valueColumnName=value_name,
         )
+        if index is None:
+            unpivoted_native_frame = unpivoted_native_frame.drop(*ids)
+        return self._from_native_frame(unpivoted_native_frame)
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index ff033b6a..0bc57253 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -1,10 +1,13 @@
 from __future__ import annotations
 
+import operator
+from importlib import import_module
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Literal
 from typing import Sequence
+from typing import cast
 
 from narwhals._expression_parsing import ExprKind
 from narwhals._spark_like.expr_dt import SparkLikeExprDateTimeNamespace
@@ -13,6 +16,7 @@ from narwhals._spark_like.expr_name import SparkLikeExprNameNamespace
 from narwhals._spark_like.expr_str import SparkLikeExprStringNamespace
 from narwhals._spark_like.utils import maybe_evaluate_expr
 from narwhals._spark_like.utils import narwhals_to_native_dtype
+from narwhals.dependencies import get_pyspark
 from narwhals.typing import CompliantExpr
 from narwhals.utils import Implementation
 from narwhals.utils import parse_version
@@ -73,31 +77,41 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
         )
 
     @property
-    def _F(self) -> Any:  # noqa: N802
+    def _F(self: Self) -> Any:  # noqa: N802
         if self._implementation is Implementation.SQLFRAME:
-            from sqlframe.duckdb import functions
+            from sqlframe.base.session import _BaseSession
+
+            return import_module(
+                f"sqlframe.{_BaseSession().execution_dialect_name}.functions"
+            )
 
-            return functions
         from pyspark.sql import functions
 
         return functions
 
     @property
-    def _native_types(self) -> Any:
+    def _native_dtypes(self: Self) -> Any:
         if self._implementation is Implementation.SQLFRAME:
-            from sqlframe.duckdb import types
+            from sqlframe.base.session import _BaseSession
+
+            return import_module(
+                f"sqlframe.{_BaseSession().execution_dialect_name}.types"
+            )
 
-            return types
         from pyspark.sql import types
 
         return types
 
     @property
-    def _Window(self) -> Any:  # noqa: N802
+    def _Window(self: Self) -> Any:  # noqa: N802
         if self._implementation is Implementation.SQLFRAME:
-            from sqlframe.duckdb import Window
+            from sqlframe.base.session import _BaseSession
+
+            _window = import_module(
+                f"sqlframe.{_BaseSession().execution_dialect_name}.window"
+            )
+            return _window.Window
 
-            return Window
         from pyspark.sql import Window
 
         return Window
@@ -287,7 +301,8 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
         )
 
     def __invert__(self: Self) -> Self:
-        return self._from_call(lambda _input: _input.__invert__(), "__invert__")
+        invert = cast("Callable[..., SparkLikeExpr]", operator.invert)
+        return self._from_call(invert, "__invert__")
 
     def abs(self: Self) -> Self:
         return self._from_call(self._F.abs, "abs")
@@ -318,7 +333,7 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
     def cast(self: Self, dtype: DType | type[DType]) -> Self:
         def _cast(_input: Column) -> Column:
             spark_dtype = narwhals_to_native_dtype(
-                dtype, self._version, self._native_types
+                dtype, self._version, self._native_dtypes
             )
             return _input.cast(spark_dtype)
 
@@ -335,9 +350,11 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
 
     def median(self: Self) -> Self:
         def _median(_input: Column) -> Column:
-            import pyspark  # ignore-banned-import
-
-            if parse_version(pyspark) < (3, 4):
+            if (
+                self._implementation.is_pyspark()
+                and (pyspark := get_pyspark()) is not None
+                and parse_version(pyspark) < (3, 4)
+            ):
                 # Use percentile_approx with default accuracy parameter (10000)
                 return self._F.percentile_approx(_input.cast("double"), 0.5)
 
@@ -419,7 +436,7 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
 
     def is_in(self: Self, values: Sequence[Any]) -> Self:
         def _is_in(_input: Column) -> Column:
-            return _input.isin(values)
+            return _input.isin(values) if values else self._F.lit(False)  # noqa: FBT003
 
         return self._from_call(_is_in, "is_in")
 
@@ -449,7 +466,7 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
     def n_unique(self: Self) -> Self:
         def _n_unique(_input: Column) -> Column:
             return self._F.count_distinct(_input) + self._F.max(
-                self._F.isnull(_input).cast(self._native_types.IntegerType())
+                self._F.isnull(_input).cast(self._native_dtypes.IntegerType())
             )
 
         return self._from_call(_n_unique, "n_unique")
diff --git a/narwhals/_spark_like/group_by.py b/narwhals/_spark_like/group_by.py
index f18cb5e7..a0acdcfe 100644
--- a/narwhals/_spark_like/group_by.py
+++ b/narwhals/_spark_like/group_by.py
@@ -35,7 +35,7 @@ class SparkLikeLazyGroupBy:
             native_expressions = expr(df)
             exclude = (
                 self._keys
-                if expr._function_name.split("->", maxsplit=1)[0] in ("all", "selector")
+                if expr._function_name.split("->", maxsplit=1)[0] in {"all", "selector"}
                 else []
             )
             agg_columns.extend(
diff --git a/narwhals/_spark_like/namespace.py b/narwhals/_spark_like/namespace.py
index 1b68d073..d64401e0 100644
--- a/narwhals/_spark_like/namespace.py
+++ b/narwhals/_spark_like/namespace.py
@@ -246,6 +246,7 @@ class SparkLikeNamespace(CompliantNamespace["SparkLikeLazyFrame", "Column"]):
                 backend_version=self._backend_version,
                 version=self._version,
                 implementation=self._implementation,
+                validate_column_names=False,
             )
 
         if how == "diagonal":
@@ -256,6 +257,7 @@ class SparkLikeNamespace(CompliantNamespace["SparkLikeLazyFrame", "Column"]):
                 backend_version=self._backend_version,
                 version=self._version,
                 implementation=self._implementation,
+                validate_column_names=False,
             )
         raise NotImplementedError
 
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b0273709..a461a0e5 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -16,9 +16,10 @@ from typing import overload
 from warnings import warn
 
 from narwhals._expression_parsing import ExprKind
-from narwhals._expression_parsing import all_exprs_are_aggs_or_literals
-from narwhals._expression_parsing import check_expressions_transform
+from narwhals._expression_parsing import all_exprs_are_scalar_like
+from narwhals._expression_parsing import check_expressions_preserve_length
 from narwhals._expression_parsing import infer_kind
+from narwhals._expression_parsing import is_scalar_like
 from narwhals.dependencies import get_polars
 from narwhals.dependencies import is_numpy_array
 from narwhals.dependencies import is_numpy_array_1d
@@ -137,9 +138,7 @@ class BaseFrame(Generic[_FrameT]):
     ) -> Self:
         compliant_exprs, kinds = self._flatten_and_extract(*exprs, **named_exprs)
         compliant_exprs = [
-            compliant_expr.broadcast(kind)
-            if (kind is ExprKind.LITERAL or kind is ExprKind.AGGREGATION)
-            else compliant_expr
+            compliant_expr.broadcast(kind) if is_scalar_like(kind) else compliant_expr
             for compliant_expr, kind in zip(compliant_exprs, kinds)
         ]
         return self._from_compliant_dataframe(
@@ -166,14 +165,12 @@ class BaseFrame(Generic[_FrameT]):
                     missing_columns, available_columns
                 ) from e
         compliant_exprs, kinds = self._flatten_and_extract(*flat_exprs, **named_exprs)
-        if compliant_exprs and all_exprs_are_aggs_or_literals(*flat_exprs, **named_exprs):
+        if compliant_exprs and all_exprs_are_scalar_like(*flat_exprs, **named_exprs):
             return self._from_compliant_dataframe(
                 self._compliant_frame.aggregate(*compliant_exprs),
             )
         compliant_exprs = [
-            compliant_expr.broadcast(kind)
-            if (kind is ExprKind.LITERAL or kind is ExprKind.AGGREGATION)
-            else compliant_expr
+            compliant_expr.broadcast(kind) if is_scalar_like(kind) else compliant_expr
             for compliant_expr, kind in zip(compliant_exprs, kinds)
         ]
         return self._from_compliant_dataframe(
@@ -205,7 +202,7 @@ class BaseFrame(Generic[_FrameT]):
             and all(isinstance(x, bool) for x in predicates[0])
         ):
             flat_predicates = flatten(predicates)
-            check_expressions_transform(*flat_predicates, function_name="filter")
+            check_expressions_preserve_length(*flat_predicates, function_name="filter")
             compliant_predicates, _kinds = self._flatten_and_extract(*flat_predicates)
             plx = self.__narwhals_namespace__()
             predicate = plx.all_horizontal(
@@ -499,7 +496,7 @@ class DataFrame(BaseFrame[DataFrameT]):
     def __len__(self: Self) -> int:
         return self._compliant_frame.__len__()  # type: ignore[no-any-return]
 
-    def __array__(self: Self, dtype: Any = None, copy: bool | None = None) -> _2DArray:
+    def __array__(self: Self, dtype: Any = None, copy: bool | None = None) -> _2DArray:  # noqa: FBT001
         return self._compliant_frame.__array__(dtype, copy=copy)  # type: ignore[no-any-return]
 
     def __repr__(self: Self) -> str:  # pragma: no cover
@@ -2204,18 +2201,18 @@ class LazyFrame(BaseFrame[FrameT]):
             plx = self.__narwhals_namespace__()
             return plx.col(arg)
         if isinstance(arg, Expr):
-            if arg._metadata.is_order_dependent():
+            if arg._metadata.n_open_windows > 0:
                 msg = (
                     "Order-dependent expressions are not supported for use in LazyFrame.\n\n"
                     "Hints:\n"
                     "- Instead of `lf.select(nw.col('a').sort())`, use `lf.select('a').sort()\n"
                     "- Instead of `lf.select(nw.col('a').head())`, use `lf.select('a').head()\n"
                     "- `Expr.cum_sum`, and other such expressions, are not currently supported.\n"
-                    "  In a future version of Narwhals, a `order_by` argument will be added and \n"
-                    "  they will be supported."
+                    "  In a future version of Narwhals, a `order_by` argument will be added to\n"
+                    "  `over` and they will be supported."
                 )
                 raise OrderDependentExprError(msg)
-            if arg._metadata.is_changes_length():
+            if arg._metadata.kind.is_changes_length():
                 msg = (
                     "Length-changing expressions are not supported for use in LazyFrame, unless\n"
                     "followed by an aggregation.\n\n"
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index e3369582..75678b0b 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -569,7 +569,7 @@ class Duration(TemporalType, metaclass=_DurationMeta):
     """
 
     def __init__(self: Self, time_unit: TimeUnit = "us") -> None:
-        if time_unit not in ("s", "ms", "us", "ns"):
+        if time_unit not in {"s", "ms", "us", "ns"}:
             msg = (
                 "invalid `time_unit`"
                 f"\n\nExpected one of {{'ns','us','ms', 's'}}, got {time_unit!r}."
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 962d2450..36dc6cda 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -649,7 +649,7 @@ class Expr:
                 function=function, return_dtype=return_dtype
             ),
             # safest assumptions
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def skew(self: Self) -> Self:
@@ -773,7 +773,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_min(),
-            ExprMetadata(ExprKind.AGGREGATION, order_dependent=True),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.AGGREGATION),
         )
 
     def arg_max(self: Self) -> Self:
@@ -797,7 +797,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_max(),
-            ExprMetadata(ExprKind.AGGREGATION, order_dependent=True),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.AGGREGATION),
         )
 
     def count(self: Self) -> Self:
@@ -922,7 +922,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_sum(reverse=reverse),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def diff(self: Self) -> Self:
@@ -965,7 +965,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).diff(),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def shift(self: Self, n: int) -> Self:
@@ -1011,7 +1011,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).shift(n),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def replace_strict(
@@ -1101,7 +1101,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_extra_open_window(),
         )
 
     # --- transform ---
@@ -1321,7 +1321,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_true(),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def fill_null(
@@ -1493,13 +1493,16 @@ class Expr:
             self._metadata.with_kind(ExprKind.CHANGES_LENGTH),
         )
 
-    def over(self: Self, *keys: str | Iterable[str]) -> Self:
+    def over(
+        self: Self, *keys: str | Iterable[str], _order_by: str | None = None
+    ) -> Self:
         """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`.
+            _order_by: Unused, but this is building up to something.
 
         Returns:
             A new expression.
@@ -1532,15 +1535,20 @@ class Expr:
             |2  4  y                    4|
             └────────────────────────────┘
         """
-        if self._metadata.is_changes_length():
+        if self._metadata.kind.is_changes_length():
             msg = "`.over()` can not be used for expressions which change length."
             raise LengthChangingExprError(msg)
+        kind = ExprKind.TRANSFORM
+        n_open_windows = self._metadata.n_open_windows
+        if _order_by is not None and self._metadata.kind.is_window():
+            n_open_windows -= 1
+        metadata = ExprMetadata(kind, n_open_windows=n_open_windows)
         flattened = flatten(keys)
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).over(
                 flattened, kind=self._metadata.kind
             ),
-            self._metadata.with_kind(ExprKind.TRANSFORM),
+            metadata,
         )
 
     def is_duplicated(self: Self) -> Self:
@@ -1648,7 +1656,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).is_first_distinct(),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def is_last_distinct(self: Self) -> Self:
@@ -1677,7 +1685,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).is_last_distinct(),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def quantile(
@@ -1746,7 +1754,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).head(n),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def tail(self: Self, n: int = 10) -> Self:
@@ -1774,7 +1782,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).tail(n),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def round(self: Self, decimals: int = 0) -> Self:
@@ -1869,7 +1877,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     # need to allow numeric typing
@@ -2012,7 +2020,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_count(reverse=reverse),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def cum_min(self: Self, *, reverse: bool = False) -> Self:
@@ -2045,7 +2053,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_min(reverse=reverse),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def cum_max(self: Self, *, reverse: bool = False) -> Self:
@@ -2078,7 +2086,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_max(reverse=reverse),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def cum_prod(self: Self, *, reverse: bool = False) -> Self:
@@ -2111,7 +2119,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_prod(reverse=reverse),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def rolling_sum(
@@ -2173,7 +2181,7 @@ class Expr:
                 min_samples=min_samples,
                 center=center,
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def rolling_mean(
@@ -2235,7 +2243,7 @@ class Expr:
                 min_samples=min_samples,
                 center=center,
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def rolling_var(
@@ -2297,7 +2305,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).rolling_var(
                 window_size=window_size, min_samples=min_samples, center=center, ddof=ddof
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def rolling_std(
@@ -2362,7 +2370,7 @@ class Expr:
                 center=center,
                 ddof=ddof,
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     def rank(
@@ -2428,7 +2436,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).rank(
                 method=method, descending=descending
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
         )
 
     @property
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index dbef8343..cb4ddf6b 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -23,57 +23,25 @@ class ExprStringNamespace(Generic[ExprT]):
             A new expression.
 
         Examples:
-            >>> import pandas as pd
             >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"words": ["foo", "Café", "345", "東京", None]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_len_chars(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         words_len=nw.col("words").str.len_chars()
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_len_chars`:
-
-            >>> agnostic_str_len_chars(df_pd)
-              words  words_len
-            0   foo        3.0
-            1  Café        4.0
-            2   345        3.0
-            3    東京        2.0
-            4  None        NaN
-
-            >>> agnostic_str_len_chars(df_pl)
-            shape: (5, 2)
-            ┌───────┬───────────┐
-            │ words ┆ words_len │
-            │ ---   ┆ ---       │
-            │ str   ┆ u32       │
-            ╞═══════╪═══════════╡
-            │ foo   ┆ 3         │
-            │ Café  ┆ 4         │
-            │ 345   ┆ 3         │
-            │ 東京  ┆ 2         │
-            │ null  ┆ null      │
-            └───────┴───────────┘
-
-            >>> agnostic_str_len_chars(df_pa)
-            pyarrow.Table
-            words: string
-            words_len: int32
-            ----
-            words: [["foo","Café","345","東京",null]]
-            words_len: [[3,4,3,2,null]]
+            >>> df_native = pl.DataFrame({"words": ["foo", "345", None]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(words_len=nw.col("words").str.len_chars())
+            ┌─────────────────────┐
+            | Narwhals DataFrame  |
+            |---------------------|
+            |shape: (3, 2)        |
+            |┌───────┬───────────┐|
+            |│ words ┆ words_len │|
+            |│ ---   ┆ ---       │|
+            |│ str   ┆ u32       │|
+            |╞═══════╪═══════════╡|
+            |│ foo   ┆ 3         │|
+            |│ 345   ┆ 3         │|
+            |│ null  ┆ null      │|
+            |└───────┴───────────┘|
+            └─────────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.len_chars(),
@@ -96,49 +64,17 @@ class ExprStringNamespace(Generic[ExprT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"foo": ["123abc", "abc abc123"]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_replace(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     df = df.with_columns(replaced=nw.col("foo").str.replace("abc", ""))
-            ...     return df.to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_replace`:
-
-            >>> agnostic_str_replace(df_pd)
-                      foo replaced
-            0      123abc      123
-            1  abc abc123   abc123
-
-            >>> agnostic_str_replace(df_pl)
-            shape: (2, 2)
-            ┌────────────┬──────────┐
-            │ foo        ┆ replaced │
-            │ ---        ┆ ---      │
-            │ str        ┆ str      │
-            ╞════════════╪══════════╡
-            │ 123abc     ┆ 123      │
-            │ abc abc123 ┆  abc123  │
-            └────────────┴──────────┘
-
-            >>> agnostic_str_replace(df_pa)
-            pyarrow.Table
-            foo: string
-            replaced: string
-            ----
-            foo: [["123abc","abc abc123"]]
-            replaced: [["123"," abc123"]]
+            >>> df_native = pd.DataFrame({"foo": ["123abc", "abc abc123"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(replaced=nw.col("foo").str.replace("abc", ""))
+            ┌──────────────────────┐
+            |  Narwhals DataFrame  |
+            |----------------------|
+            |          foo replaced|
+            |0      123abc      123|
+            |1  abc abc123   abc123|
+            └──────────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.replace(
@@ -162,51 +98,17 @@ class ExprStringNamespace(Generic[ExprT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"foo": ["123abc", "abc abc123"]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_replace_all(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     df = df.with_columns(
-            ...         replaced=nw.col("foo").str.replace_all("abc", "")
-            ...     )
-            ...     return df.to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_replace_all`:
-
-            >>> agnostic_str_replace_all(df_pd)
-                      foo replaced
-            0      123abc      123
-            1  abc abc123      123
-
-            >>> agnostic_str_replace_all(df_pl)
-            shape: (2, 2)
-            ┌────────────┬──────────┐
-            │ foo        ┆ replaced │
-            │ ---        ┆ ---      │
-            │ str        ┆ str      │
-            ╞════════════╪══════════╡
-            │ 123abc     ┆ 123      │
-            │ abc abc123 ┆  123     │
-            └────────────┴──────────┘
-
-            >>> agnostic_str_replace_all(df_pa)
-            pyarrow.Table
-            foo: string
-            replaced: string
-            ----
-            foo: [["123abc","abc abc123"]]
-            replaced: [["123"," 123"]]
+            >>> df_native = pd.DataFrame({"foo": ["123abc", "abc abc123"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(replaced=nw.col("foo").str.replace_all("abc", ""))
+            ┌──────────────────────┐
+            |  Narwhals DataFrame  |
+            |----------------------|
+            |          foo replaced|
+            |0      123abc      123|
+            |1  abc abc123      123|
+            └──────────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.replace_all(
@@ -228,35 +130,13 @@ class ExprStringNamespace(Generic[ExprT]):
             A new expression.
 
         Examples:
-            >>> from typing import Any
-            >>> import pandas as pd
             >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrame
-            >>>
-            >>> data = {"fruits": ["apple", "\nmango"]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_strip_chars(df_native: IntoFrame) -> dict[str, Any]:
-            ...     df = nw.from_native(df_native)
-            ...     df = df.with_columns(stripped=nw.col("fruits").str.strip_chars())
-            ...     return df.to_dict(as_series=False)
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_strip_chars`:
-
-            >>> agnostic_str_strip_chars(df_pd)
-            {'fruits': ['apple', '\nmango'], 'stripped': ['apple', 'mango']}
-
-            >>> agnostic_str_strip_chars(df_pl)
-            {'fruits': ['apple', '\nmango'], 'stripped': ['apple', 'mango']}
-
-            >>> agnostic_str_strip_chars(df_pa)
+            >>> df_native = pl.DataFrame({"fruits": ["apple", "\nmango"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(stripped=nw.col("fruits").str.strip_chars()).to_dict(
+            ...     as_series=False
+            ... )
             {'fruits': ['apple', '\nmango'], 'stripped': ['apple', 'mango']}
         """
         return self._expr.__class__(
@@ -275,52 +155,18 @@ class ExprStringNamespace(Generic[ExprT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"fruits": ["apple", "mango", None]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_starts_with(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         has_prefix=nw.col("fruits").str.starts_with("app")
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_starts_with`:
-
-            >>> agnostic_str_starts_with(df_pd)
-              fruits has_prefix
-            0  apple       True
-            1  mango      False
-            2   None       None
-
-            >>> agnostic_str_starts_with(df_pl)
-            shape: (3, 2)
-            ┌────────┬────────────┐
-            │ fruits ┆ has_prefix │
-            │ ---    ┆ ---        │
-            │ str    ┆ bool       │
-            ╞════════╪════════════╡
-            │ apple  ┆ true       │
-            │ mango  ┆ false      │
-            │ null   ┆ null       │
-            └────────┴────────────┘
-
-            >>> agnostic_str_starts_with(df_pa)
-            pyarrow.Table
-            fruits: string
-            has_prefix: bool
-            ----
-            fruits: [["apple","mango",null]]
-            has_prefix: [[true,false,null]]
+            >>> df_native = pd.DataFrame({"fruits": ["apple", "mango", None]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(has_prefix=nw.col("fruits").str.starts_with("app"))
+            ┌───────────────────┐
+            |Narwhals DataFrame |
+            |-------------------|
+            |  fruits has_prefix|
+            |0  apple       True|
+            |1  mango      False|
+            |2   None       None|
+            └───────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.starts_with(prefix),
@@ -338,52 +184,18 @@ class ExprStringNamespace(Generic[ExprT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"fruits": ["apple", "mango", None]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_ends_with(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         has_suffix=nw.col("fruits").str.ends_with("ngo")
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_ends_with`:
-
-            >>> agnostic_str_ends_with(df_pd)
-              fruits has_suffix
-            0  apple      False
-            1  mango       True
-            2   None       None
-
-            >>> agnostic_str_ends_with(df_pl)
-            shape: (3, 2)
-            ┌────────┬────────────┐
-            │ fruits ┆ has_suffix │
-            │ ---    ┆ ---        │
-            │ str    ┆ bool       │
-            ╞════════╪════════════╡
-            │ apple  ┆ false      │
-            │ mango  ┆ true       │
-            │ null   ┆ null       │
-            └────────┴────────────┘
-
-            >>> agnostic_str_ends_with(df_pa)
-            pyarrow.Table
-            fruits: string
-            has_suffix: bool
-            ----
-            fruits: [["apple","mango",null]]
-            has_suffix: [[false,true,null]]
+            >>> df_native = pd.DataFrame({"fruits": ["apple", "mango", None]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(has_suffix=nw.col("fruits").str.ends_with("ngo"))
+            ┌───────────────────┐
+            |Narwhals DataFrame |
+            |-------------------|
+            |  fruits has_suffix|
+            |0  apple      False|
+            |1  mango       True|
+            |2   None       None|
+            └───────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.ends_with(suffix),
@@ -402,67 +214,22 @@ class ExprStringNamespace(Generic[ExprT]):
             A new expression.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"pets": ["cat", "dog", "rabbit and parrot", "dove", None]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_contains(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         default_match=nw.col("pets").str.contains("parrot|Dove"),
-            ...         case_insensitive_match=nw.col("pets").str.contains(
-            ...             "(?i)parrot|Dove"
-            ...         ),
-            ...         literal_match=nw.col("pets").str.contains(
-            ...             "parrot|Dove", literal=True
-            ...         ),
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_contains`:
-
-            >>> agnostic_str_contains(df_pd)
-                            pets default_match case_insensitive_match literal_match
-            0                cat         False                  False         False
-            1                dog         False                  False         False
-            2  rabbit and parrot          True                   True         False
-            3               dove         False                   True         False
-            4               None          None                   None          None
-
-            >>> agnostic_str_contains(df_pl)
-            shape: (5, 4)
-            ┌───────────────────┬───────────────┬────────────────────────┬───────────────┐
-            │ pets              ┆ default_match ┆ case_insensitive_match ┆ literal_match │
-            │ ---               ┆ ---           ┆ ---                    ┆ ---           │
-            │ str               ┆ bool          ┆ bool                   ┆ bool          │
-            ╞═══════════════════╪═══════════════╪════════════════════════╪═══════════════╡
-            │ cat               ┆ false         ┆ false                  ┆ false         │
-            │ dog               ┆ false         ┆ false                  ┆ false         │
-            │ rabbit and parrot ┆ true          ┆ true                   ┆ false         │
-            │ dove              ┆ false         ┆ true                   ┆ false         │
-            │ null              ┆ null          ┆ null                   ┆ null          │
-            └───────────────────┴───────────────┴────────────────────────┴───────────────┘
-
-            >>> agnostic_str_contains(df_pa)
+            >>> df_native = pa.table({"pets": ["cat", "dog", "rabbit and parrot"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(
+            ...     default_match=nw.col("pets").str.contains("cat|parrot"),
+            ...     case_insensitive_match=nw.col("pets").str.contains("cat|(?i)parrot"),
+            ... ).to_native()
             pyarrow.Table
             pets: string
             default_match: bool
             case_insensitive_match: bool
-            literal_match: bool
             ----
-            pets: [["cat","dog","rabbit and parrot","dove",null]]
-            default_match: [[false,false,true,false,null]]
-            case_insensitive_match: [[false,false,true,true,null]]
-            literal_match: [[false,false,false,false,null]]
+            pets: [["cat","dog","rabbit and parrot"]]
+            default_match: [[true,false,true]]
+            case_insensitive_match: [[true,false,true]]
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.contains(
@@ -483,89 +250,19 @@ class ExprStringNamespace(Generic[ExprT]):
             A new expression.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
+           >>> import pandas as pd
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"s": ["pear", None, "papaya", "dragonfruit"]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_slice(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         s_sliced=nw.col("s").str.slice(4, length=3)
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_slice`:
-
-            >>> agnostic_str_slice(df_pd)  # doctest: +NORMALIZE_WHITESPACE
-                         s s_sliced
-            0         pear
-            1         None     None
-            2       papaya       ya
-            3  dragonfruit      onf
-
-            >>> agnostic_str_slice(df_pl)
-            shape: (4, 2)
-            ┌─────────────┬──────────┐
-            │ s           ┆ s_sliced │
-            │ ---         ┆ ---      │
-            │ str         ┆ str      │
-            ╞═════════════╪══════════╡
-            │ pear        ┆          │
-            │ null        ┆ null     │
-            │ papaya      ┆ ya       │
-            │ dragonfruit ┆ onf      │
-            └─────────────┴──────────┘
-
-            >>> agnostic_str_slice(df_pa)
-            pyarrow.Table
-            s: string
-            s_sliced: string
-            ----
-            s: [["pear",null,"papaya","dragonfruit"]]
-            s_sliced: [["",null,"ya","onf"]]
-
-            Using negative indexes:
-
-            >>> def agnostic_str_slice_negative(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(s_sliced=nw.col("s").str.slice(-3)).to_native()
-
-            >>> agnostic_str_slice_negative(df_pd)
-                         s s_sliced
-            0         pear      ear
-            1         None     None
-            2       papaya      aya
-            3  dragonfruit      uit
-
-            >>> agnostic_str_slice_negative(df_pl)
-            shape: (4, 2)
-            ┌─────────────┬──────────┐
-            │ s           ┆ s_sliced │
-            │ ---         ┆ ---      │
-            │ str         ┆ str      │
-            ╞═════════════╪══════════╡
-            │ pear        ┆ ear      │
-            │ null        ┆ null     │
-            │ papaya      ┆ aya      │
-            │ dragonfruit ┆ uit      │
-            └─────────────┴──────────┘
-
-            >>> agnostic_str_slice_negative(df_pa)
-            pyarrow.Table
-            s: string
-            s_sliced: string
-            ----
-            s: [["pear",null,"papaya","dragonfruit"]]
-            s_sliced: [["ear",null,"aya","uit"]]
+            >>> df_native = pd.DataFrame({"s": ["pear", None, "papaya"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(s_sliced=nw.col("s").str.slice(4, length=3))
+            ┌──────────────────┐
+            |Narwhals DataFrame|
+            |------------------|
+            |        s s_sliced|
+            |0    pear         |
+            |1    None     None|
+            |2  papaya       ya|
+            └──────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
@@ -587,55 +284,17 @@ class ExprStringNamespace(Generic[ExprT]):
             If the length of the string has fewer than `n` characters, the full string is returned.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"lyrics": ["Atatata", "taata", "taatatata", "zukkyun"]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_head(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         lyrics_head=nw.col("lyrics").str.head()
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_head`:
-
-            >>> agnostic_str_head(df_pd)
-                  lyrics lyrics_head
-            0    Atatata       Atata
-            1      taata       taata
-            2  taatatata       taata
-            3    zukkyun       zukky
-
-            >>> agnostic_str_head(df_pl)
-            shape: (4, 2)
-            ┌───────────┬─────────────┐
-            │ lyrics    ┆ lyrics_head │
-            │ ---       ┆ ---         │
-            │ str       ┆ str         │
-            ╞═══════════╪═════════════╡
-            │ Atatata   ┆ Atata       │
-            │ taata     ┆ taata       │
-            │ taatatata ┆ taata       │
-            │ zukkyun   ┆ zukky       │
-            └───────────┴─────────────┘
-
-            >>> agnostic_str_head(df_pa)
+            >>> df_native = pa.table({"lyrics": ["taata", "taatatata", "zukkyun"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(lyrics_head=nw.col("lyrics").str.head()).to_native()
             pyarrow.Table
             lyrics: string
             lyrics_head: string
             ----
-            lyrics: [["Atatata","taata","taatatata","zukkyun"]]
-            lyrics_head: [["Atata","taata","taata","zukky"]]
+            lyrics: [["taata","taatatata","zukkyun"]]
+            lyrics_head: [["taata","taata","zukky"]]
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(0, n),
@@ -655,55 +314,17 @@ class ExprStringNamespace(Generic[ExprT]):
             If the length of the string has fewer than `n` characters, the full string is returned.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"lyrics": ["Atatata", "taata", "taatatata", "zukkyun"]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_tail(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         lyrics_tail=nw.col("lyrics").str.tail()
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_tail`:
-
-            >>> agnostic_str_tail(df_pd)
-                  lyrics lyrics_tail
-            0    Atatata       atata
-            1      taata       taata
-            2  taatatata       atata
-            3    zukkyun       kkyun
-
-            >>> agnostic_str_tail(df_pl)
-            shape: (4, 2)
-            ┌───────────┬─────────────┐
-            │ lyrics    ┆ lyrics_tail │
-            │ ---       ┆ ---         │
-            │ str       ┆ str         │
-            ╞═══════════╪═════════════╡
-            │ Atatata   ┆ atata       │
-            │ taata     ┆ taata       │
-            │ taatatata ┆ atata       │
-            │ zukkyun   ┆ kkyun       │
-            └───────────┴─────────────┘
-
-            >>> agnostic_str_tail(df_pa)
+            >>> df_native = pa.table({"lyrics": ["taata", "taatatata", "zukkyun"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(lyrics_tail=nw.col("lyrics").str.tail()).to_native()
             pyarrow.Table
             lyrics: string
             lyrics_tail: string
             ----
-            lyrics: [["Atatata","taata","taatatata","zukkyun"]]
-            lyrics_tail: [["atata","taata","atata","kkyun"]]
+            lyrics: [["taata","taatatata","zukkyun"]]
+            lyrics_tail: [["taata","atata","kkyun"]]
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
@@ -733,49 +354,24 @@ class ExprStringNamespace(Generic[ExprT]):
             set the time unit in pandas, if the version permits, will arrive.
 
         Examples:
-            >>> import pandas as pd
             >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = ["2020-01-01", "2020-01-02"]
-            >>> df_pd = pd.DataFrame({"a": data})
-            >>> df_pl = pl.DataFrame({"a": data})
-            >>> df_pa = pa.table({"a": data})
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_to_datetime(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(
-            ...         nw.col("a").str.to_datetime(format="%Y-%m-%d")
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_to_datetime`:
-
-            >>> agnostic_str_to_datetime(df_pd)
-                       a
-            0 2020-01-01
-            1 2020-01-02
-
-            >>> agnostic_str_to_datetime(df_pl)
-            shape: (2, 1)
-            ┌─────────────────────┐
-            │ a                   │
-            │ ---                 │
-            │ datetime[μs]        │
-            ╞═════════════════════╡
-            │ 2020-01-01 00:00:00 │
-            │ 2020-01-02 00:00:00 │
-            └─────────────────────┘
-
-            >>> agnostic_str_to_datetime(df_pa)
-            pyarrow.Table
-            a: timestamp[us]
-            ----
-            a: [[2020-01-01 00:00:00.000000,2020-01-02 00:00:00.000000]]
+            >>> df_native = pl.DataFrame({"a": ["2020-01-01", "2020-01-02"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.select(nw.col("a").str.to_datetime(format="%Y-%m-%d"))
+            ┌───────────────────────┐
+            |  Narwhals DataFrame   |
+            |-----------------------|
+            |shape: (2, 1)          |
+            |┌─────────────────────┐|
+            |│ a                   │|
+            |│ ---                 │|
+            |│ datetime[μs]        │|
+            |╞═════════════════════╡|
+            |│ 2020-01-01 00:00:00 │|
+            |│ 2020-01-02 00:00:00 │|
+            |└─────────────────────┘|
+            └───────────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_datetime(format=format),
@@ -795,52 +391,17 @@ class ExprStringNamespace(Generic[ExprT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"fruits": ["apple", "mango", None]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_to_uppercase(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         upper_col=nw.col("fruits").str.to_uppercase()
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_to_uppercase`:
-
-            >>> agnostic_str_to_uppercase(df_pd)
-              fruits upper_col
-            0  apple     APPLE
-            1  mango     MANGO
-            2   None      None
-
-            >>> agnostic_str_to_uppercase(df_pl)
-            shape: (3, 2)
-            ┌────────┬───────────┐
-            │ fruits ┆ upper_col │
-            │ ---    ┆ ---       │
-            │ str    ┆ str       │
-            ╞════════╪═══════════╡
-            │ apple  ┆ APPLE     │
-            │ mango  ┆ MANGO     │
-            │ null   ┆ null      │
-            └────────┴───────────┘
-
-            >>> agnostic_str_to_uppercase(df_pa)
-            pyarrow.Table
-            fruits: string
-            upper_col: string
-            ----
-            fruits: [["apple","mango",null]]
-            upper_col: [["APPLE","MANGO",null]]
+            >>> df_native = pd.DataFrame({"fruits": ["apple", None]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(upper_col=nw.col("fruits").str.to_uppercase())
+            ┌──────────────────┐
+            |Narwhals DataFrame|
+            |------------------|
+            |  fruits upper_col|
+            |0  apple     APPLE|
+            |1   None      None|
+            └──────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_uppercase(),
@@ -855,52 +416,17 @@ class ExprStringNamespace(Generic[ExprT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"fruits": ["APPLE", "MANGO", None]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_str_to_lowercase(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.with_columns(
-            ...         lower_col=nw.col("fruits").str.to_lowercase()
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_str_to_lowercase`:
-
-            >>> agnostic_str_to_lowercase(df_pd)
-              fruits lower_col
-            0  APPLE     apple
-            1  MANGO     mango
-            2   None      None
-
-            >>> agnostic_str_to_lowercase(df_pl)
-            shape: (3, 2)
-            ┌────────┬───────────┐
-            │ fruits ┆ lower_col │
-            │ ---    ┆ ---       │
-            │ str    ┆ str       │
-            ╞════════╪═══════════╡
-            │ APPLE  ┆ apple     │
-            │ MANGO  ┆ mango     │
-            │ null   ┆ null      │
-            └────────┴───────────┘
-
-            >>> agnostic_str_to_lowercase(df_pa)
-            pyarrow.Table
-            fruits: string
-            lower_col: string
-            ----
-            fruits: [["APPLE","MANGO",null]]
-            lower_col: [["apple","mango",null]]
+            >>> df_native = pd.DataFrame({"fruits": ["APPLE", None]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(lower_col=nw.col("fruits").str.to_lowercase())
+            ┌──────────────────┐
+            |Narwhals DataFrame|
+            |------------------|
+            |  fruits lower_col|
+            |0  APPLE     apple|
+            |1   None      None|
+            └──────────────────┘
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_lowercase(),
diff --git a/narwhals/functions.py b/narwhals/functions.py
index d64b5c49..9f64dba2 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -16,10 +16,11 @@ from typing import overload
 from narwhals._expression_parsing import ExprKind
 from narwhals._expression_parsing import ExprMetadata
 from narwhals._expression_parsing import apply_n_ary_operation
-from narwhals._expression_parsing import check_expressions_transform
+from narwhals._expression_parsing import check_expressions_preserve_length
 from narwhals._expression_parsing import combine_metadata
 from narwhals._expression_parsing import extract_compliant
 from narwhals._expression_parsing import infer_kind
+from narwhals._expression_parsing import is_scalar_like
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.dependencies import is_numpy_array
@@ -796,12 +797,12 @@ def _read_csv_impl(
 ) -> DataFrame[Any]:
     eager_backend = Implementation.from_backend(backend)
     native_namespace = eager_backend.to_native_namespace()
-    if eager_backend in (
+    if eager_backend in {
         Implementation.POLARS,
         Implementation.PANDAS,
         Implementation.MODIN,
         Implementation.CUDF,
-    ):
+    }:
         native_frame = native_namespace.read_csv(source, **kwargs)
     elif eager_backend is Implementation.PYARROW:
         from pyarrow import csv  # ignore-banned-import
@@ -859,13 +860,13 @@ def _scan_csv_impl(
     implementation = Implementation.from_native_namespace(native_namespace)
     if implementation is Implementation.POLARS:
         native_frame = native_namespace.scan_csv(source, **kwargs)
-    elif implementation in (
+    elif implementation in {
         Implementation.PANDAS,
         Implementation.MODIN,
         Implementation.CUDF,
         Implementation.DASK,
         Implementation.DUCKDB,
-    ):
+    }:
         native_frame = native_namespace.read_csv(source, **kwargs)
     elif implementation is Implementation.PYARROW:
         from pyarrow import csv  # ignore-banned-import
@@ -923,13 +924,13 @@ def _read_parquet_impl(
     source: str, *, native_namespace: ModuleType, **kwargs: Any
 ) -> DataFrame[Any]:
     implementation = Implementation.from_native_namespace(native_namespace)
-    if implementation in (
+    if implementation in {
         Implementation.POLARS,
         Implementation.PANDAS,
         Implementation.MODIN,
         Implementation.CUDF,
         Implementation.DUCKDB,
-    ):
+    }:
         native_frame = native_namespace.read_parquet(source, **kwargs)
     elif implementation is Implementation.PYARROW:
         import pyarrow.parquet as pq  # ignore-banned-import
@@ -988,13 +989,13 @@ def _scan_parquet_impl(
     implementation = Implementation.from_native_namespace(native_namespace)
     if implementation is Implementation.POLARS:
         native_frame = native_namespace.scan_parquet(source, **kwargs)
-    elif implementation in (
+    elif implementation in {
         Implementation.PANDAS,
         Implementation.MODIN,
         Implementation.CUDF,
         Implementation.DASK,
         Implementation.DUCKDB,
-    ):
+    }:
         native_frame = native_namespace.read_parquet(source, **kwargs)
     elif implementation is Implementation.PYARROW:
         import pyarrow.parquet as pq  # ignore-banned-import
@@ -1138,7 +1139,7 @@ def len_() -> Expr:
     def func(plx: Any) -> Any:
         return plx.len()
 
-    return Expr(func, ExprMetadata(ExprKind.AGGREGATION, order_dependent=False))
+    return Expr(func, ExprMetadata(ExprKind.AGGREGATION, n_open_windows=0))
 
 
 def sum(*columns: str) -> Expr:
@@ -1436,7 +1437,7 @@ def max_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
 class When:
     def __init__(self: Self, *predicates: IntoExpr | Iterable[IntoExpr]) -> None:
         self._predicate = all_horizontal(*flatten(predicates))
-        check_expressions_transform(self._predicate, function_name="when")
+        check_expressions_preserve_length(self._predicate, function_name="when")
 
     def then(self: Self, value: IntoExpr | Any) -> Then:
         return Then(
@@ -1458,9 +1459,7 @@ class Then(Expr):
         def func(plx: CompliantNamespace[Any, Any]) -> CompliantExpr[Any, Any]:
             compliant_expr = self._to_compliant_expr(plx)
             compliant_value = extract_compliant(plx, value, str_as_lit=False)
-            if (
-                kind is ExprKind.AGGREGATION or kind is ExprKind.LITERAL
-            ) and is_compliant_expr(compliant_value):
+            if is_scalar_like(kind) and is_compliant_expr(compliant_value):
                 compliant_value = compliant_value.broadcast(kind)
             return compliant_expr.otherwise(compliant_value)  # type: ignore[no-any-return]
 
@@ -1596,7 +1595,7 @@ def lit(value: Any, dtype: DType | type[DType] | None = None) -> Expr:
 
     return Expr(
         lambda plx: plx.lit(value, dtype),
-        ExprMetadata(ExprKind.LITERAL, order_dependent=False),
+        ExprMetadata(ExprKind.LITERAL, n_open_windows=0),
     )
 
 
diff --git a/narwhals/group_by.py b/narwhals/group_by.py
index 66f01cb9..e299d17d 100644
--- a/narwhals/group_by.py
+++ b/narwhals/group_by.py
@@ -8,7 +8,7 @@ from typing import Iterator
 from typing import TypeVar
 from typing import cast
 
-from narwhals._expression_parsing import all_exprs_are_aggs_or_literals
+from narwhals._expression_parsing import all_exprs_are_scalar_like
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.exceptions import InvalidOperationError
@@ -18,6 +18,8 @@ from narwhals.utils import tupleify
 if TYPE_CHECKING:
     from typing_extensions import Self
 
+    from narwhals.dataframe import DataFrame
+    from narwhals.dataframe import LazyFrame
     from narwhals.expr import Expr
 
 DataFrameT = TypeVar("DataFrameT")
@@ -26,7 +28,7 @@ LazyFrameT = TypeVar("LazyFrameT")
 
 class GroupBy(Generic[DataFrameT]):
     def __init__(self: Self, df: DataFrameT, *keys: str, drop_null_keys: bool) -> None:
-        self._df = cast(DataFrame[Any], df)
+        self._df = cast("DataFrame[Any]", df)
         self._keys = keys
         self._grouped = self._df._compliant_frame.group_by(
             *self._keys, drop_null_keys=drop_null_keys
@@ -113,7 +115,7 @@ class GroupBy(Generic[DataFrameT]):
             └─────┴─────┴─────┘
         """
         flat_aggs = tuple(flatten(aggs))
-        if not all_exprs_are_aggs_or_literals(*flat_aggs, **named_aggs):
+        if not all_exprs_are_scalar_like(*flat_aggs, **named_aggs):
             msg = (
                 "Found expression which does not aggregate.\n\n"
                 "All expressions passed to GroupBy.agg must aggregate.\n"
@@ -142,7 +144,7 @@ class GroupBy(Generic[DataFrameT]):
 
 class LazyGroupBy(Generic[LazyFrameT]):
     def __init__(self: Self, df: LazyFrameT, *keys: str, drop_null_keys: bool) -> None:
-        self._df = cast(LazyFrame[Any], df)
+        self._df = cast("LazyFrame[Any]", df)
         self._keys = keys
         self._grouped = self._df._compliant_frame.group_by(
             *self._keys, drop_null_keys=drop_null_keys
@@ -213,7 +215,7 @@ class LazyGroupBy(Generic[LazyFrameT]):
             └─────┴─────┴─────┘
         """
         flat_aggs = tuple(flatten(aggs))
-        if not all_exprs_are_aggs_or_literals(*flat_aggs, **named_aggs):
+        if not all_exprs_are_scalar_like(*flat_aggs, **named_aggs):
             msg = (
                 "Found expression which does not aggregate.\n\n"
                 "All expressions passed to GroupBy.agg must aggregate.\n"
diff --git a/narwhals/series.py b/narwhals/series.py
index 421e1c64..8c4ca9e7 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -112,7 +112,7 @@ class Series(Generic[IntoSeriesT]):
         """
         return self._compliant_series._implementation  # type: ignore[no-any-return]
 
-    def __array__(self: Self, dtype: Any = None, copy: bool | None = None) -> _1DArray:
+    def __array__(self: Self, dtype: Any = None, copy: bool | None = None) -> _1DArray:  # noqa: FBT001
         return self._compliant_series.__array__(dtype=dtype, copy=copy)  # type: ignore[no-any-return]
 
     @overload
@@ -154,7 +154,7 @@ class Series(Generic[IntoSeriesT]):
             ]
         """
         if isinstance(idx, int) or (
-            is_numpy_scalar(idx) and idx.dtype.kind in ("i", "u")
+            is_numpy_scalar(idx) and idx.dtype.kind in {"i", "u"}
         ):
             return self._compliant_series[idx]
         return self._from_compliant_series(
diff --git a/narwhals/series_str.py b/narwhals/series_str.py
index 0851cbc7..873feda1 100644
--- a/narwhals/series_str.py
+++ b/narwhals/series_str.py
@@ -24,55 +24,17 @@ class SeriesStringNamespace(Generic[SeriesT]):
             A new Series containing the length of each string in characters.
 
         Examples:
-            >>> import pandas as pd
             >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["foo", "Café", "345", "東京", None]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_len_chars(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.len_chars().to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_len_chars`:
-
-            >>> agnostic_len_chars(s_pd)
-            0    3.0
-            1    4.0
-            2    3.0
-            3    2.0
-            4    NaN
-            dtype: float64
-
-            >>> agnostic_len_chars(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (5,)
+            >>> s_native = pl.Series(["foo", "345", None])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.len_chars().to_native()  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
             Series: '' [u32]
             [
-               3
-               4
-               3
-               2
-               null
-            ]
-
-            >>> agnostic_len_chars(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                3,
-                4,
-                3,
-                2,
-                null
-              ]
+                    3
+                    3
+                    null
             ]
         """
         return self._narwhals_series._from_compliant_series(
@@ -95,47 +57,13 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["123abc", "abc abc123"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_replace(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     s = s.str.replace("abc", "")
-            ...     return s.to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_replace`:
-
-            >>> agnostic_replace(s_pd)
+            >>> s_native = pd.Series(["123abc", "abc abc123"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.replace("abc", "").to_native()
             0        123
             1     abc123
             dtype: object
-
-            >>> agnostic_replace(s_pl)  # doctest:+NORMALIZE_WHITESPACE
-            shape: (2,)
-            Series: '' [str]
-            [
-                "123"
-                " abc123"
-            ]
-
-            >>> agnostic_replace(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "123",
-                " abc123"
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.replace(
@@ -158,47 +86,13 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["123abc", "abc abc123"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_replace_all(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     s = s.str.replace_all("abc", "")
-            ...     return s.to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_replace_all`:
-
-            >>> agnostic_replace_all(s_pd)
+            >>> s_native = pd.Series(["123abc", "abc abc123"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.replace_all("abc", "").to_native()
             0     123
             1     123
             dtype: object
-
-            >>> agnostic_replace_all(s_pl)  # doctest:+NORMALIZE_WHITESPACE
-            shape: (2,)
-            Series: '' [str]
-            [
-                "123"
-                " 123"
-            ]
-
-            >>> agnostic_replace_all(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "123",
-                " 123"
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.replace_all(
@@ -216,47 +110,16 @@ class SeriesStringNamespace(Generic[SeriesT]):
             A new Series with leading and trailing characters removed.
 
         Examples:
-            >>> import pandas as pd
             >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["apple", "\nmango"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_strip_chars(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     s = s.str.strip_chars()
-            ...     return s.to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_strip_chars`:
-
-            >>> agnostic_strip_chars(s_pd)
-            0    apple
-            1    mango
-            dtype: object
-
-            >>> agnostic_strip_chars(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> s_native = pl.Series(["apple", "\nmango"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.strip_chars().to_native()  # doctest: +NORMALIZE_WHITESPACE
             shape: (2,)
             Series: '' [str]
             [
-                "apple"
-                "mango"
-            ]
-
-            >>> agnostic_strip_chars(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "apple",
-                "mango"
-              ]
+                    "apple"
+                    "mango"
             ]
         """
         return self._narwhals_series._from_compliant_series(
@@ -274,49 +137,14 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["apple", "mango", None]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_starts_with(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.starts_with("app").to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_starts_with`:
-
-            >>> agnostic_starts_with(s_pd)
+            >>> s_native = pd.Series(["apple", "mango", None])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.starts_with("app").to_native()
             0     True
             1    False
             2     None
             dtype: object
-
-            >>> agnostic_starts_with(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (3,)
-            Series: '' [bool]
-            [
-               true
-               false
-               null
-            ]
-
-            >>> agnostic_starts_with(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                true,
-                false,
-                null
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.starts_with(prefix)
@@ -333,49 +161,14 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["apple", "mango", None]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_ends_with(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.ends_with("ngo").to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_ends_with`:
-
-            >>> agnostic_ends_with(s_pd)
+            >>> s_native = pd.Series(["apple", "mango", None])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.ends_with("ngo").to_native()
             0    False
             1     True
             2     None
             dtype: object
-
-            >>> agnostic_ends_with(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (3,)
-            Series: '' [bool]
-            [
-               false
-               true
-               null
-            ]
-
-            >>> agnostic_ends_with(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                false,
-                true,
-                null
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.ends_with(suffix)
@@ -393,54 +186,22 @@ class SeriesStringNamespace(Generic[SeriesT]):
             A new Series with boolean values indicating if each string contains the pattern.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["cat", "dog", "rabbit and parrot", "dove", None]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_contains(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.contains("parrot|dove").to_native()
-
-            We can then pass any supported library such as pandas, Polars, or PyArrow to `agnostic_contains`:
-
-            >>> agnostic_contains(s_pd)
-            0    False
-            1    False
-            2     True
-            3     True
-            4     None
-            dtype: object
-
-            >>> agnostic_contains(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (5,)
-            Series: '' [bool]
-            [
-               false
-               false
-               true
-               true
-               null
-            ]
-
-            >>> agnostic_contains(s_pa)  # doctest: +ELLIPSIS
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> s_native = pa.chunked_array([["cat", "dog", "rabbit and parrot"]])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.contains(
+            ...     "cat|parrot"
+            ... ).to_native()  # doctest: +ELLIPSIS  +NORMALIZE_WHITESPACE
             <pyarrow.lib.ChunkedArray object at ...>
             [
-              [
-                false,
-                false,
-                true,
+            [
                 true,
-                null
-              ]
+                false,
+                true
+            ]
             ]
         """
         return self._narwhals_series._from_compliant_series(
@@ -460,86 +221,14 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["pear", None, "papaya", "dragonfruit"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_slice(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.slice(4, length=3).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_slice`:
-
-            >>> agnostic_slice(s_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> s_native = pd.Series(["pear", None, "papaya"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.slice(4, 3).to_native()  # doctest: +NORMALIZE_WHITESPACE
             0
             1    None
             2      ya
-            3     onf
-            dtype: object
-
-            >>> agnostic_slice(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (4,)
-            Series: '' [str]
-            [
-               ""
-               null
-               "ya"
-               "onf"
-            ]
-
-            >>> agnostic_slice(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "",
-                null,
-                "ya",
-                "onf"
-              ]
-            ]
-
-            Using negative indexes:
-
-            >>> def agnostic_slice(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.slice(-3).to_native()
-
-            >>> agnostic_slice(s_pd)  # doctest: +NORMALIZE_WHITESPACE
-            0     ear
-            1    None
-            2     aya
-            3     uit
             dtype: object
-
-            >>> agnostic_slice(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (4,)
-            Series: '' [str]
-            [
-                "ear"
-                null
-                "aya"
-                "uit"
-            ]
-
-            >>> agnostic_slice(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "ear",
-                null,
-                "aya",
-                "uit"
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.slice(
@@ -562,52 +251,18 @@ class SeriesStringNamespace(Generic[SeriesT]):
             2. If the length of the string has fewer than `n` characters, the full string is returned.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["Atatata", "taata", "taatatata", "zukkyun"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_head(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.head().to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_head`:
-
-            >>> agnostic_head(s_pd)
-            0    Atata
-            1    taata
-            2    taata
-            3    zukky
-            dtype: object
-
-            >>> agnostic_head(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (4,)
-            Series: '' [str]
-            [
-               "Atata"
-               "taata"
-               "taata"
-               "zukky"
-            ]
-
-            >>> agnostic_head(s_pa)  # doctest: +ELLIPSIS
+            >>> s_native = pa.chunked_array([["taata", "taatatata", "zukkyun"]])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.head().to_native()  # doctest: +ELLIPSIS  +NORMALIZE_WHITESPACE
             <pyarrow.lib.ChunkedArray object at ...>
             [
-              [
-                "Atata",
+            [
                 "taata",
                 "taata",
                 "zukky"
-              ]
+            ]
             ]
         """
         return self._narwhals_series._from_compliant_series(
@@ -629,52 +284,18 @@ class SeriesStringNamespace(Generic[SeriesT]):
             2. If the length of the string has fewer than `n` characters, the full string is returned.
 
         Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["Atatata", "taata", "taatatata", "zukkyun"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_tail(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.tail().to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_tail`:
-
-            >>> agnostic_tail(s_pd)
-            0    atata
-            1    taata
-            2    atata
-            3    kkyun
-            dtype: object
-
-            >>> agnostic_tail(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (4,)
-            Series: '' [str]
-            [
-               "atata"
-               "taata"
-               "atata"
-               "kkyun"
-            ]
-
-            >>> agnostic_tail(s_pa)  # doctest: +ELLIPSIS
+            >>> s_native = pa.chunked_array([["taata", "taatatata", "zukkyun"]])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.tail().to_native()  # doctest: +ELLIPSIS  +NORMALIZE_WHITESPACE
             <pyarrow.lib.ChunkedArray object at ...>
             [
-              [
-                "atata",
+            [
                 "taata",
                 "atata",
                 "kkyun"
-              ]
+            ]
             ]
         """
         return self._narwhals_series._from_compliant_series(
@@ -694,49 +315,13 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["apple", "mango", None]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_to_uppercase(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.to_uppercase().to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_to_uppercase`:
-
-            >>> agnostic_to_uppercase(s_pd)
+            >>> s_native = pd.Series(["apple", None])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.to_uppercase().to_native()
             0    APPLE
-            1    MANGO
-            2     None
+            1     None
             dtype: object
-
-            >>> agnostic_to_uppercase(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (3,)
-            Series: '' [str]
-            [
-               "APPLE"
-               "MANGO"
-               null
-            ]
-
-            >>> agnostic_to_uppercase(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "APPLE",
-                "MANGO",
-                null
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.to_uppercase()
@@ -750,49 +335,13 @@ class SeriesStringNamespace(Generic[SeriesT]):
 
         Examples:
             >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["APPLE", "MANGO", None]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_to_lowercase(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.to_lowercase().to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_to_lowercase`:
-
-            >>> agnostic_to_lowercase(s_pd)
+            >>> s_native = pd.Series(["APPLE", None])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.to_lowercase().to_native()
             0    apple
-            1    mango
-            2     None
+            1     None
             dtype: object
-
-            >>> agnostic_to_lowercase(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (3,)
-            Series: '' [str]
-            [
-               "apple"
-               "mango"
-               null
-            ]
-
-            >>> agnostic_to_lowercase(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at ...>
-            [
-              [
-                "apple",
-                "mango",
-                null
-              ]
-            ]
         """
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.str.to_lowercase()
@@ -819,46 +368,18 @@ class SeriesStringNamespace(Generic[SeriesT]):
             A new Series with datetime dtype.
 
         Examples:
-            >>> import pandas as pd
             >>> import polars as pl
-            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoSeriesT
-
-            >>> data = ["2020-01-01", "2020-01-02"]
-            >>> s_pd = pd.Series(data)
-            >>> s_pl = pl.Series(data)
-            >>> s_pa = pa.chunked_array([data])
-
-            We define a dataframe-agnostic function:
-
-            >>> def agnostic_to_datetime(s_native: IntoSeriesT) -> IntoSeriesT:
-            ...     s = nw.from_native(s_native, series_only=True)
-            ...     return s.str.to_datetime(format="%Y-%m-%d").to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_to_datetime`:
-
-            >>> agnostic_to_datetime(s_pd)
-            0   2020-01-01
-            1   2020-01-02
-            dtype: datetime64[ns]
-
-            >>> agnostic_to_datetime(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> s_native = pl.Series(["2020-01-01", "2020-01-02"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.to_datetime(
+            ...     format="%Y-%m-%d"
+            ... ).to_native()  # doctest: +NORMALIZE_WHITESPACE
             shape: (2,)
             Series: '' [datetime[μs]]
             [
-               2020-01-01 00:00:00
-               2020-01-02 00:00:00
-            ]
-
-            >>> agnostic_to_datetime(s_pa)  # doctest: +ELLIPSIS
-            <pyarrow.lib.ChunkedArray object at 0x...>
-            [
-              [
-                2020-01-01 00:00:00.000000,
-                2020-01-02 00:00:00.000000
-              ]
+                    2020-01-01 00:00:00
+                    2020-01-02 00:00:00
             ]
         """
         return self._narwhals_series._from_compliant_series(
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 1001a7a7..3d001af3 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -968,7 +968,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).head(n),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def tail(self: Self, n: int = 10) -> Self:
@@ -982,7 +982,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).tail(n),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
@@ -997,7 +997,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def unique(self: Self, *, maintain_order: bool | None = None) -> Self:
@@ -1036,7 +1036,7 @@ class Expr(NwExpr):
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
             ),
-            self._metadata.with_order_dependence(),
+            self._metadata.with_extra_open_window(),
         )
 
     def arg_true(self: Self) -> Self:
@@ -1047,7 +1047,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_true(),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind_and_extra_open_window(ExprKind.CHANGES_LENGTH),
         )
 
     def sample(
@@ -1081,7 +1081,7 @@ class Expr(NwExpr):
             lambda plx: self._to_compliant_expr(plx).sample(
                 n, fraction=fraction, with_replacement=with_replacement, seed=seed
             ),
-            self._metadata.with_kind_and_order_dependence(ExprKind.CHANGES_LENGTH),
+            self._metadata.with_kind(ExprKind.CHANGES_LENGTH),
         )
 
 
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 5dd7c839..e545c272 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -738,6 +738,7 @@ def _from_native_impl(  # noqa: PLR0915
                 backend_version=parse_version(get_pyspark()),
                 version=version,
                 implementation=Implementation.PYSPARK,
+                validate_column_names=True,
             ),
             level="lazy",
         )
@@ -760,6 +761,7 @@ def _from_native_impl(  # noqa: PLR0915
                 backend_version=backend_version,
                 version=version,
                 implementation=Implementation.SQLFRAME,
+                validate_column_names=True,
             ),
             level="lazy",
         )
diff --git a/narwhals/typing.py b/narwhals/typing.py
index f1b62179..fe04c470 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -132,6 +132,10 @@ class SupportsNativeNamespace(Protocol):
     def __native_namespace__(self) -> ModuleType: ...
 
 
+IntoCompliantExpr: TypeAlias = (
+    "CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co] | CompliantSeriesT_co"
+)
+
 IntoExpr: TypeAlias = Union["Expr", str, "Series[Any]"]
 """Anything which can be converted to an expression.
 
@@ -321,14 +325,6 @@ class DTypes:
     Unknown: type[dtypes.Unknown]
 
 
-if TYPE_CHECKING:
-    # This one needs to be in TYPE_CHECKING to pass on 3.9,
-    # and can only be defined after CompliantExpr has been defined
-    IntoCompliantExpr: TypeAlias = (
-        CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co] | CompliantSeriesT_co
-    )
-
-
 __all__ = [
     "CompliantDataFrame",
     "CompliantLazyFrame",
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 0babd544..da8eadb8 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -26,6 +26,7 @@ from narwhals.dependencies import get_pandas
 from narwhals.dependencies import get_polars
 from narwhals.dependencies import get_pyarrow
 from narwhals.dependencies import get_pyspark_sql
+from narwhals.dependencies import get_sqlframe
 from narwhals.dependencies import is_cudf_series
 from narwhals.dependencies import is_modin_series
 from narwhals.dependencies import is_pandas_dataframe
@@ -157,6 +158,7 @@ class Implementation(Enum):
             get_dask_dataframe(): Implementation.DASK,
             get_duckdb(): Implementation.DUCKDB,
             get_ibis(): Implementation.IBIS,
+            get_sqlframe(): Implementation.SQLFRAME,
         }
         return mapping.get(native_namespace, Implementation.UNKNOWN)
 
@@ -182,6 +184,7 @@ class Implementation(Enum):
             "dask": Implementation.DASK,
             "duckdb": Implementation.DUCKDB,
             "ibis": Implementation.IBIS,
+            "sqlframe": Implementation.SQLFRAME,
         }
         return mapping.get(backend_name, Implementation.UNKNOWN)
 
@@ -244,6 +247,12 @@ class Implementation(Enum):
             import duckdb  # ignore-banned-import
 
             return duckdb
+
+        if self is Implementation.SQLFRAME:
+            import sqlframe  # ignore-banned-import
+
+            return sqlframe
+
         msg = "Not supported Implementation"  # pragma: no cover
         raise AssertionError(msg)
 
@@ -283,6 +292,22 @@ class Implementation(Enum):
             Implementation.CUDF,
         }
 
+    def is_spark_like(self: Self) -> bool:
+        """Return whether implementation is pyspark or sqlframe.
+
+        Returns:
+            Boolean.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import narwhals as nw
+            >>> df_native = pd.DataFrame({"a": [1, 2, 3]})
+            >>> df = nw.from_native(df_native)
+            >>> df.implementation.is_spark_like()
+            False
+        """
+        return self in {Implementation.PYSPARK, Implementation.SQLFRAME}
+
     def is_polars(self: Self) -> bool:
         """Return whether implementation is Polars.
 
@@ -411,6 +436,22 @@ class Implementation(Enum):
         """
         return self is Implementation.IBIS  # pragma: no cover
 
+    def is_sqlframe(self: Self) -> bool:
+        """Return whether implementation is SQLFrame.
+
+        Returns:
+            Boolean.
+
+        Examples:
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> df_native = pl.DataFrame({"a": [1, 2, 3]})
+            >>> df = nw.from_native(df_native)
+            >>> df.implementation.is_sqlframe()
+            False
+        """
+        return self is Implementation.SQLFRAME  # pragma: no cover
+
 
 MIN_VERSIONS: dict[Implementation, tuple[int, ...]] = {
     Implementation.PANDAS: (0, 25, 3),
@@ -422,7 +463,7 @@ MIN_VERSIONS: dict[Implementation, tuple[int, ...]] = {
     Implementation.DASK: (2024, 8),
     Implementation.DUCKDB: (1,),
     Implementation.IBIS: (6,),
-    Implementation.SQLFRAME: (3, 14, 2),
+    Implementation.SQLFRAME: (3, 22, 0),
 }
 
 
@@ -613,8 +654,8 @@ def maybe_align_index(
             msg = "given index doesn't have a unique index"
             raise ValueError(msg)
 
-    lhs_any = cast(Any, lhs)
-    rhs_any = cast(Any, rhs)
+    lhs_any = cast("Any", lhs)
+    rhs_any = cast("Any", rhs)
     if isinstance(
         getattr(lhs_any, "_compliant_frame", None), PandasLikeDataFrame
     ) and isinstance(getattr(rhs_any, "_compliant_frame", None), PandasLikeDataFrame):
@@ -698,7 +739,7 @@ def maybe_get_index(obj: DataFrame[Any] | LazyFrame[Any] | Series[Any]) -> Any |
         >>> nw.maybe_get_index(series)
         RangeIndex(start=0, stop=2, step=1)
     """
-    obj_any = cast(Any, obj)
+    obj_any = cast("Any", obj)
     native_obj = obj_any.to_native()
     if is_pandas_like_dataframe(native_obj) or is_pandas_like_series(native_obj):
         return native_obj.index
@@ -754,7 +795,7 @@ def maybe_set_index(
     """
     from narwhals.translate import to_native
 
-    df_any = cast(Any, obj)
+    df_any = cast("Any", obj)
     native_obj = df_any.to_native()
 
     if column_names is not None and index is not None:
@@ -829,7 +870,7 @@ def maybe_reset_index(obj: FrameOrSeriesT) -> FrameOrSeriesT:
         >>> nw.maybe_get_index(series)
         RangeIndex(start=0, stop=2, step=1)
     """
-    obj_any = cast(Any, obj)
+    obj_any = cast("Any", obj)
     native_obj = obj_any.to_native()
     if is_pandas_like_dataframe(native_obj):
         native_namespace = obj_any.__native_namespace__()
@@ -903,7 +944,7 @@ def maybe_convert_dtypes(
         b           boolean
         dtype: object
     """
-    obj_any = cast(Any, obj)
+    obj_any = cast("Any", obj)
     native_obj = obj_any.to_native()
     if is_pandas_like_dataframe(native_obj):
         return obj_any._from_compliant_dataframe(
@@ -1139,8 +1180,8 @@ def issue_deprecation_warning(message: str, _version: str) -> None:
 
 
 def validate_strict_and_pass_though(
-    strict: bool | None,
-    pass_through: bool | None,
+    strict: bool | None,  # noqa: FBT001
+    pass_through: bool | None,  # noqa: FBT001
     *,
     pass_through_default: bool,
     emit_deprecation_warning: bool,
diff --git a/pyproject.toml b/pyproject.toml
index dd4244f4..8a5dc190 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,7 @@ polars = ["polars>=0.20.3"]
 dask = ["dask[dataframe]>=2024.8"]
 duckdb = ["duckdb>=1.0"]
 ibis = ["ibis-framework>=6.0.0", "rich", "packaging", "pyarrow_hotfix"]
+sqlframe = ["sqlframe>=3.22.0"]
 tests = [
   "covdefaults",
   "pytest",
@@ -106,11 +107,26 @@ exclude = [
 line-length = 90
 fix = true
 target-version = "py38"
+extend-exclude = ["utils/bump_version.py", "**/this.py"]
 
-lint.select = [
+[tool.ruff.lint]
+preview = true
+explicit-preview-rules = true
+
+extend-safe-fixes = [
+  "C419",
+  "C420",
+  "PLR6201",
+  "TC",
+  "UP",
+]
+extend-select = [
+  "PLR6201",
+]
+select = [
   "ALL",
 ]
-lint.ignore = [
+ignore = [
   "A001",
   "A004",
   "A005",
@@ -143,6 +159,7 @@ lint.ignore = [
 ]
 
 [tool.ruff.lint.per-file-ignores]
+"narwhals/expr_dt.py" = ["RUF002"]
 "tests/*" = ["S101"]
 "tpch/tests/*" = ["S101"]
 "utils/*" = ["S311", "PTH123"]
@@ -185,7 +202,8 @@ filterwarnings = [
   'ignore:.*is_datetime64tz_dtype is deprecated and will be removed in a future version.*:DeprecationWarning:pyspark',
   # Warning raised by PyArrow nightly just by importing pandas
   'ignore:.*Python binding for RankQuantileOptions not exposed:RuntimeWarning:pyarrow',
-  'ignore:.*pandas only supports SQLAlchemy:UserWarning'
+  'ignore:.*pandas only supports SQLAlchemy:UserWarning',
+  'ignore:.*numpy.core is deprecated and has been renamed to numpy._core.*:DeprecationWarning:sqlframe',
 ]
 xfail_strict = true
 markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
@@ -251,6 +269,7 @@ module = [
   "*._pandas_like.*",
   "*._ibis.*",
   "*._arrow.*",
+  "*._dask.*",
 ]
 warn_return_any = false
 
@@ -261,7 +280,7 @@ pythonPlatform = "All"
 reportMissingImports = "none"
 reportMissingModuleSource = "none"
 reportPrivateImportUsage = "none"
-reportUnusedExpression = "none"    # can enforce with `ruff`
+reportUnusedExpression = "none"    # handled by (https://docs.astral.sh/ruff/rules/unused-variable/)
 typeCheckingMode = "basic"
 include = ["narwhals", "tests"]
 ignore = [


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 3042f630..6bb570e5 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -53,12 +53,12 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
         # we are not testing pyspark on Windows here because it is very slow
-        run: uv pip install -e ".[tests, core, extra, dask, modin]" --system
+        run: uv pip install -e ".[tests, core, extra, dask, modin, sqlframe]" --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
         run: |
-          pytest tests --cov=narwhals --cov=tests --runslow --cov-fail-under=95 --constructors=pandas,pandas[nullable],pandas[pyarrow],pyarrow,modin[pyarrow],polars[eager],polars[lazy],dask,duckdb
+          pytest tests --cov=narwhals --cov=tests --runslow --cov-fail-under=95 --constructors=pandas,pandas[nullable],pandas[pyarrow],pyarrow,modin[pyarrow],polars[eager],polars[lazy],dask,duckdb,sqlframe
 
   pytest-full-coverage:
     strategy:
@@ -83,7 +83,7 @@ jobs:
           cache-suffix: ${{ matrix.python-version }}
           cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install -e ".[tests, core, extra, modin, dask]" --system
+        run: uv pip install -e ".[tests, core, extra, modin, dask, sqlframe]" --system
       - name: install pyspark
         run: uv pip install -e ".[pyspark]" --system
         # PySpark is not yet available on Python3.12+
diff --git a/tests/conftest.py b/tests/conftest.py
index 72aeb115..a0cde090 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -184,7 +184,7 @@ def pyspark_lazy_constructor() -> Callable[[Any], IntoFrame]:  # pragma: no cove
 
 def sqlframe_pyspark_lazy_constructor(
     obj: dict[str, Any],
-) -> Callable[[Any], IntoFrame]:  # pragma: no cover
+) -> IntoFrame:  # pragma: no cover
     from sqlframe.duckdb import DuckDBSession
 
     session = DuckDBSession()
@@ -208,9 +208,7 @@ LAZY_CONSTRUCTORS: dict[str, Callable[[Any], IntoFrame]] = {
     "polars[lazy]": polars_lazy_constructor,
     "duckdb": duckdb_lazy_constructor,
     "pyspark": pyspark_lazy_constructor,  # type: ignore[dict-item]
-    # We've reported several bugs to sqlframe - once they address
-    # them, we can start testing them as part of our CI.
-    # "sqlframe": sqlframe_pyspark_lazy_constructor,  # noqa: ERA001
+    "sqlframe": sqlframe_pyspark_lazy_constructor,
 }
 GPU_CONSTRUCTORS: dict[str, Callable[[Any], IntoFrame]] = {"cudf": cudf_constructor}
 
@@ -236,7 +234,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
 
     for constructor in selected_constructors:
         if (
-            constructor in ("pandas[nullable]", "pandas[pyarrow]")
+            constructor in {"pandas[nullable]", "pandas[pyarrow]"}
             and MIN_PANDAS_NULLABLE_VERSION > PANDAS_VERSION
         ):  # pragma: no cover
             continue
@@ -247,10 +245,9 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
             constructors_ids.append(constructor)
         elif constructor in LAZY_CONSTRUCTORS:
             if constructor == "pyspark":
-                if sys.version_info < (3, 12):  # pragma: no cover
-                    constructors.append(pyspark_lazy_constructor())
-                else:  # pragma: no cover
+                if sys.version_info >= (3, 12):  # pragma: no cover
                     continue
+                constructors.append(pyspark_lazy_constructor())  # pragma: no cover
             else:
                 constructors.append(LAZY_CONSTRUCTORS[constructor])
             constructors_ids.append(constructor)
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index 5ed8c1cc..1f512cde 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -54,7 +54,7 @@ SCHEMA = {
     "p": nw.Int64,
 }
 
-SPARK_INCOMPATIBLE_COLUMNS = {"e", "f", "g", "h", "l", "o", "p"}
+SPARK_LIKE_INCOMPATIBLE_COLUMNS = {"e", "f", "g", "h", "l", "o", "p"}
 DUCKDB_INCOMPATIBLE_COLUMNS = {"l", "o", "p"}
 
 
@@ -72,7 +72,7 @@ def test_cast(
         request.applymarker(pytest.mark.xfail)
 
     if "pyspark" in str(constructor):
-        incompatible_columns = SPARK_INCOMPATIBLE_COLUMNS  # pragma: no cover
+        incompatible_columns = SPARK_LIKE_INCOMPATIBLE_COLUMNS  # pragma: no cover
     elif "duckdb" in str(constructor):
         incompatible_columns = DUCKDB_INCOMPATIBLE_COLUMNS  # pragma: no cover
     else:
@@ -172,7 +172,7 @@ def test_cast_string() -> None:
     s = nw.from_native(s_pd, series_only=True)
     s = s.cast(nw.String)
     result = nw.to_native(s)
-    assert str(result.dtype) in ("string", "object", "dtype('O')")
+    assert str(result.dtype) in {"string", "object", "dtype('O')"}
 
 
 def test_cast_raises_for_unknown_dtype(
@@ -185,7 +185,7 @@ def test_cast_raises_for_unknown_dtype(
         request.applymarker(pytest.mark.xfail)
 
     if "pyspark" in str(constructor):
-        incompatible_columns = SPARK_INCOMPATIBLE_COLUMNS  # pragma: no cover
+        incompatible_columns = SPARK_LIKE_INCOMPATIBLE_COLUMNS  # pragma: no cover
     else:
         incompatible_columns = set()
 
@@ -236,7 +236,9 @@ def test_cast_datetime_tz_aware(
 
 
 def test_cast_struct(request: pytest.FixtureRequest, constructor: Constructor) -> None:
-    if any(backend in str(constructor) for backend in ("dask", "modin", "cudf")):
+    if any(
+        backend in str(constructor) for backend in ("dask", "modin", "cudf", "sqlframe")
+    ):
         request.applymarker(pytest.mark.xfail)
 
     if "pandas" in str(constructor) and PANDAS_VERSION < (2, 2):
@@ -254,8 +256,9 @@ def test_cast_struct(request: pytest.FixtureRequest, constructor: Constructor) -
     if "spark" in str(constructor):  # pragma: no cover
         # Special handling for pyspark as it natively maps the input to
         # a column of type MAP<STRING, STRING>
-        import pyspark.sql.functions as F  # noqa: N812
-        import pyspark.sql.types as T  # noqa: N812
+        _tmp_nw_compliant_frame = nw.from_native(native_df)._compliant_frame
+        F = _tmp_nw_compliant_frame._F  # noqa: N806
+        T = _tmp_nw_compliant_frame._native_dtypes  # noqa: N806
 
         native_df = native_df.withColumn(  # type: ignore[union-attr]
             "a",
diff --git a/tests/expr_and_series/is_nan_test.py b/tests/expr_and_series/is_nan_test.py
index d4065c22..237ec807 100644
--- a/tests/expr_and_series/is_nan_test.py
+++ b/tests/expr_and_series/is_nan_test.py
@@ -56,8 +56,7 @@ def test_nan(constructor: Constructor) -> None:
             NarwhalsError,
             match="NAN is not supported in a Non-floating point type column",
         )
-        if "polars_lazy" in str(constructor)
-        and os.environ.get("NARWHALS_POLARS_GPU", False)
+        if "polars_lazy" in str(constructor) and os.environ.get("NARWHALS_POLARS_GPU")
         else does_not_raise()
     )
     with context:
diff --git a/tests/expr_and_series/operators_test.py b/tests/expr_and_series/operators_test.py
index bc4e0550..5297040a 100644
--- a/tests/expr_and_series/operators_test.py
+++ b/tests/expr_and_series/operators_test.py
@@ -90,7 +90,7 @@ def test_logic_operators_expr_scalar(
     if (
         "dask" in str(constructor)
         and DASK_VERSION < (2024, 10)
-        and operator in ("__rand__", "__ror__")
+        and operator in {"__rand__", "__ror__"}
     ):
         request.applymarker(pytest.mark.xfail)
     data = {"a": [True, True, False, False]}
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index 65dba7f6..65d4f60e 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -268,5 +268,5 @@ def test_unsupported_over() -> None:
     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"):
+    with pytest.raises(NotImplementedError, match="aggregation or literal"):
         nw.from_native(tbl).select(nw.col("a").shift(1).cum_sum().over("b"))
diff --git a/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py b/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py
index 61566b56..28aaa713 100644
--- a/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py
+++ b/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py
@@ -32,19 +32,19 @@ def test_str_to_uppercase(
 ) -> None:
     if any("ß" in s for value in data.values() for s in value) & (
         constructor.__name__
-        in (
+        in {
             "pandas_pyarrow_constructor",
             "pyarrow_table_constructor",
             "modin_pyarrow_constructor",
             "duckdb_lazy_constructor",
-        )
+            "sqlframe_pyspark_lazy_constructor",
+        }
         or ("dask" in str(constructor) and PYARROW_VERSION >= (12,))
     ):
         # We are marking it xfail for these conditions above
         # since the pyarrow backend will convert
         # smaller cap 'ß' to upper cap 'ẞ' instead of 'SS'
         request.applymarker(pytest.mark.xfail)
-
     df = nw.from_native(constructor(data))
     result_frame = df.select(nw.col("a").str.to_uppercase())
 
@@ -76,14 +76,14 @@ def test_str_to_uppercase_series(
 
     if any("ß" in s for value in data.values() for s in value) & (
         constructor_eager.__name__
-        not in (
+        not in {
             "pandas_constructor",
             "pandas_nullable_constructor",
             "polars_eager_constructor",
             "cudf_constructor",
             "duckdb_lazy_constructor",
             "modin_constructor",
-        )
+        }
     ):
         # We are marking it xfail for these conditions above
         # since the pyarrow backend will convert
diff --git a/tests/expr_and_series/unary_test.py b/tests/expr_and_series/unary_test.py
index 280ace82..40c9a49e 100644
--- a/tests/expr_and_series/unary_test.py
+++ b/tests/expr_and_series/unary_test.py
@@ -77,7 +77,11 @@ def test_unary_series(constructor_eager: ConstructorEager) -> None:
     assert_equal_data(result, expected)
 
 
-def test_unary_two_elements(constructor: Constructor) -> None:
+def test_unary_two_elements(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if "sqlframe" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
     data = {"a": [1, 2], "b": [2, 10], "c": [2.0, None]}
     result = nw.from_native(constructor(data)).select(
         a_nunique=nw.col("a").n_unique(),
@@ -123,7 +127,7 @@ def test_unary_two_elements_series(constructor_eager: ConstructorEager) -> None:
 def test_unary_one_element(
     constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
-    if "pyspark" in str(constructor):
+    if "pyspark" in str(constructor) and "sqlframe" not in str(constructor):
         request.applymarker(pytest.mark.xfail)
     data = {"a": [1], "b": [2], "c": [None]}
     # Dask runs into a divide by zero RuntimeWarning for 1 element skew.
diff --git a/tests/expression_parsing_test.py b/tests/expression_parsing_test.py
new file mode 100644
index 00000000..b540a6fa
--- /dev/null
+++ b/tests/expression_parsing_test.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+import pytest
+
+import narwhals as nw
+
+
+@pytest.mark.parametrize(
+    ("expr", "expected"),
+    [
+        (nw.col("a"), 0),
+        (nw.col("a").mean(), 0),
+        (nw.col("a").cum_sum(), 1),
+        (nw.col("a").cum_sum().over(_order_by="id"), 0),
+        ((nw.col("a").cum_sum() + 1).over(_order_by="id"), 1),
+        (nw.col("a").cum_sum().cum_sum().over(_order_by="id"), 1),
+        (nw.col("a").cum_sum().cum_sum(), 2),
+        (nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()), 1),
+        (nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()).over("a"), 1),
+    ],
+)
+def test_has_open_windows(expr: nw.Expr, expected: int) -> None:
+    assert expr._metadata.n_open_windows == expected
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index 71058638..41a9823d 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -669,14 +669,14 @@ def test_joinasof_by_exceptions(constructor: Constructor) -> None:
 def test_join_duplicate_column_names(
     constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
-    if "polars" in str(constructor):
-        # https://github.com/pola-rs/polars/issues/21048
-        request.applymarker(pytest.mark.xfail)
-    if "cudf" in str(constructor):
+    if (
+        "polars" in str(constructor)  # https://github.com/pola-rs/polars/issues/21048
+        or "cudf" in str(constructor)
         # TODO(unassigned): cudf doesn't raise here for some reason,
         # need to investigate.
+    ):
         request.applymarker(pytest.mark.xfail)
-    if "pyspark" in str(constructor):
+    if "pyspark" in str(constructor) and "sqlframe" not in str(constructor):
         from pyspark.errors import AnalysisException
 
         exception = AnalysisException
diff --git a/tests/frame/select_test.py b/tests/frame/select_test.py
index ee46a4a6..3ba2110a 100644
--- a/tests/frame/select_test.py
+++ b/tests/frame/select_test.py
@@ -30,7 +30,7 @@ def test_select(constructor: Constructor) -> None:
 
 
 def test_empty_select(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if "duckdb" in str(constructor):
+    if "duckdb" in str(constructor) or "sqlframe" in str(constructor):
         request.applymarker(pytest.mark.xfail)
     result = nw.from_native(constructor({"a": [1, 2, 3]})).lazy().select()
     assert result.collect().shape == (0, 0)
@@ -151,3 +151,10 @@ def test_changes_length_vs_aggregation(constructor_eager: ConstructorEager) -> N
     result = df.select(nw.sum_horizontal(nw.col("a").drop_nulls(), nw.col("a").mean()))
     expected = {"a": [3.0, 5.0]}
     assert_equal_data(result, expected)
+
+
+def test_binary_window_aggregation(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(constructor_eager({"a": [1, 1, 2]}))
+    result = df.select(nw.col("a").cum_sum() + nw.col("a").sum())
+    expected = {"a": [5, 6, 8]}
+    assert_equal_data(result, expected)
diff --git a/tests/frame/unique_test.py b/tests/frame/unique_test.py
index 8e1b71de..61d64200 100644
--- a/tests/frame/unique_test.py
+++ b/tests/frame/unique_test.py
@@ -39,9 +39,7 @@ def test_unique(
         "last",
     }:
         context: Any = pytest.raises(ValueError, match="row order")
-    elif (
-        keep == "none" and df.implementation is nw.Implementation.PYSPARK
-    ):  # pragma: no cover
+    elif keep == "none" and df.implementation.is_spark_like():  # pragma: no cover
         context = pytest.raises(
             ValueError,
             match="`LazyFrame.unique` with PySpark backend only supports `keep='any'`.",
diff --git a/tests/frame/unpivot_test.py b/tests/frame/unpivot_test.py
index cb6c07f0..706defa9 100644
--- a/tests/frame/unpivot_test.py
+++ b/tests/frame/unpivot_test.py
@@ -20,30 +20,58 @@ data = {
     "c": [2, 4, 6],
 }
 
-expected_b_only = {
+expected_on_b_idx_a = {
     "a": ["x", "y", "z"],
     "variable": ["b", "b", "b"],
     "value": [1, 3, 5],
 }
 
-expected_b_c = {
+expected_on_b_c_idx_a = {
     "a": ["x", "y", "z", "x", "y", "z"],
     "variable": ["b", "b", "b", "c", "c", "c"],
     "value": [1, 3, 5, 2, 4, 6],
 }
 
+expected_on_none_idx_a = {
+    "a": ["x", "y", "z", "x", "y", "z"],
+    "variable": ["b", "b", "b", "c", "c", "c"],
+    "value": [1, 3, 5, 2, 4, 6],
+}
+
+expected_on_b_c_idx_none = {
+    "variable": ["b", "b", "b", "c", "c", "c"],
+    "value": [1, 3, 5, 2, 4, 6],
+}
+
+expected_on_none_idx_none = {
+    "variable": ["a", "a", "a", "b", "b", "b", "c", "c", "c"],
+    "value": ["x", "y", "z", "1", "3", "5", "2", "4", "6"],
+}
+
 
 @pytest.mark.parametrize(
-    ("on", "expected"),
-    [("b", expected_b_only), (["b", "c"], expected_b_c), (None, expected_b_c)],
+    ("on", "index", "expected"),
+    [
+        ("b", ["a"], expected_on_b_idx_a),
+        (["b", "c"], ["a"], expected_on_b_c_idx_a),
+        (None, ["a"], expected_on_none_idx_a),
+        (["b", "c"], None, expected_on_b_c_idx_none),
+        (None, None, expected_on_none_idx_none),
+    ],
 )
-def test_unpivot_on(
+def test_unpivot(
     constructor: Constructor,
     on: str | list[str] | None,
+    index: list[str] | None,
     expected: dict[str, list[float]],
+    request: pytest.FixtureRequest,
 ) -> None:
+    if on is None and index is None and "polars" not in str(constructor):
+        # TODO(2082): add support in other backends
+        request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor(data))
-    result = df.unpivot(on=on, index=["a"]).sort("variable", "a")
+    sort_columns = ["variable"] if index is None else ["variable", "a"]
+    result = df.unpivot(on=on, index=index).sort(by=sort_columns)
     assert_equal_data(result, expected)
 
 
@@ -62,7 +90,15 @@ def test_unpivot_var_value_names(
 ) -> None:
     context = (
         pytest.raises(NotImplementedError)
-        if ("duckdb" in str(constructor) and any([variable_name == "", value_name == ""]))
+        if (
+            any([variable_name == "", value_name == ""])
+            and (
+                "duckdb" in str(constructor)
+                # This might depend from the dialect we use in sqlframe.
+                # Since for now we use only duckdb, we need to xfail it
+                or "sqlframe" in str(constructor)
+            )
+        )
         else does_not_raise()
     )
 
diff --git a/tests/selectors_test.py b/tests/selectors_test.py
index c7703c5d..b9e06f7e 100644
--- a/tests/selectors_test.py
+++ b/tests/selectors_test.py
@@ -221,7 +221,7 @@ def test_set_ops(
     expected: list[str],
     request: pytest.FixtureRequest,
 ) -> None:
-    if "duckdb" in str(constructor) and not expected:
+    if ("duckdb" in str(constructor) or "sqlframe" in str(constructor)) and not expected:
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor(data))
     result = df.select(selector).collect_schema().names()
diff --git a/tests/stable_api_test.py b/tests/stable_api_test.py
index ad341689..071065e6 100644
--- a/tests/stable_api_test.py
+++ b/tests/stable_api_test.py
@@ -93,7 +93,7 @@ def test_stable_api_docstrings() -> None:
     for item in main_namespace_api:
         if getattr(nw, item).__doc__ is None:
             continue
-        if item in ("from_native", "narwhalify"):
+        if item in {"from_native", "narwhalify"}:
             # `eager_or_interchange` param was removed from main namespace,
             # but is still present in v1 docstring.
             continue
@@ -119,10 +119,10 @@ def test_lazyframe_docstrings() -> None:
     df = nw.from_native(pl.LazyFrame())
     api = [i for i in df.__dir__() if not i.startswith("_")]
     for item in api:
-        if item in ("schema", "columns"):
+        if item in {"schema", "columns"}:
             # to avoid performance warning
             continue
-        if item in ("tail",):
+        if item in {"tail"}:
             # deprecated
             continue
         assert remove_docstring_examples(
diff --git a/tests/utils.py b/tests/utils.py
index 295729c3..65633e85 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -99,9 +99,9 @@ def assert_equal_data(result: Any, expected: Mapping[str, Any]) -> None:
     if hasattr(result, "collect"):
         kwargs: dict[Implementation, dict[str, Any]] = {Implementation.POLARS: {}}
 
-        if os.environ.get("NARWHALS_POLARS_GPU", False):  # pragma: no cover
+        if os.environ.get("NARWHALS_POLARS_GPU", None):  # pragma: no cover
             kwargs[Implementation.POLARS].update({"engine": "gpu"})
-        if os.environ.get("NARWHALS_POLARS_NEW_STREAMING", False):  # pragma: no cover
+        if os.environ.get("NARWHALS_POLARS_NEW_STREAMING", None):  # pragma: no cover
             kwargs[Implementation.POLARS].update({"new_streaming": True})
 
         result = result.collect(**kwargs.get(result.implementation, {}))
@@ -154,4 +154,4 @@ def maybe_get_modin_df(df_pandas: pd.DataFrame) -> Any:
 
 def is_windows() -> bool:
     """Check if the current platform is Windows."""
-    return sys.platform in ["win32", "cygwin"]
+    return sys.platform in {"win32", "cygwin"}