Repository: narwhals-dev/narwhals
Pull request #2066 (2025-02-22 13:58:42)

chore(typing): add `CompliantSeries.dtype`
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [x] 🐳 Other

## Related issues

- Related #2060
- https://github.com/narwhals-dev/narwhals/issues/2044#issuecomment-2674262833

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below
Will simplify a workaround I've done in https://github.com/narwhals-dev/narwhals/pull/2064#discussion_r1966497077

<details><summary>Code block</summary>
<p>

https://github.com/narwhals-dev/narwhals/blob/3c5ea87d268dbb03633232e289e61c4f4c837543/narwhals/_selectors.py#L45-L51

</p>
</details> 

The property is available on all `CompliantSeries` already, but not visible to a type checker without this




Patch:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index d3378ea8..bd44a6e5 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class ArrowExpr(CompliantExpr[ArrowSeries]):
+class ArrowExpr(CompliantExpr["ArrowDataFrame", ArrowSeries]):
     _implementation: Implementation = Implementation.PYARROW
 
     def __init__(
@@ -409,7 +409,13 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
             self, "clip", lower_bound=lower_bound, upper_bound=upper_bound
         )
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
+        if kind is ExprKind.TRANSFORM:
+            msg = (
+                "Elementwise operations in `over` context are not supported for PyArrow."
+            )
+            raise NotImplementedError(msg)
+
         def func(df: ArrowDataFrame) -> list[ArrowSeries]:
             output_names, aliases = evaluate_output_names_and_aliases(self, df, [])
             if overlap := set(output_names).intersection(keys):
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index f11e69af..4ae03060 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -40,7 +40,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class ArrowNamespace(CompliantNamespace[ArrowSeries]):
+class ArrowNamespace(CompliantNamespace[ArrowDataFrame, ArrowSeries]):
     def _create_expr_from_callable(
         self: Self,
         func: Callable[[ArrowDataFrame], Sequence[ArrowSeries]],
diff --git a/narwhals/_arrow/series_dt.py b/narwhals/_arrow/series_dt.py
index 3ba67752..6b45a0fa 100644
--- a/narwhals/_arrow/series_dt.py
+++ b/narwhals/_arrow/series_dt.py
@@ -9,7 +9,6 @@ import pyarrow.compute as pc
 from narwhals._arrow.utils import floordiv_compat
 from narwhals._arrow.utils import lit
 from narwhals.utils import import_dtypes_module
-from narwhals.utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -52,7 +51,7 @@ class ArrowSeriesDateTimeNamespace:
     def timestamp(self: Self, time_unit: TimeUnit) -> ArrowSeries:
         ser: ArrowSeries = self._compliant_series
         dtypes = import_dtypes_module(ser._version)
-        if isinstance_or_issubclass(ser.dtype, dtypes.Datetime):
+        if isinstance(ser.dtype, dtypes.Datetime):
             unit = ser.dtype.time_unit
             s_cast = ser._native_series.cast(pa.int64())
             if unit == "ns":
@@ -86,7 +85,7 @@ class ArrowSeriesDateTimeNamespace:
             else:  # pragma: no cover
                 msg = f"unexpected time unit {unit}, please report an issue at https://github.com/narwhals-dev/narwhals"
                 raise AssertionError(msg)
-        elif isinstance_or_issubclass(ser.dtype, dtypes.Date):
+        elif isinstance(ser.dtype, dtypes.Date):
             time_s = pc.multiply(ser._native_series.cast(pa.int32()), 86400)
             if time_unit == "ns":
                 result = cast("ArrowChunkedArray", pc.multiply(time_s, 1_000_000_000))
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index a0af9e64..5d765a3a 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["dx.Series"]):
+class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):
     _implementation: Implementation = Implementation.DASK
 
     def __init__(
@@ -535,7 +535,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             lambda _input: _input.isna().sum().to_series(), "null_count"
         )
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
         def func(df: DaskLazyFrame) -> list[Any]:
             output_names, aliases = evaluate_output_names_and_aliases(self, df, [])
             if overlap := set(output_names).intersection(keys):
diff --git a/narwhals/_dask/expr_dt.py b/narwhals/_dask/expr_dt.py
index 330933bc..c569d7dc 100644
--- a/narwhals/_dask/expr_dt.py
+++ b/narwhals/_dask/expr_dt.py
@@ -114,8 +114,8 @@ class DaskExprDateTimeNamespace:
                 # Date is only supported in pandas dtypes if pyarrow-backed
                 s_cast = s.astype("Int32[pyarrow]")
                 result = calculate_timestamp_date(s_cast, time_unit)
-            elif dtype == dtypes.Datetime:
-                original_time_unit = dtype.time_unit  # type: ignore[attr-defined]
+            elif isinstance(dtype, dtypes.Datetime):
+                original_time_unit = dtype.time_unit
                 s_cast = (
                     s.astype("Int64[pyarrow]") if is_pyarrow_dtype else s.astype("int64")
                 )
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index 39f79145..13b57796 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -107,7 +107,7 @@ class DaskLazyGroupBy:
 def agg_dask(
     df: DaskLazyFrame,
     grouped: Any,
-    exprs: Sequence[CompliantExpr[dx.Series]],
+    exprs: Sequence[CompliantExpr[DaskLazyFrame, dx.Series]],
     keys: list[str],
     from_dataframe: Callable[[Any], DaskLazyFrame],
 ) -> DaskLazyFrame:
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 1ef2d7e8..53ef7dbc 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
         import dask_expr as dx
 
 
-class DaskNamespace(CompliantNamespace["dx.Series"]):
+class DaskNamespace(CompliantNamespace[DaskLazyFrame, "dx.Series"]):
     @property
     def selectors(self: Self) -> DaskSelectorNamespace:
         return DaskSelectorNamespace(self)
diff --git a/narwhals/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index 0dc9aab6..45f61448 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class DuckDBExpr(CompliantExpr["duckdb.Expression"]):  # type: ignore[type-var]
+class DuckDBExpr(CompliantExpr["DuckDBLazyFrame", "duckdb.Expression"]):  # type: ignore[type-var]
     _implementation = Implementation.DUCKDB
     _depth = 0  # Unused, just for compatibility with CompliantExpr
 
diff --git a/narwhals/_duckdb/namespace.py b/narwhals/_duckdb/namespace.py
index 2f50a707..bc5de2fd 100644
--- a/narwhals/_duckdb/namespace.py
+++ b/narwhals/_duckdb/namespace.py
@@ -34,7 +34,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class DuckDBNamespace(CompliantNamespace["duckdb.Expression"]):  # type: ignore[type-var]
+class DuckDBNamespace(CompliantNamespace["DuckDBLazyFrame", "duckdb.Expression"]):  # type: ignore[type-var]
     def __init__(
         self: Self, *, backend_version: tuple[int, ...], version: Version
     ) -> None:
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index ba0c81f9..0e91c02d 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -12,7 +12,6 @@ from typing import Callable
 from typing import Sequence
 from typing import TypedDict
 from typing import TypeVar
-from typing import Union
 from typing import overload
 
 from narwhals.dependencies import is_narwhals_series
@@ -30,6 +29,7 @@ if TYPE_CHECKING:
     from narwhals.expr import Expr
     from narwhals.typing import CompliantDataFrame
     from narwhals.typing import CompliantExpr
+    from narwhals.typing import CompliantFrameT_contra
     from narwhals.typing import CompliantLazyFrame
     from narwhals.typing import CompliantNamespace
     from narwhals.typing import CompliantSeries
@@ -37,9 +37,6 @@ if TYPE_CHECKING:
     from narwhals.typing import IntoExpr
     from narwhals.typing import _1DArray
 
-    ArrowOrPandasLikeExpr = TypeVar(
-        "ArrowOrPandasLikeExpr", bound=Union[ArrowExpr, PandasLikeExpr]
-    )
     PandasLikeExprT = TypeVar("PandasLikeExprT", bound=PandasLikeExpr)
     ArrowExprT = TypeVar("ArrowExprT", bound=ArrowExpr)
 
@@ -54,8 +51,8 @@ def is_expr(obj: Any) -> TypeIs[Expr]:
 
 
 def evaluate_into_expr(
-    df: CompliantDataFrame | CompliantLazyFrame,
-    expr: CompliantExpr[CompliantSeriesT_co],
+    df: CompliantFrameT_contra,
+    expr: CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co],
 ) -> Sequence[CompliantSeriesT_co]:
     """Return list of raw columns.
 
@@ -75,7 +72,9 @@ def evaluate_into_expr(
 
 
 def evaluate_into_exprs(
-    df: CompliantDataFrame, /, *exprs: CompliantExpr[CompliantSeriesT_co]
+    df: CompliantFrameT_contra,
+    /,
+    *exprs: CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co],
 ) -> list[CompliantSeriesT_co]:
     """Evaluate each expr into Series."""
     return [
@@ -87,7 +86,8 @@ def evaluate_into_exprs(
 
 @overload
 def maybe_evaluate_expr(
-    df: CompliantDataFrame, expr: CompliantExpr[CompliantSeriesT_co]
+    df: CompliantFrameT_contra,
+    expr: CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co],
 ) -> CompliantSeriesT_co: ...
 
 
@@ -96,7 +96,7 @@ def maybe_evaluate_expr(df: CompliantDataFrame, expr: T) -> T: ...
 
 
 def maybe_evaluate_expr(
-    df: CompliantDataFrame, expr: CompliantExpr[CompliantSeriesT_co] | T
+    df: Any, expr: CompliantExpr[Any, CompliantSeriesT_co] | T
 ) -> CompliantSeriesT_co | T:
     """Evaluate `expr` if it's an expression, otherwise return it as is."""
     if is_compliant_expr(expr):
@@ -234,7 +234,7 @@ def reuse_series_namespace_implementation(
     )
 
 
-def is_simple_aggregation(expr: CompliantExpr[Any]) -> bool:
+def is_simple_aggregation(expr: CompliantExpr[Any, Any]) -> bool:
     """Check if expr is a very simple one.
 
     Examples:
@@ -252,24 +252,22 @@ def is_simple_aggregation(expr: CompliantExpr[Any]) -> bool:
 
 
 def combine_evaluate_output_names(
-    *exprs: CompliantExpr[Any],
-) -> Callable[[CompliantDataFrame | CompliantLazyFrame], Sequence[str]]:
+    *exprs: CompliantExpr[CompliantFrameT_contra, Any],
+) -> Callable[[CompliantFrameT_contra], Sequence[str]]:
     # Follow left-hand-rule for naming. E.g. `nw.sum_horizontal(expr1, expr2)` takes the
     # first name of `expr1`.
     if not is_compliant_expr(exprs[0]):  # pragma: no cover
         msg = f"Safety assertion failed, expected expression, got: {type(exprs[0])}. Please report a bug."
         raise AssertionError(msg)
 
-    def evaluate_output_names(
-        df: CompliantDataFrame | CompliantLazyFrame,
-    ) -> Sequence[str]:
+    def evaluate_output_names(df: CompliantFrameT_contra) -> Sequence[str]:
         return exprs[0]._evaluate_output_names(df)[:1]
 
     return evaluate_output_names
 
 
 def combine_alias_output_names(
-    *exprs: CompliantExpr[Any],
+    *exprs: CompliantExpr[Any, Any],
 ) -> Callable[[Sequence[str]], Sequence[str]] | None:
     # Follow left-hand-rule for naming. E.g. `nw.sum_horizontal(expr1.alias(alias), expr2)` takes the
     # aliasing function of `expr1` and apply it to the first output name of `expr1`.
@@ -283,11 +281,11 @@ def combine_alias_output_names(
 
 
 def extract_compliant(
-    plx: CompliantNamespace[CompliantSeriesT_co],
+    plx: CompliantNamespace[CompliantFrameT_contra, CompliantSeriesT_co],
     other: Any,
     *,
     str_as_lit: bool,
-) -> CompliantExpr[CompliantSeriesT_co] | object:
+) -> CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co] | object:
     if is_expr(other):
         return other._to_compliant_expr(plx)
     if isinstance(other, str) and not str_as_lit:
@@ -301,7 +299,7 @@ def extract_compliant(
 
 
 def evaluate_output_names_and_aliases(
-    expr: CompliantExpr[Any],
+    expr: CompliantExpr[Any, Any],
     df: CompliantDataFrame | CompliantLazyFrame,
     exclude: Sequence[str],
 ) -> tuple[Sequence[str], Sequence[str]]:
@@ -446,7 +444,7 @@ def apply_n_ary_operation(
     function: Any,
     *comparands: IntoExpr,
     str_as_lit: bool,
-) -> CompliantExpr[Any]:
+) -> CompliantExpr[Any, Any]:
     compliant_exprs = (
         extract_compliant(plx, comparand, str_as_lit=str_as_lit)
         for comparand in comparands
@@ -463,3 +461,4 @@ def apply_n_ary_operation(
         for compliant_expr, kind in zip(compliant_exprs, kinds)
     )
     return function(*compliant_exprs)
+    return function(*compliant_exprs)
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index f9694c07..9c48ac86 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -46,7 +46,7 @@ MANY_TO_MANY_AGG_FUNCTIONS_TO_PANDAS_EQUIVALENT = {
 }
 
 
-class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
+class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
     def __init__(
         self: Self,
         call: Callable[[PandasLikeDataFrame], Sequence[PandasLikeSeries]],
@@ -419,7 +419,7 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
             kwargs={**self._kwargs, "name": name},
         )
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
         if (
             is_simple_aggregation(self)
             and (function_name := re.sub(r"(\w+->)", "", self._function_name))
@@ -469,7 +469,13 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
                     )
                 )
                 return [result_frame[name] for name in aliases]
-
+        elif kind is ExprKind.TRANSFORM:
+            msg = (
+                "Elementwise operations are only supported in `over` context "
+                "for pandas if they are elementary "
+                "(e.g. `nw.col('a').cum_sum().over('b'))`)."
+            )
+            raise NotImplementedError(msg)
         else:
 
             def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index d930630e..c5275d2d 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class PandasLikeNamespace(CompliantNamespace[PandasLikeSeries]):
+class PandasLikeNamespace(CompliantNamespace[PandasLikeDataFrame, PandasLikeSeries]):
     @property
     def selectors(self: Self) -> PandasSelectorNamespace:
         return PandasSelectorNamespace(self)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index c45bd6aa..ee07aa26 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -656,7 +656,7 @@ class PandasLikeSeries(CompliantSeries):
         # https://numpy.org/doc/stable/reference/generated/numpy.ndarray.__array__.html
         copy = copy or self._implementation is Implementation.CUDF
         dtypes = import_dtypes_module(self._version)
-        if self.dtype == dtypes.Datetime and self.dtype.time_zone is not None:  # type: ignore[attr-defined]
+        if isinstance(self.dtype, dtypes.Datetime) and self.dtype.time_zone is not None:
             s = self.dt.convert_time_zone("UTC").dt.replace_time_zone(None)._native_series
         else:
             s = self._native_series
diff --git a/narwhals/_pandas_like/series_dt.py b/narwhals/_pandas_like/series_dt.py
index bbebbc6b..c16be61a 100644
--- a/narwhals/_pandas_like/series_dt.py
+++ b/narwhals/_pandas_like/series_dt.py
@@ -218,8 +218,8 @@ class PandasLikeSeriesDateTimeNamespace:
             # Date is only supported in pandas dtypes if pyarrow-backed
             s_cast = s.astype("Int32[pyarrow]")
             result = calculate_timestamp_date(s_cast, time_unit)
-        elif dtype == dtypes.Datetime:
-            original_time_unit = dtype.time_unit  # type: ignore[attr-defined]
+        elif isinstance(dtype, dtypes.Datetime):
+            original_time_unit = dtype.time_unit
             if (
                 self._compliant_series._implementation is Implementation.PANDAS
                 and self._compliant_series._backend_version < (2,)
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 52afb834..d39d2cdb 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -97,6 +97,9 @@ class PolarsExpr:
             )
         return self._from_native_expr(self._native_expr.is_nan())
 
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
+        return self._from_native_expr(self._native_expr.over(keys))
+
     def rolling_var(
         self: Self,
         window_size: int,
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index 97c17606..599b9605 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class SparkLikeExpr(CompliantExpr["Column"]):
+class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
     _depth = 0  # Unused, just for compatibility with CompliantExpr
 
     def __init__(
@@ -454,7 +454,7 @@ class SparkLikeExpr(CompliantExpr["Column"]):
 
         return self._from_call(_n_unique, "n_unique")
 
-    def over(self: Self, keys: list[str]) -> Self:
+    def over(self: Self, keys: list[str], kind: ExprKind) -> Self:
         def func(df: SparkLikeLazyFrame) -> list[Column]:
             return [expr.over(self._Window.partitionBy(*keys)) for expr in self._call(df)]
 
diff --git a/narwhals/_spark_like/namespace.py b/narwhals/_spark_like/namespace.py
index b6f51a60..6946e1ff 100644
--- a/narwhals/_spark_like/namespace.py
+++ b/narwhals/_spark_like/namespace.py
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class SparkLikeNamespace(CompliantNamespace["Column"]):
+class SparkLikeNamespace(CompliantNamespace["SparkLikeLazyFrame", "Column"]):
     def __init__(
         self: Self,
         *,
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 90a18935..c22125b6 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -82,7 +82,7 @@ class BaseFrame(Generic[_FrameT]):
 
     def _flatten_and_extract(
         self, *exprs: IntoExpr | Iterable[IntoExpr], **named_exprs: IntoExpr
-    ) -> tuple[list[IntoCompliantExpr[Any]], list[ExprKind]]:
+    ) -> tuple[list[IntoCompliantExpr[Any, Any]], list[ExprKind]]:
         """Process `args` and `kwargs`, extracting underlying objects as we go, interpreting strings as column names."""
         out_exprs = []
         out_kinds = []
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 2c323329..b15c9ec2 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1189,10 +1189,10 @@ class Expr:
         """
 
         def func(
-            compliant_expr: CompliantExpr[Any],
-            lb: CompliantExpr[Any],
-            ub: CompliantExpr[Any],
-        ) -> CompliantExpr[Any]:
+            compliant_expr: CompliantExpr[Any, Any],
+            lb: CompliantExpr[Any, Any],
+            ub: CompliantExpr[Any, Any],
+        ) -> CompliantExpr[Any, Any]:
             if closed == "left":
                 return (compliant_expr >= lb) & (compliant_expr < ub)  # type: ignore[no-any-return]
             elif closed == "right":
@@ -1602,7 +1602,9 @@ class Expr:
             msg = "`.over()` can not be used for expressions which change length."
             raise LengthChangingExprError(msg)
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).over(flatten(keys)),
+            lambda plx: self._to_compliant_expr(plx).over(
+                flatten(keys), kind=self._metadata["kind"]
+            ),
             ExprMetadata(
                 kind=ExprKind.TRANSFORM,
                 is_order_dependent=self._metadata["is_order_dependent"],
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 22634f98..2cd172ae 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -1458,7 +1458,7 @@ class Then(Expr):
     def otherwise(self: Self, value: IntoExpr | Any) -> Expr:
         kind = infer_kind(value, str_as_lit=False)
 
-        def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
+        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 (
diff --git a/narwhals/typing.py b/narwhals/typing.py
index 4923bbed..f1b62179 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -44,6 +44,8 @@ if TYPE_CHECKING:
 
 
 class CompliantSeries(Protocol):
+    @property
+    def dtype(self) -> DType: ...
     @property
     def name(self) -> str: ...
     def __narwhals_series__(self) -> CompliantSeries: ...
@@ -72,25 +74,30 @@ class CompliantLazyFrame(Protocol):
         # (so, no broadcasting is necessary).
 
 
+CompliantFrameT_contra = TypeVar(
+    "CompliantFrameT_contra",
+    bound="CompliantDataFrame | CompliantLazyFrame",
+    contravariant=True,
+)
 CompliantSeriesT_co = TypeVar(
     "CompliantSeriesT_co", bound=CompliantSeries, covariant=True
 )
 
 
-class CompliantExpr(Protocol, Generic[CompliantSeriesT_co]):
+class CompliantExpr(Protocol, Generic[CompliantFrameT_contra, CompliantSeriesT_co]):
     _implementation: Implementation
     _backend_version: tuple[int, ...]
     _version: Version
-    _evaluate_output_names: Callable[
-        [CompliantDataFrame | CompliantLazyFrame], Sequence[str]
-    ]
+    _evaluate_output_names: Callable[[CompliantFrameT_contra], Sequence[str]]
     _alias_output_names: Callable[[Sequence[str]], Sequence[str]] | None
     _depth: int
     _function_name: str
 
     def __call__(self, df: Any) -> Sequence[CompliantSeriesT_co]: ...
     def __narwhals_expr__(self) -> None: ...
-    def __narwhals_namespace__(self) -> CompliantNamespace[CompliantSeriesT_co]: ...
+    def __narwhals_namespace__(
+        self,
+    ) -> CompliantNamespace[CompliantFrameT_contra, CompliantSeriesT_co]: ...
     def is_null(self) -> Self: ...
     def alias(self, name: str) -> Self: ...
     def cast(self, dtype: DType) -> Self: ...
@@ -112,11 +119,13 @@ class CompliantExpr(Protocol, Generic[CompliantSeriesT_co]):
     ) -> Self: ...
 
 
-class CompliantNamespace(Protocol, Generic[CompliantSeriesT_co]):
-    def col(self, *column_names: str) -> CompliantExpr[CompliantSeriesT_co]: ...
+class CompliantNamespace(Protocol, Generic[CompliantFrameT_contra, CompliantSeriesT_co]):
+    def col(
+        self, *column_names: str
+    ) -> CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co]: ...
     def lit(
         self, value: Any, dtype: DType | None
-    ) -> CompliantExpr[CompliantSeriesT_co]: ...
+    ) -> CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co]: ...
 
 
 class SupportsNativeNamespace(Protocol):
@@ -316,7 +325,7 @@ 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[CompliantSeriesT_co] | CompliantSeriesT_co
+        CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co] | CompliantSeriesT_co
     )
 
 
diff --git a/narwhals/utils.py b/narwhals/utils.py
index cb33a603..1543595e 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -53,6 +53,7 @@ if TYPE_CHECKING:
     from narwhals.series import Series
     from narwhals.typing import CompliantDataFrame
     from narwhals.typing import CompliantExpr
+    from narwhals.typing import CompliantFrameT_contra
     from narwhals.typing import CompliantLazyFrame
     from narwhals.typing import CompliantSeries
     from narwhals.typing import CompliantSeriesT_co
@@ -1294,11 +1295,11 @@ def dtype_matches_time_unit_and_time_zone(
     dtype: DType, dtypes: DTypes, time_units: Set[TimeUnit], time_zones: Set[str | None]
 ) -> bool:
     return (
-        (dtype == dtypes.Datetime)
-        and (dtype.time_unit in time_units)  # type: ignore[attr-defined]
+        isinstance(dtype, dtypes.Datetime)
+        and (dtype.time_unit in time_units)
         and (
-            dtype.time_zone in time_zones  # type: ignore[attr-defined]
-            or ("*" in time_zones and dtype.time_zone is not None)  # type: ignore[attr-defined]
+            dtype.time_zone in time_zones
+            or ("*" in time_zones and dtype.time_zone is not None)
         )
     )
 
@@ -1321,8 +1322,8 @@ def is_compliant_series(obj: Any) -> TypeIs[CompliantSeries]:
 
 
 def is_compliant_expr(
-    obj: CompliantExpr[CompliantSeriesT_co] | Any,
-) -> TypeIs[CompliantExpr[CompliantSeriesT_co]]:
+    obj: CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co] | Any,
+) -> TypeIs[CompliantExpr[CompliantFrameT_contra, CompliantSeriesT_co]]:
     return hasattr(obj, "__narwhals_expr__")
 
 


Test patch:
diff --git a/tests/expr_and_series/dt/convert_time_zone_test.py b/tests/expr_and_series/dt/convert_time_zone_test.py
index 9a18ee07..9effb17e 100644
--- a/tests/expr_and_series/dt/convert_time_zone_test.py
+++ b/tests/expr_and_series/dt/convert_time_zone_test.py
@@ -42,7 +42,8 @@ def test_convert_time_zone(
     result = df.select(nw.col("a").dt.convert_time_zone("Asia/Kathmandu"))
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone == "Asia/Kathmandu"
     result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
     expected = {"a": ["2020-01-01T05:45+0545", "2020-01-02T05:45+0545"]}
     assert_equal_data(result_str, expected)
@@ -70,7 +71,8 @@ def test_convert_time_zone_series(
     result = df.select(df["a"].dt.convert_time_zone("Asia/Kathmandu"))
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone == "Asia/Kathmandu"
     result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
     expected = {"a": ["2020-01-01T05:45+0545", "2020-01-02T05:45+0545"]}
     assert_equal_data(result_str, expected)
@@ -105,7 +107,8 @@ def test_convert_time_zone_from_none(
     )
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone == "Asia/Kathmandu"
     result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
     expected = {"a": ["2020-01-01T05:45+0545", "2020-01-02T05:45+0545"]}
     assert_equal_data(result_str, expected)
diff --git a/tests/expr_and_series/dt/replace_time_zone_test.py b/tests/expr_and_series/dt/replace_time_zone_test.py
index 6876c318..b8a78bc2 100644
--- a/tests/expr_and_series/dt/replace_time_zone_test.py
+++ b/tests/expr_and_series/dt/replace_time_zone_test.py
@@ -40,7 +40,8 @@ def test_replace_time_zone(
     result = df.select(nw.col("a").dt.replace_time_zone("Asia/Kathmandu"))
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone == "Asia/Kathmandu"
     result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
     expected = {"a": ["2020-01-01T00:00+0545", "2020-01-02T00:00+0545"]}
     assert_equal_data(result_str, expected)
@@ -68,7 +69,8 @@ def test_replace_time_zone_none(
     result = df.select(nw.col("a").dt.replace_time_zone(None))
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone is None  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone is None
     result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M"))
     expected = {"a": ["2020-01-01T00:00", "2020-01-02T00:00"]}
     assert_equal_data(result_str, expected)
@@ -95,7 +97,8 @@ def test_replace_time_zone_series(
     result = df.select(df["a"].dt.replace_time_zone("Asia/Kathmandu"))
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone == "Asia/Kathmandu"
     result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
     expected = {"a": ["2020-01-01T00:00+0545", "2020-01-02T00:00+0545"]}
     assert_equal_data(result_str, expected)
@@ -121,7 +124,8 @@ def test_replace_time_zone_none_series(
     result = df.select(df["a"].dt.replace_time_zone(None))
     result_dtype = result.collect_schema()["a"]
     assert result_dtype == nw.Datetime
-    assert result_dtype.time_zone is None  # type: ignore[attr-defined]
+    assert isinstance(result_dtype, nw.Datetime)
+    assert result_dtype.time_zone is None
     result_str = result.select(df["a"].dt.to_string("%Y-%m-%dT%H:%M"))
     expected = {"a": ["2020-01-01T00:00", "2020-01-02T00:00"]}
     assert_equal_data(result_str, expected)
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index 600bde17..65dba7f6 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -4,6 +4,7 @@ import re
 from contextlib import nullcontext as does_not_raise
 
 import pandas as pd
+import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -259,3 +260,13 @@ def test_over_raise_len_change(constructor: Constructor) -> None:
         match=re.escape("`.over()` can not be used for expressions which change length."),
     ):
         nw.from_native(df).select(nw.col("b").drop_nulls().over("a"))
+
+
+def test_unsupported_over() -> None:
+    data = {"a": [1, 2, 3, 4, 5, 6], "b": ["x", "x", "x", "y", "y", "y"]}
+    df = pd.DataFrame(data)
+    with pytest.raises(NotImplementedError, match="elementary"):
+        nw.from_native(df).select(nw.col("a").shift(1).cum_sum().over("b"))
+    tbl = pa.table(data)  # type: ignore[arg-type]
+    with pytest.raises(NotImplementedError, match="Elementwise"):
+        nw.from_native(tbl).select(nw.col("a").shift(1).cum_sum().over("b"))
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index 3ec5ecda..869544cd 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -6,6 +6,7 @@ from typing import Mapping
 
 import pandas as pd
 import polars as pl
+import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -31,6 +32,10 @@ def test_group_by_complex() -> None:
             df.group_by("a").agg((nw.col("b") - nw.col("c").mean()).mean()).sort("a")
         )
     assert_equal_data(result_pd, expected)
+    with pytest.raises(ValueError, match="complex aggregation"):
+        nw.from_native(pa.table({"a": [1, 1, 2], "b": [4, 5, 6]})).group_by("a").agg(
+            (nw.col("b") - nw.col("c").mean()).mean()
+        )
 
     lf = nw.from_native(df_lazy).lazy()
     result_pl = lf.group_by("a").agg((nw.col("b") - nw.col("c").mean()).mean()).sort("a")