Repository: narwhals-dev/narwhals

Issue:
docs: bikeshed on good name for example function in docs
Related to https://github.com/narwhals-dev/narwhals/pull/1406#issuecomment-2486295148

Currently the docstring examples of use `func` or `my_library_agnostic_function`

Let's discuss on Friday's call what to name the functions

In the meantime I'm pretty keen on not having `narwhalify` in the docstrings, so we can showcase type annotations and keep things simple and non-magical for newcomers

Discussion:

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 2ae8d761..83a8c57b 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from itertools import chain
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
@@ -465,19 +466,21 @@ class ArrowDataFrame:
         row_indices = pa.array(range(df.num_rows))
         return self._from_native_frame(df.append_column(name, row_indices))
 
-    def filter(
-        self,
-        *predicates: IntoArrowExpr,
-    ) -> Self:
+    def filter(self, *predicates: IntoArrowExpr, **constraints: Any) -> Self:
         if (
             len(predicates) == 1
             and isinstance(predicates[0], list)
             and all(isinstance(x, bool) for x in predicates[0])
+            and not constraints
         ):
             mask = predicates[0]
         else:
             plx = self.__narwhals_namespace__()
-            expr = plx.all_horizontal(*predicates)
+            expr = plx.all_horizontal(
+                *chain(
+                    predicates, (plx.col(name) == v for name, v in constraints.items())
+                )
+            )
             # Safety: all_horizontal's expression only returns a single column.
             mask = expr._call(self)[0]._native_series
         return self._from_native_frame(self._native_frame.filter(mask))
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 82f0aa52..96a6ce84 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from itertools import chain
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
@@ -86,14 +87,12 @@ class DaskLazyFrame:
     def columns(self) -> list[str]:
         return self._native_frame.columns.tolist()  # type: ignore[no-any-return]
 
-    def filter(
-        self,
-        *predicates: DaskExpr,
-    ) -> Self:
+    def filter(self, *predicates: DaskExpr, **constraints: Any) -> Self:
         if (
             len(predicates) == 1
             and isinstance(predicates[0], list)
             and all(isinstance(x, bool) for x in predicates[0])
+            and not constraints
         ):
             msg = (
                 "`LazyFrame.filter` is not supported for Dask backend with boolean masks."
@@ -103,7 +102,9 @@ class DaskLazyFrame:
         from narwhals._dask.namespace import DaskNamespace
 
         plx = DaskNamespace(backend_version=self._backend_version, dtypes=self._dtypes)
-        expr = plx.all_horizontal(*predicates)
+        expr = plx.all_horizontal(
+            *chain(predicates, (plx.col(name) == v for name, v in constraints.items()))
+        )
         # Safety: all_horizontal's expression only returns a single column.
         mask = expr._call(self)[0]
         return self._from_native_frame(self._native_frame.loc[mask])
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index de3eaacf..96bfe936 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from itertools import chain
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
@@ -379,19 +380,21 @@ class PandasLikeDataFrame:
     def row(self, row: int) -> tuple[Any, ...]:
         return tuple(x for x in self._native_frame.iloc[row])
 
-    def filter(
-        self,
-        *predicates: IntoPandasLikeExpr,
-    ) -> Self:
+    def filter(self, *predicates: IntoPandasLikeExpr, **constraints: Any) -> Self:
         plx = self.__narwhals_namespace__()
         if (
             len(predicates) == 1
             and isinstance(predicates[0], list)
             and all(isinstance(x, bool) for x in predicates[0])
+            and not constraints
         ):
             _mask = predicates[0]
         else:
-            expr = plx.all_horizontal(*predicates)
+            expr = plx.all_horizontal(
+                *chain(
+                    predicates, (plx.col(name) == v for name, v in constraints.items())
+                )
+            )
             # Safety: all_horizontal's expression only returns a single column.
             mask = expr._call(self)[0]
             _mask = validate_dataframe_comparand(self._native_frame.index, mask)
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index e8dc999a..8bdb195f 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -155,15 +155,19 @@ class BaseFrame(Generic[FrameT]):
             )
         )
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
+    def filter(
+        self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool], **constraints: Any
+    ) -> Self:
         if not (
             len(predicates) == 1
             and isinstance(predicates[0], list)
             and all(isinstance(x, bool) for x in predicates[0])
         ):
-            predicates, _ = self._flatten_and_extract(*predicates)
+            predicates, constraints = self._flatten_and_extract(
+                *predicates, **constraints
+            )
         return self._from_compliant_dataframe(
-            self._compliant_frame.filter(*predicates),
+            self._compliant_frame.filter(*predicates, **constraints),
         )
 
     def sort(
@@ -958,21 +962,24 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> from narwhals.typing import IntoDataFrame
+            >>> from typing import Any
+            >>>
             >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
 
             Let's define a library-agnostic function to get the second row.
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_row(df_native: IntoDataFrame) -> tuple[Any, ...]:
+            ...     df = nw.from_native(df_native)
             ...     return df.row(1)
 
             We can then pass pandas / Polars / any other supported library:
 
-            >>> func(df_pd)
+            >>> agnostic_row(df_pd)
             (2, 5)
-            >>> func(df_pl)
+            >>> agnostic_row(df_pl)
             (2, 5)
         """
         return self._compliant_frame.row(index)  # type: ignore[no-any-return]
@@ -985,26 +992,30 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>>
             >>> data = {"a": [1, 2, 3], "ba": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_pipe(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
             ...     return df.pipe(
-            ...         lambda _df: _df.select([x for x in _df.columns if len(x) == 1])
+            ...         lambda _df: _df.select(
+            ...             [x for x in _df.columns if len(x) == 1]
+            ...         ).to_native()
             ...     )
 
             We can then pass either pandas or Polars:
 
-            >>> func(df_pd)
+            >>> agnostic_pipe(df_pd)
                a
             0  1
             1  2
             2  3
-            >>> func(df_pl)
+            >>> agnostic_pipe(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ a   │
@@ -1033,22 +1044,24 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>>
             >>> data = {"a": [1.0, 2.0, None], "ba": [1.0, None, 2.0]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop_nulls()
+            >>> def agnostic_drop_nulls(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.drop_nulls().to_native()
 
             We can then pass either pandas or Polars:
 
-            >>> func(df_pd)
+            >>> agnostic_drop_nulls(df_pd)
                  a   ba
             0  1.0  1.0
-            >>> func(df_pl)
+            >>> agnostic_drop_nulls(df_pl)
             shape: (1, 2)
             ┌─────┬─────┐
             │ a   ┆ ba  │
@@ -1069,24 +1082,26 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>>
             >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_row_index()
+            >>> def agnostic_with_row_index(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_row_index().to_native()
 
             We can then pass either pandas or Polars:
 
-            >>> func(df_pd)
+            >>> agnostic_with_row_index(df_pd)
                index  a  b
             0      0  1  4
             1      1  2  5
             2      2  3  6
-            >>> func(df_pl)
+            >>> agnostic_with_row_index(df_pl)
             shape: (3, 3)
             ┌───────┬─────┬─────┐
             │ index ┆ a   ┆ b   │
@@ -1108,6 +1123,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.schema import Schema
+            >>> from narwhals.typing import IntoFrame
+            >>>
             >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6.0, 7.0, 8.0],
@@ -1118,17 +1136,17 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_schema(df_native: IntoFrame) -> Schema:
+            ...     df = nw.from_native(df_native)
             ...     return df.schema
 
-            You can pass either pandas or Polars to `func`:
+            You can pass either pandas or Polars to `agnostic_schema`:
 
-            >>> df_pd_schema = func(df_pd)
+            >>> df_pd_schema = agnostic_schema(df_pd)
             >>> df_pd_schema
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
 
-            >>> df_pl_schema = func(df_pl)
+            >>> df_pl_schema = agnostic_schema(df_pl)
             >>> df_pl_schema
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
         """
@@ -1141,6 +1159,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.schema import Schema
+            >>> from narwhals.typing import IntoFrame
+            >>>
             >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6.0, 7.0, 8.0],
@@ -1151,17 +1172,17 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_collect_schema(df_native: IntoFrame) -> Schema:
+            ...     df = nw.from_native(df_native)
             ...     return df.collect_schema()
 
-            You can pass either pandas or Polars to `func`:
+            You can pass either pandas or Polars to `agnostic_collect_schema`:
 
-            >>> df_pd_schema = func(df_pd)
+            >>> df_pd_schema = agnostic_collect_schema(df_pd)
             >>> df_pd_schema
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
 
-            >>> df_pl_schema = func(df_pl)
+            >>> df_pl_schema = agnostic_collect_schema(df_pl)
             >>> df_pl_schema
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
         """
@@ -1176,6 +1197,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrame
+            >>>
             >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(df)
             >>> df_pl = pl.DataFrame(df)
@@ -1183,17 +1206,17 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_columns(df_native: IntoFrame) -> list[str]:
+            ...     df = nw.from_native(df_native)
             ...     return df.columns
 
             We can pass any supported library such as pandas, Polars, or PyArrow to `func`:
 
-            >>> func(df_pd)
+            >>> agnostic_columns(df_pd)
             ['foo', 'bar', 'ham']
-            >>> func(df_pl)
+            >>> agnostic_columns(df_pl)
             ['foo', 'bar', 'ham']
-            >>> func(df_pa)
+            >>> agnostic_columns(df_pa)
             ['foo', 'bar', 'ham']
         """
         return super().columns
@@ -1757,7 +1780,9 @@ class DataFrame(BaseFrame[DataFrameT]):
         """
         return super().unique(subset, keep=keep, maintain_order=maintain_order)
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
+    def filter(
+        self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool], **constraints: Any
+    ) -> Self:
         r"""Filter the rows in the DataFrame based on one or more predicate expressions.
 
         The original order of the remaining rows is preserved.
@@ -1765,11 +1790,16 @@ class DataFrame(BaseFrame[DataFrameT]):
         Arguments:
             *predicates: Expression(s) that evaluates to a boolean Series. Can
                 also be a (single!) boolean list.
+            **constraints: Column filters; use `name = value` to filter columns by the supplied value.
+                Each constraint will behave the same as `nw.col(name).eq(value)`, and will be implicitly
+                joined with the other filter conditions using &.
 
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrame
+            >>>
             >>> df = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6, 7, 8],
@@ -1781,17 +1811,17 @@ class DataFrame(BaseFrame[DataFrameT]):
             Let's define a dataframe-agnostic function in which we filter on
             one condition.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter(nw.col("foo") > 1)
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     return df.filter(nw.col("foo") > 1).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass either pandas or Polars to `agnostic_filter`:
 
-            >>> func(df_pd)
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             1    2    7   b
             2    3    8   c
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (2, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1804,13 +1834,13 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             Filter on multiple conditions, combined with and/or operators:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a"))
-            >>> func(df_pd)
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a")).to_native()
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             0    1    6   a
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1820,14 +1850,17 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 1   ┆ 6   ┆ a   │
             └─────┴─────┴─────┘
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") == 1) | (nw.col("ham") == "c"))
-            >>> func(df_pd)
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     dframe = df.filter(
+            ...         (nw.col("foo") == 1) | (nw.col("ham") == "c")
+            ...     ).to_native()
+            ...     return dframe
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             0    1    6   a
             2    3    8   c
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (2, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1840,17 +1873,17 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             Provide multiple filters using `*args` syntax:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
             ...     dframe = df.filter(
             ...         nw.col("foo") <= 2,
             ...         ~nw.col("ham").is_in(["b", "c"]),
-            ...     )
+            ...     ).to_native()
             ...     return dframe
-            >>> func(df_pd)
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             0    1    6   a
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1859,8 +1892,26 @@ class DataFrame(BaseFrame[DataFrameT]):
             ╞═════╪═════╪═════╡
             │ 1   ┆ 6   ┆ a   │
             └─────┴─────┴─────┘
+
+            Provide multiple filters using `**kwargs` syntax:
+
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     return df.filter(foo=2, ham="b").to_native()
+            >>> agnostic_filter(df_pd)
+               foo  bar ham
+            1    2    7   b
+            >>> agnostic_filter(df_pl)
+            shape: (1, 3)
+            ┌─────┬─────┬─────┐
+            │ foo ┆ bar ┆ ham │
+            │ --- ┆ --- ┆ --- │
+            │ i64 ┆ i64 ┆ str │
+            ╞═════╪═════╪═════╡
+            │ 2   ┆ 7   ┆ b   │
+            └─────┴─────┴─────┘
         """
-        return super().filter(*predicates)
+        return super().filter(*predicates, **constraints)
 
     def group_by(
         self, *keys: str | Iterable[str], drop_null_keys: bool = False
@@ -2952,24 +3003,26 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>>
             >>> data = {"a": [1, 2, 3], "ba": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.LazyFrame(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.pipe(lambda _df: _df.select("a"))
+            >>> def agnostic_pipe(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.pipe(lambda _df: _df.select("a")).to_native()
 
             We can then pass either pandas or Polars:
 
-            >>> func(df_pd)
+            >>> agnostic_pipe(df_pd)
                a
             0  1
             1  2
             2  3
-            >>> func(df_pl).collect()
+            >>> agnostic_pipe(df_pl).collect()
             shape: (3, 1)
             ┌─────┐
             │ a   │
@@ -2998,22 +3051,24 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>>
             >>> data = {"a": [1.0, 2.0, None], "ba": [1.0, None, 2.0]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.LazyFrame(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop_nulls()
+            >>> def agnostic_drop_nulls(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.drop_nulls().to_native()
 
             We can then pass either pandas or Polars:
 
-            >>> func(df_pd)
+            >>> agnostic_drop_nulls(df_pd)
                  a   ba
             0  1.0  1.0
-            >>> func(df_pl).collect()
+            >>> agnostic_drop_nulls(df_pl).collect()
             shape: (1, 2)
             ┌─────┬─────┐
             │ a   ┆ ba  │
@@ -3032,24 +3087,26 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import polars as pl
             >>> import pandas as pd
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>>
             >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.LazyFrame(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_row_index()
+            >>> def agnostic_with_row_index(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_row_index().to_native()
 
             We can then pass either pandas or Polars:
 
-            >>> func(df_pd)
+            >>> agnostic_with_row_index(df_pd)
                index  a  b
             0      0  1  4
             1      1  2  5
             2      2  3  6
-            >>> func(df_pl).collect()
+            >>> agnostic_with_row_index(df_pl).collect()
             shape: (3, 3)
             ┌───────┬─────┬─────┐
             │ index ┆ a   ┆ b   │
@@ -3110,21 +3167,23 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrame
+            >>>
             >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(df)
             >>> lf_pl = pl.LazyFrame(df)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_columns(df_native: IntoFrame) -> list[str]:
+            ...     df = nw.from_native(df_native)
             ...     return df.columns
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass either pandas or Polars to `agnostic_columns`:
 
-            >>> func(df_pd)
+            >>> agnostic_columns(df_pd)
             ['foo', 'bar', 'ham']
-            >>> func(lf_pl)  # doctest: +SKIP
+            >>> agnostic_columns(lf_pl)  # doctest: +SKIP
             ['foo', 'bar', 'ham']
         """
         return super().columns
@@ -3665,7 +3724,9 @@ class LazyFrame(BaseFrame[FrameT]):
         """
         return super().unique(subset, keep=keep, maintain_order=maintain_order)
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
+    def filter(
+        self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool], **constraints: Any
+    ) -> Self:
         r"""Filter the rows in the LazyFrame based on a predicate expression.
 
         The original order of the remaining rows is preserved.
@@ -3673,11 +3734,16 @@ class LazyFrame(BaseFrame[FrameT]):
         Arguments:
             *predicates: Expression that evaluates to a boolean Series. Can
                 also be a (single!) boolean list.
+            **constraints: Column filters; use `name = value` to filter columns by the supplied value.
+                Each constraint will behave the same as `nw.col(name).eq(value)`, and will be implicitly
+                joined with the other filter conditions using &.
 
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrame
+            >>>
             >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6, 7, 8],
@@ -3690,17 +3756,17 @@ class LazyFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function in which we filter on
             one condition.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter(nw.col("foo") > 1)
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     return df.filter(nw.col("foo") > 1).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass either pandas or Polars to `agnostic_filter`:
 
-            >>> func(df_pd)
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             1    2    7   b
             2    3    8   c
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (2, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3710,7 +3776,7 @@ class LazyFrame(BaseFrame[FrameT]):
             │ 2   ┆ 7   ┆ b   │
             │ 3   ┆ 8   ┆ c   │
             └─────┴─────┴─────┘
-            >>> func(lf_pl).collect()
+            >>> agnostic_filter(lf_pl).collect()
             shape: (2, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3723,13 +3789,14 @@ class LazyFrame(BaseFrame[FrameT]):
 
             Filter on multiple conditions:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a"))
-            >>> func(df_pd)
+
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a")).to_native()
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             0    1    6   a
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3738,7 +3805,7 @@ class LazyFrame(BaseFrame[FrameT]):
             ╞═════╪═════╪═════╡
             │ 1   ┆ 6   ┆ a   │
             └─────┴─────┴─────┘
-            >>> func(lf_pl).collect()
+            >>> agnostic_filter(lf_pl).collect()
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3750,17 +3817,17 @@ class LazyFrame(BaseFrame[FrameT]):
 
             Provide multiple filters using `*args` syntax:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
             ...     dframe = df.filter(
             ...         nw.col("foo") == 1,
             ...         nw.col("ham") == "a",
             ...     )
-            ...     return dframe
-            >>> func(df_pd)
+            ...     return dframe.to_native()
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             0    1    6   a
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3769,7 +3836,7 @@ class LazyFrame(BaseFrame[FrameT]):
             ╞═════╪═════╪═════╡
             │ 1   ┆ 6   ┆ a   │
             └─────┴─────┴─────┘
-            >>> func(lf_pl).collect()
+            >>> agnostic_filter(lf_pl).collect()
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3781,14 +3848,17 @@ class LazyFrame(BaseFrame[FrameT]):
 
             Filter on an OR condition:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") == 1) | (nw.col("ham") == "c"))
-            >>> func(df_pd)
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     dframe = df.filter(
+            ...         (nw.col("foo") == 1) | (nw.col("ham") == "c")
+            ...     ).to_native()
+            ...     return dframe
+            >>> agnostic_filter(df_pd)
                foo  bar ham
             0    1    6   a
             2    3    8   c
-            >>> func(df_pl)
+            >>> agnostic_filter(df_pl)
             shape: (2, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3798,7 +3868,7 @@ class LazyFrame(BaseFrame[FrameT]):
             │ 1   ┆ 6   ┆ a   │
             │ 3   ┆ 8   ┆ c   │
             └─────┴─────┴─────┘
-            >>> func(lf_pl).collect()
+            >>> agnostic_filter(lf_pl).collect()
             shape: (2, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -3808,8 +3878,35 @@ class LazyFrame(BaseFrame[FrameT]):
             │ 1   ┆ 6   ┆ a   │
             │ 3   ┆ 8   ┆ c   │
             └─────┴─────┴─────┘
+
+            Provide multiple filters using `**kwargs` syntax:
+
+            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            ...     df = nw.from_native(df_native)
+            ...     return df.filter(foo=2, ham="b").to_native()
+            >>> agnostic_filter(df_pd)
+               foo  bar ham
+            1    2    7   b
+            >>> agnostic_filter(df_pl)
+            shape: (1, 3)
+            ┌─────┬─────┬─────┐
+            │ foo ┆ bar ┆ ham │
+            │ --- ┆ --- ┆ --- │
+            │ i64 ┆ i64 ┆ str │
+            ╞═════╪═════╪═════╡
+            │ 2   ┆ 7   ┆ b   │
+            └─────┴─────┴─────┘
+            >>> agnostic_filter(lf_pl).collect()
+            shape: (1, 3)
+            ┌─────┬─────┬─────┐
+            │ foo ┆ bar ┆ ham │
+            │ --- ┆ --- ┆ --- │
+            │ i64 ┆ i64 ┆ str │
+            ╞═════╪═════╪═════╡
+            │ 2   ┆ 7   ┆ b   │
+            └─────┴─────┴─────┘
         """
-        return super().filter(*predicates)
+        return super().filter(*predicates, **constraints)
 
     def group_by(
         self, *keys: str | Iterable[str], drop_null_keys: bool = False
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 6802b415..afa998db 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1611,7 +1611,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._call(plx).filter(
-                *[extract_compliant(plx, pred) for pred in flatten(predicates)]
+                *[extract_compliant(plx, pred) for pred in flatten(predicates)],
             )
         )
 


Test patch:
diff --git a/tests/expr_and_series/filter_test.py b/tests/expr_and_series/filter_test.py
index b13370c8..1d2b8b96 100644
--- a/tests/expr_and_series/filter_test.py
+++ b/tests/expr_and_series/filter_test.py
@@ -32,3 +32,12 @@ def test_filter_series(constructor_eager: ConstructorEager) -> None:
     result_s = df["a"].filter([True, False, False, False, False])
     expected = {"a": [0]}
     assert_equal_data({"a": result_s}, expected)
+
+
+def test_filter_constraints(constructor: Constructor) -> None:
+    df = nw.from_native(constructor(data))
+    result_added = df.filter(nw.col("i") < 4, b=3)
+    expected = {"i": [2], "a": [2], "b": [3], "c": [3]}
+    assert_equal_data(result_added, expected)
+    result_only = df.filter(i=2, b=3)
+    assert_equal_data(result_only, expected)