Repository: narwhals-dev/narwhals
Pull request #2371: chore: `@requires` for backend method constraints
Author: login: dangotbanned
Labels: name: error reporting
name: internal
Created: 2025-04-11T17:43:30Z | Merged: 2025-04-17T19:33:40Z

## What type of PR is this? (check all applicable)

- [x] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [x] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below
Adds a method decorator for consistent error messages on unsupported versions

Main use case is for enforcing a minimum version, e.g.:

```py
class PolarsExpr:
    @requires.backend_version((1,))
    def rolling_var(
        self: Self, window_size: int, *, min_samples: int, center: bool, ddof: int
    ) -> Self: ...
```

Still have some other cases I wanna explore:
- [x] Providing a hint (`PolarsNamespace.nth`)

## Possible follow-ups
### `PandasLike*`
`.pivot` is a good example of multiple cases dependent on more than just `backend_version`:

https://github.com/narwhals-dev/narwhals/blob/d63ec65ef400f370dc1d56764cacdaaf84248fb9/narwhals/_pandas_like/dataframe.py#L1032-L1049

### Parameter combinations
I'd only want to include the *version-based* parameter restrictions on `@requires`:
- `DuckDBLazyFrame.join(how="cross")`
- `DuckDBExpr.broadcast(kind=ExprKind.AGGREGATION)`
- `PolarsDataFrame.__array__(copy=...)`
- `PolarsExpr.over(order_by=...)`

It seems there are more *blanket* parameter restrictions, but they don't make sense as a `@requires`:
- `DaskExpr.cum_*(reverse=True)`
- `DaskExpr.rolling_*(ddof=2)` (or not `1`)
- `(Dask|DuckDB)Expr.quantile(interpolation="(nearest|higher|lower|midpoint)")`
- `DuckDBLazyFrame.join_asof(strategy="nearest")`
- `(DuckDB|SparkLike)Expr.fill_null(strategy=...)`
- `(DuckDB|SparkLike)Expr.rank(method="(average|max|ordinal)")`
- `DuckDBExprStringNamespace.to_datetime(format=None)`

### How to document this?
Like with `not_implemented`, we could mark the methods with runtime metadata. Then consume that in a script for something like https://narwhals-dev.github.io/narwhals/api-completeness/


Diff:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index efeb851e95..302a6cf335 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -35,6 +35,7 @@
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import is_list_of
 from narwhals.utils import not_implemented
+from narwhals.utils import requires
 from narwhals.utils import validate_backend_version
 
 if TYPE_CHECKING:
@@ -863,10 +864,8 @@ def cum_count(self: Self, *, reverse: bool) -> Self:
         dtypes = import_dtypes_module(self._version)
         return (~self.is_null()).cast(dtypes.UInt32()).cum_sum(reverse=reverse)
 
-    def cum_min(self: Self, *, reverse: bool) -> Self:
-        if self._backend_version < (13, 0, 0):
-            msg = "cum_min method is not supported for pyarrow < 13.0.0"
-            raise NotImplementedError(msg)
+    @requires.backend_version((13,))
+    def cum_min(self, *, reverse: bool) -> Self:
         result = (
             pc.cumulative_min(self.native, skip_nulls=True)
             if not reverse
@@ -874,10 +873,8 @@ def cum_min(self: Self, *, reverse: bool) -> Self:
         )
         return self._with_native(result)
 
-    def cum_max(self: Self, *, reverse: bool) -> Self:
-        if self._backend_version < (13, 0, 0):
-            msg = "cum_max method is not supported for pyarrow < 13.0.0"
-            raise NotImplementedError(msg)
+    @requires.backend_version((13,))
+    def cum_max(self, *, reverse: bool) -> Self:
         result = (
             pc.cumulative_max(self.native, skip_nulls=True)
             if not reverse
@@ -885,10 +882,8 @@ def cum_max(self: Self, *, reverse: bool) -> Self:
         )
         return self._with_native(result)
 
-    def cum_prod(self: Self, *, reverse: bool) -> Self:
-        if self._backend_version < (13, 0, 0):
-            msg = "cum_max method is not supported for pyarrow < 13.0.0"
-            raise NotImplementedError(msg)
+    @requires.backend_version((13,))
+    def cum_prod(self, *, reverse: bool) -> Self:
         result = (
             pc.cumulative_prod(self.native, skip_nulls=True)
             if not reverse
@@ -896,13 +891,7 @@ def cum_prod(self: Self, *, reverse: bool) -> Self:
         )
         return self._with_native(result)
 
-    def rolling_sum(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int,
-        center: bool,
-    ) -> Self:
+    def rolling_sum(self, window_size: int, *, min_samples: int, center: bool) -> Self:
         min_samples = min_samples if min_samples is not None else window_size
         padded_series, offset = pad_series(self, window_size=window_size, center=center)
 
@@ -926,13 +915,7 @@ def rolling_sum(
         )
         return result[offset:]
 
-    def rolling_mean(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int,
-        center: bool,
-    ) -> Self:
+    def rolling_mean(self, window_size: int, *, min_samples: int, center: bool) -> Self:
         min_samples = min_samples if min_samples is not None else window_size
         padded_series, offset = pad_series(self, window_size=window_size, center=center)
 
@@ -962,12 +945,7 @@ def rolling_mean(
         return result[offset:]
 
     def rolling_var(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int,
-        center: bool,
-        ddof: int,
+        self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
         min_samples = min_samples if min_samples is not None else window_size
         padded_series, offset = pad_series(self, window_size=window_size, center=center)
@@ -1010,12 +988,7 @@ def rolling_var(
         return result[offset:]
 
     def rolling_std(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int,
-        center: bool,
-        ddof: int,
+        self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
         return (
             self.rolling_var(
@@ -1048,16 +1021,14 @@ def rank(self, method: RankMethod, *, descending: bool) -> Self:
         result = pc.if_else(null_mask, lit(None, native_series.type), rank)
         return self._with_native(result)
 
+    @requires.backend_version((13,))
     def hist(  # noqa: PLR0915
-        self: Self,
+        self,
         bins: list[float | int] | None,
         *,
         bin_count: int | None,
         include_breakpoint: bool,
     ) -> ArrowDataFrame:
-        if self._backend_version < (13,):
-            msg = f"`Series.hist` requires PyArrow>=13.0.0, found PyArrow version: {self._backend_version}"
-            raise NotImplementedError(msg)
         import numpy as np  # ignore-banned-import
 
         from narwhals._arrow.dataframe import ArrowDataFrame
diff --git a/narwhals/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index 290333e868..6584d1a8cb 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -30,6 +30,7 @@
 from narwhals._expression_parsing import ExprKind
 from narwhals.utils import Implementation
 from narwhals.utils import not_implemented
+from narwhals.utils import requires
 
 if TYPE_CHECKING:
     import duckdb
@@ -59,7 +60,7 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
     _implementation = Implementation.DUCKDB
 
     def __init__(
-        self: Self,
+        self,
         call: EvalSeries[DuckDBLazyFrame, duckdb.Expression],
         *,
         evaluate_output_names: EvalNames[DuckDBLazyFrame],
@@ -75,7 +76,7 @@ def __init__(
         self._window_function: WindowFunction | None = None
         self._metadata: ExprMetadata | None = None
 
-    def __call__(self: Self, df: DuckDBLazyFrame) -> Sequence[duckdb.Expression]:
+    def __call__(self, df: DuckDBLazyFrame) -> Sequence[duckdb.Expression]:
         return self._call(df)
 
     def __narwhals_expr__(self) -> None: ...
@@ -177,7 +178,7 @@ def func(df: DuckDBLazyFrame) -> Sequence[duckdb.Expression]:
 
     @classmethod
     def from_column_names(
-        cls: type[Self],
+        cls,
         evaluate_column_names: EvalNames[DuckDBLazyFrame],
         /,
         *,
@@ -195,9 +196,7 @@ def func(df: DuckDBLazyFrame) -> list[duckdb.Expression]:
         )
 
     @classmethod
-    def from_column_indices(
-        cls: type[Self], *column_indices: int, context: _FullContext
-    ) -> Self:
+    def from_column_indices(cls, *column_indices: int, context: _FullContext) -> Self:
         def func(df: DuckDBLazyFrame) -> list[duckdb.Expression]:
             columns = df.columns
             return [col(columns[i]) for i in column_indices]
@@ -211,10 +210,7 @@ def func(df: DuckDBLazyFrame) -> list[duckdb.Expression]:
         )
 
     def _with_callable(
-        self: Self,
-        call: Callable[..., duckdb.Expression],
-        /,
-        **expressifiable_args: Self | Any,
+        self, call: Callable[..., duckdb.Expression], /, **expressifiable_args: Self | Any
     ) -> Self:
         """Create expression from callable.
 
@@ -497,14 +493,8 @@ def null_count(self: Self) -> Self:
             lambda _input: FunctionExpression("sum", _input.isnull().cast("int")),
         )
 
-    def over(
-        self: Self,
-        partition_by: Sequence[str],
-        order_by: Sequence[str] | None,
-    ) -> Self:
-        if self._backend_version < (1, 3):
-            msg = "At least version 1.3 of DuckDB is required for `over` operation."
-            raise NotImplementedError(msg)
+    @requires.backend_version((1, 3))
+    def over(self, partition_by: Sequence[str], order_by: Sequence[str] | None) -> Self:
         if (window_function := self._window_function) is not None:
             assert order_by is not None  # noqa: S101
 
@@ -549,6 +539,7 @@ def round(self: Self, decimals: int) -> Self:
             lambda _input: FunctionExpression("round", _input, lit(decimals))
         )
 
+    @requires.backend_version((1, 3))
     def shift(self, n: int) -> Self:
         ensure_type(n, int)
 
@@ -562,6 +553,7 @@ def func(window_inputs: WindowInputs) -> duckdb.Expression:
 
         return self._with_window_function(func)
 
+    @requires.backend_version((1, 3))
     def is_first_distinct(self) -> Self:
         def func(window_inputs: WindowInputs) -> duckdb.Expression:
             order_by_sql = generate_order_by_sql(*window_inputs.order_by, ascending=True)
@@ -577,6 +569,7 @@ def func(window_inputs: WindowInputs) -> duckdb.Expression:
 
         return self._with_window_function(func)
 
+    @requires.backend_version((1, 3))
     def is_last_distinct(self) -> Self:
         def func(window_inputs: WindowInputs) -> duckdb.Expression:
             order_by_sql = generate_order_by_sql(*window_inputs.order_by, ascending=False)
@@ -592,6 +585,7 @@ def func(window_inputs: WindowInputs) -> duckdb.Expression:
 
         return self._with_window_function(func)
 
+    @requires.backend_version((1, 3))
     def diff(self) -> Self:
         def func(window_inputs: WindowInputs) -> duckdb.Expression:
             order_by_sql = generate_order_by_sql(*window_inputs.order_by, ascending=True)
@@ -601,31 +595,37 @@ def func(window_inputs: WindowInputs) -> duckdb.Expression:
 
         return self._with_window_function(func)
 
+    @requires.backend_version((1, 3))
     def cum_sum(self, *, reverse: bool) -> Self:
         return self._with_window_function(
             self._cum_window_func(reverse=reverse, func_name="sum")
         )
 
+    @requires.backend_version((1, 3))
     def cum_max(self, *, reverse: bool) -> Self:
         return self._with_window_function(
             self._cum_window_func(reverse=reverse, func_name="max")
         )
 
+    @requires.backend_version((1, 3))
     def cum_min(self, *, reverse: bool) -> Self:
         return self._with_window_function(
             self._cum_window_func(reverse=reverse, func_name="min")
         )
 
+    @requires.backend_version((1, 3))
     def cum_count(self, *, reverse: bool) -> Self:
         return self._with_window_function(
             self._cum_window_func(reverse=reverse, func_name="count")
         )
 
+    @requires.backend_version((1, 3))
     def cum_prod(self, *, reverse: bool) -> Self:
         return self._with_window_function(
             self._cum_window_func(reverse=reverse, func_name="product")
         )
 
+    @requires.backend_version((1, 3))
     def rolling_sum(self, window_size: int, *, min_samples: int, center: bool) -> Self:
         return self._with_window_function(
             self._rolling_window_func(
@@ -636,6 +636,7 @@ def rolling_sum(self, window_size: int, *, min_samples: int, center: bool) -> Se
             )
         )
 
+    @requires.backend_version((1, 3))
     def rolling_mean(self, window_size: int, *, min_samples: int, center: bool) -> Self:
         return self._with_window_function(
             self._rolling_window_func(
@@ -646,6 +647,7 @@ def rolling_mean(self, window_size: int, *, min_samples: int, center: bool) -> S
             )
         )
 
+    @requires.backend_version((1, 3))
     def rolling_var(
         self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
@@ -659,6 +661,7 @@ def rolling_var(
             )
         )
 
+    @requires.backend_version((1, 3))
     def rolling_std(
         self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
@@ -691,13 +694,15 @@ def func(_input: duckdb.Expression) -> duckdb.Expression:
 
         return self._with_callable(func)
 
-    def is_unique(self: Self) -> Self:
+    @requires.backend_version((1, 3))
+    def is_unique(self) -> Self:
         def func(_input: duckdb.Expression) -> duckdb.Expression:
             sql = f"count(*) over (partition by {_input})"
             return SQLExpression(sql) == lit(1)  # type: ignore[no-any-return, unused-ignore]
 
         return self._with_callable(func)
 
+    @requires.backend_version((1, 3))
     def rank(self, method: RankMethod, *, descending: bool) -> Self:
         if self._backend_version < (1, 3):
             msg = "At least version 1.3 of DuckDB is required for `rank`."
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 6258e4218d..445eb28c7b 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -23,6 +23,7 @@
 from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 from narwhals.utils import parse_version
+from narwhals.utils import requires
 from narwhals.utils import validate_backend_version
 
 if TYPE_CHECKING:
@@ -414,6 +415,7 @@ def unpivot(
             )
         )
 
+    @requires.backend_version((1,))
     def pivot(
         self,
         on: Sequence[str],
@@ -424,9 +426,6 @@ def pivot(
         sort_columns: bool,
         separator: str,
     ) -> Self:
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
-            msg = "`pivot` is only supported for Polars>=1.0.0"
-            raise NotImplementedError(msg)
         try:
             result = self.native.pivot(
                 on,
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 3d0761a5ef..fa84145acc 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -13,6 +13,7 @@
 from narwhals._polars.utils import extract_native
 from narwhals._polars.utils import narwhals_to_native_dtype
 from narwhals.utils import Implementation
+from narwhals.utils import requires
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -109,24 +110,20 @@ def over(self, partition_by: Sequence[str], order_by: Sequence[str] | None) -> S
             native = self.native.over(partition_by or pl.lit(1), order_by=order_by)
         return self._with_native(native)
 
+    @requires.backend_version((1,))
     def rolling_var(
         self: Self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
-        if self._backend_version < (1,):  # pragma: no cover
-            msg = "`rolling_var` not implemented for polars older than 1.0"
-            raise NotImplementedError(msg)
         kwds = self._renamed_min_periods(min_samples)
         native = self.native.rolling_var(
             window_size=window_size, center=center, ddof=ddof, **kwds
         )
         return self._with_native(native)
 
+    @requires.backend_version((1,))
     def rolling_std(
         self: Self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
-        if self._backend_version < (1,):  # pragma: no cover
-            msg = "`rolling_std` not implemented for polars older than 1.0"
-            raise NotImplementedError(msg)
         kwds = self._renamed_min_periods(min_samples)
         native = self.native.rolling_std(
             window_size=window_size, center=center, ddof=ddof, **kwds
@@ -158,6 +155,7 @@ def map_batches(
         native = self.native.map_batches(function, return_dtype_pl)
         return self._with_native(native)
 
+    @requires.backend_version((1,))
     def replace_strict(
         self,
         old: Sequence[Any] | Mapping[Any, Any],
@@ -165,9 +163,6 @@ def replace_strict(
         *,
         return_dtype: DType | type[DType] | None,
     ) -> Self:
-        if self._backend_version < (1,):
-            msg = f"`replace_strict` is only available in Polars>=1.0, found version {self._backend_version}"
-            raise NotImplementedError(msg)
         return_dtype_pl = (
             narwhals_to_native_dtype(return_dtype, self._version, self._backend_version)
             if return_dtype
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
index 27094362f5..54af4f516b 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -19,6 +19,7 @@
 from narwhals.dependencies import is_numpy_array_2d
 from narwhals.dtypes import DType
 from narwhals.utils import Implementation
+from narwhals.utils import requires
 
 if TYPE_CHECKING:
     from datetime import timezone
@@ -136,10 +137,10 @@ def from_numpy(
             return self._dataframe.from_numpy(data, schema=schema, context=self)
         return self._series.from_numpy(data, context=self)  # pragma: no cover
 
-    def nth(self: Self, *indices: int) -> PolarsExpr:
-        if self._backend_version < (1, 0, 0):
-            msg = "`nth` is only supported for Polars>=1.0.0. Please use `col` for columns selection instead."
-            raise AttributeError(msg)
+    @requires.backend_version(
+        (1, 0, 0), "Please use `col` for columns selection instead."
+    )
+    def nth(self, *indices: int) -> PolarsExpr:
         return self._expr(
             pl.nth(*indices), version=self._version, backend_version=self._backend_version
         )
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index ca9c0410f8..971ad37cf1 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -18,6 +18,7 @@
 from narwhals._polars.utils import native_to_narwhals_dtype
 from narwhals.dependencies import is_numpy_array_1d
 from narwhals.utils import Implementation
+from narwhals.utils import requires
 from narwhals.utils import validate_backend_version
 
 if TYPE_CHECKING:
@@ -189,6 +190,7 @@ def cast(self: Self, dtype: DType | type[DType]) -> Self:
         dtype_pl = narwhals_to_native_dtype(dtype, self._version, self._backend_version)
         return self._with_native(self.native.cast(dtype_pl))
 
+    @requires.backend_version((1,))
     def replace_strict(
         self: Self,
         old: Sequence[Any] | Mapping[Any, Any],
@@ -202,9 +204,6 @@ def replace_strict(
             if return_dtype
             else None
         )
-        if self._backend_version < (1,):
-            msg = f"`replace_strict` is only available in Polars>=1.0, found version {self._backend_version}"
-            raise NotImplementedError(msg)
         return self._with_native(ser.replace_strict(old, new, return_dtype=dtype))
 
     def to_numpy(self, dtype: Any = None, *, copy: bool | None = None) -> _1DArray:
@@ -340,6 +339,7 @@ def ewm_mean(
 
         return self._with_native(native_result)
 
+    @requires.backend_version((1,))
     def rolling_var(
         self: Self,
         window_size: int,
@@ -348,22 +348,18 @@ def rolling_var(
         center: bool,
         ddof: int,
     ) -> Self:
-        if self._backend_version < (1,):  # pragma: no cover
-            msg = "`rolling_var` not implemented for polars older than 1.0"
-            raise NotImplementedError(msg)
-
         extra_kwargs: dict[str, Any] = (
             {"min_periods": min_samples}
             if self._backend_version < (1, 21, 0)
             else {"min_samples": min_samples}
         )
-
         return self._with_native(
             self.native.rolling_var(
                 window_size=window_size, center=center, ddof=ddof, **extra_kwargs
             )
         )
 
+    @requires.backend_version((1,))
     def rolling_std(
         self: Self,
         window_size: int,
@@ -372,16 +368,11 @@ def rolling_std(
         center: bool,
         ddof: int,
     ) -> Self:
-        if self._backend_version < (1,):  # pragma: no cover
-            msg = "`rolling_std` not implemented for polars older than 1.0"
-            raise NotImplementedError(msg)
-
         extra_kwargs: dict[str, Any] = (
             {"min_periods": min_samples}
             if self._backend_version < (1, 21, 0)
             else {"min_samples": min_samples}
         )
-
         return self._with_native(
             self.native.rolling_std(
                 window_size=window_size, center=center, ddof=ddof, **extra_kwargs
@@ -400,7 +391,6 @@ def rolling_sum(
             if self._backend_version < (1, 21, 0)
             else {"min_samples": min_samples}
         )
-
         return self._with_native(
             self.native.rolling_sum(
                 window_size=window_size, center=center, **extra_kwargs
@@ -419,7 +409,6 @@ def rolling_mean(
             if self._backend_version < (1, 21, 0)
             else {"min_samples": min_samples}
         )
-
         return self._with_native(
             self.native.rolling_mean(
                 window_size=window_size, center=center, **extra_kwargs
diff --git a/narwhals/utils.py b/narwhals/utils.py
index d268a02261..54d691b534 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -55,6 +55,7 @@
     import polars as pl
     import pyarrow as pa
     import pyspark.sql as pyspark_sql
+    from typing_extensions import Concatenate
     from typing_extensions import LiteralString
     from typing_extensions import ParamSpec
     from typing_extensions import Self
@@ -159,6 +160,8 @@ def columns(self) -> Sequence[str]: ...
 
 NativeT_co = TypeVar("NativeT_co", covariant=True)
 CompliantT_co = TypeVar("CompliantT_co", covariant=True)
+_ContextT = TypeVar("_ContextT", bound="_FullContext")
+_Method: TypeAlias = "Callable[Concatenate[_ContextT, P], R]"
 
 
 class _StoresNative(Protocol[NativeT_co]):  # noqa: PYI046
@@ -539,6 +542,27 @@ def is_sqlframe(self: Self) -> bool:
         """
         return self is Implementation.SQLFRAME  # pragma: no cover
 
+    @property
+    def _alias(self) -> LiteralString:
+        """Friendly name for errors.
+
+        Returns:
+            String.
+        """
+        mapping: dict[Implementation, LiteralString] = {
+            Implementation.PANDAS: "Pandas",
+            Implementation.POLARS: "Polars",
+            Implementation.DASK: "Dask",
+            Implementation.IBIS: "Ibis",
+            Implementation.MODIN: "Modin",
+            Implementation.CUDF: "cuDF",
+            Implementation.PYARROW: "PyArrow",
+            Implementation.PYSPARK: "PySpark",
+            Implementation.DUCKDB: "DuckDB",
+            Implementation.SQLFRAME: "SQLFrame",
+        }
+        return mapping[self]
+
 
 MIN_VERSIONS: dict[Implementation, tuple[int, ...]] = {
     Implementation.PANDAS: (0, 25, 3),
@@ -1779,3 +1803,73 @@ def _not_implemented_error(what: str, who: str, /) -> NotImplementedError:
         "please open an issue at: https://github.com/narwhals-dev/narwhals/issues"
     )
     return NotImplementedError(msg)
+
+
+class requires:  # noqa: N801
+    """Method decorator for raising under certain constraints.
+
+    Attributes:
+        _min_version: Minimum backend version.
+        _hint: Optional suggested alternative.
+
+    Examples:
+        >>> from narwhals.utils import requires, Implementation
+        >>> class SomeBackend:
+        ...     _implementation = Implementation.PYARROW
+        ...     _backend_version = 20, 0, 0
+        ...
+        ...     @requires.backend_version((9000, 0, 0))
+        ...     def really_complex_feature(self) -> str:
+        ...         return "hello"
+        >>> backend = SomeBackend()
+        >>> backend.really_complex_feature()
+        Traceback (most recent call last):
+            ...
+        NotImplementedError: `really_complex_feature` is only available in PyArrow>='9000.0.0', found version '20.0.0'.
+    """
+
+    _min_version: tuple[int, ...]
+    _hint: str
+
+    @classmethod
+    def backend_version(cls, minimum: tuple[int, ...], /, hint: str = "") -> Self:
+        """Method decorator for raising below a minimum `_backend_version`.
+
+        Arguments:
+            minimum: Minimum backend version.
+            hint: Optional suggested alternative.
+
+        Returns:
+            An exception-raising decorator.
+        """
+        obj = cls.__new__(cls)
+        obj._min_version = minimum
+        obj._hint = hint
+        return obj
+
+    @staticmethod
+    def _unparse_version(backend_version: tuple[int, ...], /) -> str:
+        return ".".join(f"{d}" for d in backend_version)
+
+    def _ensure_version(self, instance: _FullContext, /) -> None:
+        if instance._backend_version >= self._min_version:
+            return
+        method = self._wrapped_name
+        backend = instance._implementation._alias
+        minimum = self._unparse_version(self._min_version)
+        found = self._unparse_version(instance._backend_version)
+        msg = f"`{method}` is only available in {backend}>={minimum!r}, found version {found!r}."
+        if self._hint:
+            msg = f"{msg}\n{self._hint}"
+        raise NotImplementedError(msg)
+
+    def __call__(self, fn: _Method[_ContextT, P, R], /) -> _Method[_ContextT, P, R]:
+        self._wrapped_name = fn.__name__
+
+        @wraps(fn)
+        def wrapper(instance: _ContextT, *args: P.args, **kwds: P.kwargs) -> R:
+            self._ensure_version(instance)
+            return fn(instance, *args, **kwds)
+
+        # NOTE: Only getting a complaint from `mypy`
+        return wrapper  # type: ignore[return-value]
diff --git a/tests/expr_and_series/nth_test.py b/tests/expr_and_series/nth_test.py
index 7e185f76a7..22f4493486 100644
--- a/tests/expr_and_series/nth_test.py
+++ b/tests/expr_and_series/nth_test.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import re
 from typing import Any
 from typing import Mapping
 
@@ -33,16 +34,12 @@ def test_nth(
     assert_equal_data(result, expected)
 
 
-@pytest.mark.skipif(
-    POLARS_VERSION >= (1, 0, 0),
-    reason="1.0.0",
-)
+@pytest.mark.skipif(POLARS_VERSION >= (1, 0, 0), reason="1.0.0")
 def test_nth_not_supported() -> None:  # pragma: no cover
     pytest.importorskip("polars")
     import polars as pl
 
     df = nw.from_native(pl.DataFrame(data))
-    with pytest.raises(
-        AttributeError, match="`nth` is only supported for Polars>=1.0.0."
-    ):
+    pattern = re.compile(r"`nth` .+ Polars>=\'1.0.0\'.+`col`.+instead", re.DOTALL)
+    with pytest.raises(NotImplementedError, match=pattern):
         df.select(nw.nth(0))
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 57fe764a48..72e975af6e 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -21,9 +21,11 @@
 import narwhals.stable.v1 as nw_v1
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.utils import Implementation
+from narwhals.utils import Version
 from narwhals.utils import check_column_exists
 from narwhals.utils import deprecate_native_namespace
 from narwhals.utils import parse_version
+from narwhals.utils import requires
 from tests.utils import PANDAS_VERSION
 from tests.utils import get_module_version_as_tuple
 
@@ -501,3 +503,57 @@ def func3(
         func3(param, backend=pl, native_namespace=pl)
 
     assert func3(param, backend=Implementation.POLARS) is Implementation.POLARS
+
+
+def test_requires() -> None:
+    class ProbablyCompliant:
+        _implementation: Implementation = Implementation.POLARS
+        _version: Version = Version.MAIN
+
+        def __init__(self, native_obj: str, backend_version: tuple[int, ...]) -> None:
+            self._native_obj: str = native_obj
+            self._backend_version: tuple[int, ...] = backend_version
+
+        @property
+        def native(self) -> str:
+            return self._native_obj
+
+        @requires.backend_version((1, 0, 0))
+        def to_int(self) -> int:
+            return int(self.native)
+
+        @requires.backend_version((2,), hint="Something helpful I suppose")
+        def concat(self, *strings: str, separator: str = "") -> str:
+            return separator.join((self.native, *strings))
+
+        @requires.backend_version((3, 0, 0))
+        def repeat(self, n: int) -> str:
+            return self.native * n
+
+    v_05 = ProbablyCompliant("123", (0, 5))
+    v_201 = ProbablyCompliant("123", (2, 0, 1))
+    v_300 = ProbablyCompliant("123", (3, 0, 0))
+
+    converted = v_201.to_int()
+    assert converted == 123
+    match = r"`to_int`.+Polars>=\'1.0.0\'.+found.+\'0.5\'"
+    with pytest.raises(NotImplementedError, match=match):
+        v_05.to_int()
+    repeated = v_300.repeat(3)
+    assert repeated == "123123123"
+    match = r"`repeat`.+Polars>=\'3.0.0\'.+found.+\'2.0.1\'"
+    with pytest.raises(NotImplementedError, match=match):
+        v_201.repeat(3)
+    match = r"`repeat`.+Polars>=\'3.0.0\'.+found.+\'0.5\'"
+    with pytest.raises(NotImplementedError, match=match):
+        v_05.repeat(3)
+    joined = v_201.concat("456", "789")
+    assert joined == "123456789"
+    joined_sep = v_201.concat("456", "789", separator=" ")
+    assert joined_sep == "123 456 789"
+    assert v_300.concat("forever") == "123forever"
+    pattern = re.compile(
+        r"`concat`.+Polars>=\'2\'.+found.+\'0.5\'.+Something helpful I suppose", re.DOTALL
+    )
+    with pytest.raises(NotImplementedError, match=pattern):
+        v_05.concat("never")


Reviews:
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T10:50:59Z
user: login: FBruzzesi
body: Thanks @dangotbanned , it looks very good!

I personally like the `requires.backend_version(...)` style, as it leaves more room to expand if needed. Aside such personal preference, I don't have anything to object with the PR itself :) 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T17:46:14Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:13:14Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:14:00Z
user: login: FBruzzesi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:23:47Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:43:51Z
user: login: FBruzzesi
body: It's a yes from me!

![DogHighFiveGIF](https://github.com/user-attachments/assets/127e6241-a472-409d-9dbe-ff92ae6368e8)

state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T19:30:51Z

Review comments:
diff_hunk: @@ -1779,3 +1803,67 @@ def _not_implemented_error(what: str, who: str, /) -> NotImplementedError:
         "please open an issue at: https://github.com/narwhals-dev/narwhals/issues"
     )
     return NotImplementedError(msg)
+
+
+class requires:  # noqa: N801
+    """Method decorator for raising under certain constraints.
+
+    Arguments:
+        min_version: Minimum backend version.
+        hint: Optional suggested alternative.
+
+    Returns:
+        An exception-raising decorator.
+    """
+
+    # NOTE: Decide how constraints should work
+    # - [x] min_version
+    # - [ ] specific parameters
+    # - [x] optional message
+    def __init__(self, *, min_version: tuple[int, ...], hint: str = "") -> None:
+        self._min_version: tuple[int, ...] = min_version
+        self._hint: str = hint
+        self._wrapped_name: str
+
+    @classmethod
+    def backend_version(cls, minimum: tuple[int, ...], /, hint: str = "") -> Self:
+        """Method decorator for raising below a minimum `_backend_version`.
+
+        Arguments:
+            minimum: Minimum backend version.
+            hint: Optional suggested alternative.
+
+        Returns:
+            An exception-raising decorator.
+        """
+        obj = cls.__new__(cls)
+        obj._min_version = minimum
+        obj._hint = hint
+        return obj
path: narwhals/utils.py
user: login: dangotbanned
body: I want to switch everything over to this style, see for comparison (https://github.com/narwhals-dev/narwhals/pull/2371#issuecomment-2799886235)

Also the one current use for coverage (https://github.com/narwhals-dev/narwhals/pull/2371/commits/0ac95955cc6d0f144b19696c2bfd275065274b9f)
created_at: 2025-04-16T10:50:59Z
author_association: MEMBER
diff_hunk: @@ -539,6 +542,27 @@ def is_sqlframe(self: Self) -> bool:
         """
         return self is Implementation.SQLFRAME  # pragma: no cover
 
+    @property
+    def _alias(self) -> LiteralString:
+        """Friendly name for errors.
path: narwhals/utils.py
user: login: FBruzzesi
body: Nice touch! This might be used in many other places in which we raise error specifically for a given implementation 💡
created_at: 2025-04-17T17:34:31Z
author_association: MEMBER
diff_hunk: @@ -1779,3 +1803,67 @@ def _not_implemented_error(what: str, who: str, /) -> NotImplementedError:
         "please open an issue at: https://github.com/narwhals-dev/narwhals/issues"
     )
     return NotImplementedError(msg)
+
+
+class requires:  # noqa: N801
+    """Method decorator for raising under certain constraints.
+
+    Arguments:
+        min_version: Minimum backend version.
+        hint: Optional suggested alternative.
+
+    Returns:
+        An exception-raising decorator.
+    """
+
+    # NOTE: Decide how constraints should work
+    # - [x] min_version
+    # - [ ] specific parameters
+    # - [x] optional message
+    def __init__(self, *, min_version: tuple[int, ...], hint: str = "") -> None:
+        self._min_version: tuple[int, ...] = min_version
+        self._hint: str = hint
+        self._wrapped_name: str
+
+    @classmethod
+    def backend_version(cls, minimum: tuple[int, ...], /, hint: str = "") -> Self:
+        """Method decorator for raising below a minimum `_backend_version`.
+
+        Arguments:
+            minimum: Minimum backend version.
+            hint: Optional suggested alternative.
+
+        Returns:
+            An exception-raising decorator.
+        """
+        obj = cls.__new__(cls)
+        obj._min_version = minimum
+        obj._hint = hint
+        return obj
path: narwhals/utils.py
user: login: FBruzzesi
body: It looks pretty nice and more explicit 👌🏼
created_at: 2025-04-17T17:38:20Z
author_association: MEMBER
diff_hunk: @@ -539,6 +542,27 @@ def is_sqlframe(self: Self) -> bool:
         """
         return self is Implementation.SQLFRAME  # pragma: no cover
 
+    @property
+    def _alias(self) -> LiteralString:
+        """Friendly name for errors.
path: narwhals/utils.py
user: login: dangotbanned
body: Thanks @FBruzzesi!

Yeah I noticed we seem to use those names in the errors I've replaced here and in the docstrings like [`Implementation.is_cudf`](https://narwhals-dev.github.io/narwhals/api-reference/implementation/#narwhals.Implementation.is_cudf)
But was suprised they hadn't been defined in one place yet
created_at: 2025-04-17T18:13:14Z
author_association: MEMBER
diff_hunk: @@ -1779,3 +1803,67 @@ def _not_implemented_error(what: str, who: str, /) -> NotImplementedError:
         "please open an issue at: https://github.com/narwhals-dev/narwhals/issues"
     )
     return NotImplementedError(msg)
+
+
+class requires:  # noqa: N801
+    """Method decorator for raising under certain constraints.
+
+    Arguments:
+        min_version: Minimum backend version.
+        hint: Optional suggested alternative.
+
+    Returns:
+        An exception-raising decorator.
+    """
+
+    # NOTE: Decide how constraints should work
+    # - [x] min_version
+    # - [ ] specific parameters
+    # - [x] optional message
+    def __init__(self, *, min_version: tuple[int, ...], hint: str = "") -> None:
+        self._min_version: tuple[int, ...] = min_version
+        self._hint: str = hint
+        self._wrapped_name: str
+
+    @classmethod
+    def backend_version(cls, minimum: tuple[int, ...], /, hint: str = "") -> Self:
+        """Method decorator for raising below a minimum `_backend_version`.
+
+        Arguments:
+            minimum: Minimum backend version.
+            hint: Optional suggested alternative.
+
+        Returns:
+            An exception-raising decorator.
+        """
+        obj = cls.__new__(cls)
+        obj._min_version = minimum
+        obj._hint = hint
+        return obj
path: narwhals/utils.py
user: login: dangotbanned
body: Great I'll go with this one then for all of them 😄 
created_at: 2025-04-17T18:14:00Z
author_association: MEMBER
diff_hunk: @@ -539,6 +542,27 @@ def is_sqlframe(self: Self) -> bool:
         """
         return self is Implementation.SQLFRAME  # pragma: no cover
 
+    @property
+    def _alias(self) -> LiteralString:
+        """Friendly name for errors.
path: narwhals/utils.py
user: login: FBruzzesi
body: I assume that in practice it rarely happened that a minimum version exception was raised as downstream libraries require higher versions than our minimum ones - at least that's my impression when working on some of them.
created_at: 2025-04-17T18:23:47Z
author_association: MEMBER
diff_hunk: @@ -1779,3 +1803,67 @@ def _not_implemented_error(what: str, who: str, /) -> NotImplementedError:
         "please open an issue at: https://github.com/narwhals-dev/narwhals/issues"
     )
     return NotImplementedError(msg)
+
+
+class requires:  # noqa: N801
+    """Method decorator for raising under certain constraints.
+
+    Arguments:
+        min_version: Minimum backend version.
+        hint: Optional suggested alternative.
+
+    Returns:
+        An exception-raising decorator.
+    """
+
+    # NOTE: Decide how constraints should work
+    # - [x] min_version
+    # - [ ] specific parameters
+    # - [x] optional message
+    def __init__(self, *, min_version: tuple[int, ...], hint: str = "") -> None:
+        self._min_version: tuple[int, ...] = min_version
+        self._hint: str = hint
+        self._wrapped_name: str
+
+    @classmethod
+    def backend_version(cls, minimum: tuple[int, ...], /, hint: str = "") -> Self:
+        """Method decorator for raising below a minimum `_backend_version`.
+
+        Arguments:
+            minimum: Minimum backend version.
+            hint: Optional suggested alternative.
+
+        Returns:
+            An exception-raising decorator.
+        """
+        obj = cls.__new__(cls)
+        obj._min_version = minimum
+        obj._hint = hint
+        return obj
path: narwhals/utils.py
user: login: dangotbanned
body: Resolved in
- (https://github.com/narwhals-dev/narwhals/pull/2371/commits/6e40e915141ac69864d188bcc2b6cf7230d641fc)
- (https://github.com/narwhals-dev/narwhals/pull/2371/commits/d3cedc59293bd79b5c10ff3552f3408d084d8ca5)
created_at: 2025-04-17T18:43:51Z
author_association: MEMBER
diff_hunk: @@ -1808,22 +1808,28 @@ def _not_implemented_error(what: str, who: str, /) -> NotImplementedError:
 class requires:  # noqa: N801
     """Method decorator for raising under certain constraints.
 
-    Arguments:
-        min_version: Minimum backend version.
-        hint: Optional suggested alternative.
+    Attributes:
+        _min_version: Minimum backend version.
+        _hint: Optional suggested alternative.
 
-    Returns:
-        An exception-raising decorator.
+    Examples:
+        >>> from narwhals.utils import requires, Implementation
+        >>> class SomeBackend:
+        ...     _implementation = Implementation.PYARROW
+        ...     _backend_version = 20, 0, 0
+        ...
+        ...     @requires.backend_version((9000, 0, 0))
+        ...     def really_complex_feature(self) -> str:
+        ...         return "hello"
path: narwhals/utils.py
user: login: FBruzzesi
body: 😂 I had a god laugh
created_at: 2025-04-17T19:29:10Z
author_association: MEMBER

PR comments:
user: login: dangotbanned
created_at: 2025-04-13T09:55:12Z
body: Been think about an alternative spelling which could be more easily adapted for other requirements.

<details><summary>Imports</summary>
<p>

```py
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from typing_extensions import Self

    from narwhals._polars.expr import PolarsExpr
```

</p>
</details> 

## Current
### Decorator constructor
```py
class requires:  # noqa: N801
    def __init__(self, *, min_version: tuple[int, ...], hint: str = "") -> None:
        self._min_version: tuple[int, ...] = min_version
        self._hint: str = hint
        self._wrapped_name: str
```
### Usage
```py
class DuckDBExpr:
    @requires(min_version=(1, 3))
    def is_first_distinct(self) -> Self: ...

class PolarsNamespace:
    @requires(
        min_version=(1, 0, 0), hint="Please use `col` for columns selection instead."
    )
    def nth(self, *indices: int) -> PolarsExpr: ...
```

## Alternative
Would be pretty easy to add support for multi-`implementation` cases this way - without it affecting the more common usage.

I also like that we can stay aligned with the `backend_version` name, while still indicating it is a minimum

### Decorator constructor
See (https://github.com/narwhals-dev/narwhals/pull/2371/commits/651cf2f10482f77f73fd0e36de53d46ffbe6e4fb)

<details><summary>Code block</summary>
<p>

```py
class requires:  # noqa: N801
    @classmethod
    def backend_version(cls, min_version: tuple[int, ...], /, hint: str = "") -> Self:
        obj = cls.__new__(cls)
        obj._min_version = min_version
        obj._hint = hint
        return obj
```

</p>
</details> 


### Usage
```py
class DuckDBExpr:
    @requires.backend_version((1, 3))
    def is_first_distinct(self) -> Self: ...

class PolarsNamespace:
    @requires.backend_version(
        (1, 0, 0), "Please use `col` for columns selection instead."
    )
    def nth(self, *indices: int) -> PolarsExpr: ...
```
author_association: MEMBER
user: login: dangotbanned
created_at: 2025-04-17T18:08:49Z
body: > Thanks @dangotbanned , it looks very good!
> 
> I personally like the `requires.backend_version(...)` style, as it leaves more room to expand if needed. Aside such personal preference, I don't have anything to object with the PR itself :)

Thanks @FBruzzesi 😍 
author_association: MEMBER
user: login: MarcoGorelli
created_at: 2025-04-17T19:46:28Z
body: nice, thanks both!
author_association: MEMBER