Repository: narwhals-dev/narwhals
Pull request #2371 (2025-04-11 17:43:30)

chore: `@requires` for backend method constraints
## 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/

dangotbanned: 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
```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
```

### 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: ...
```
dangotbanned: > 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 😍 

Patch:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index efeb851e..302a6cf3 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -35,6 +35,7 @@ from narwhals.utils import generate_temporary_column_name
 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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         )
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         )
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         )
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         )
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         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 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
         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 290333e8..6584d1a8 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -30,6 +30,7 @@ from narwhals._duckdb.utils import when
 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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
         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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
 
     @classmethod
     def from_column_names(
-        cls: type[Self],
+        cls,
         evaluate_column_names: EvalNames[DuckDBLazyFrame],
         /,
         *,
@@ -195,9 +196,7 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
             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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
             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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
             )
         )
 
+    @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 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
             )
         )
 
+    @requires.backend_version((1, 3))
     def rolling_var(
         self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
@@ -659,6 +661,7 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
             )
         )
 
+    @requires.backend_version((1, 3))
     def rolling_std(
         self, window_size: int, *, min_samples: int, center: bool, ddof: int
     ) -> Self:
@@ -691,13 +694,15 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "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 6258e421..445eb28c 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -23,6 +23,7 @@ from narwhals.utils import _into_arrow_table
 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 @@ class PolarsDataFrame:
             )
         )
 
+    @requires.backend_version((1,))
     def pivot(
         self,
         on: Sequence[str],
@@ -424,9 +426,6 @@ class PolarsDataFrame:
         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 3d0761a5..fa84145a 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -13,6 +13,7 @@ from narwhals._polars.utils import extract_args_kwargs
 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 @@ class PolarsExpr:
             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 @@ class PolarsExpr:
         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 @@ class PolarsExpr:
         *,
         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 27094362..54af4f51 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -19,6 +19,7 @@ from narwhals._polars.utils import narwhals_to_native_dtype
 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 @@ class PolarsNamespace:
             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 ca9c0410..971ad37c 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -18,6 +18,7 @@ from narwhals._polars.utils import narwhals_to_native_dtype
 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 @@ class PolarsSeries:
         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 @@ class PolarsSeries:
             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 @@ class PolarsSeries:
 
         return self._with_native(native_result)
 
+    @requires.backend_version((1,))
     def rolling_var(
         self: Self,
         window_size: int,
@@ -348,22 +348,18 @@ class PolarsSeries:
         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 @@ class PolarsSeries:
         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 @@ class PolarsSeries:
             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 @@ class PolarsSeries:
             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 d268a022..54d691b5 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
     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 @@ if TYPE_CHECKING:
 
 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 @@ class Implementation(Enum):
         """
         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]


Test patch:
diff --git a/tests/expr_and_series/nth_test.py b/tests/expr_and_series/nth_test.py
index 7e185f76..22f44934 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 57fe764a..72e975af 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -21,9 +21,11 @@ import narwhals as nw
 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 test_deprecate_native_namespace() -> None:
         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")