Repository: narwhals-dev/narwhals (python)

Issue:
bug: `df.filter(c=nw.col('a'))` gives incorrect result for pandas and unclear error message for duckdb
the keywords-syntax in `filter` is meant to be used with literals, not with expressions. e.g. `filter(c=3)` is shorthand for `filter(nw.col('c')==3)`

Can we produce an informative error message, with a hint, if someone writes `df.filter(c=nw.col('a'))`?
`


Pull request description:
fix: Allow exprs in `.filter` constraints
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [x] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Closes #2112 

## 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


Patch:
diff --git a/docs/backcompat.md b/docs/backcompat.md
index 3c20c7e4..e1ffa329 100644
--- a/docs/backcompat.md
+++ b/docs/backcompat.md
@@ -113,6 +113,8 @@ before making any change.
 
 The following are differences between the main Narwhals namespace and `narwhals.stable.v1`:
 
+- Since Narwhals 1.29.0, `LazyFrame.gather_every` has been deprecated from the main namespace.
+
 - Since Narwhals 1.24.1, an empty or all-null object-dtype pandas Series is inferred to
   be of dtype `String`. Previously, it would have been inferred as `Object`.
 
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b9a279be..32893b9a 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -34,6 +34,7 @@ from narwhals.utils import find_stacklevel
 from narwhals.utils import flatten
 from narwhals.utils import generate_repr
 from narwhals.utils import is_sequence_but_not_str
+from narwhals.utils import issue_deprecation_warning
 from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
@@ -201,21 +202,22 @@ class BaseFrame(Generic[_FrameT]):
             and isinstance(predicates[0], list)
             and all(isinstance(x, bool) for x in predicates[0])
         ):
+            from narwhals.functions import col
+
             flat_predicates = flatten(predicates)
             check_expressions_preserve_length(*flat_predicates, function_name="filter")
-            compliant_predicates, _kinds = self._flatten_and_extract(*flat_predicates)
             plx = self.__narwhals_namespace__()
+            compliant_predicates, _kinds = self._flatten_and_extract(*flat_predicates)
+            compliant_constraints = (
+                (col(name) == v)._to_compliant_expr(plx)
+                for name, v in constraints.items()
+            )
             predicate = plx.all_horizontal(
-                *chain(
-                    compliant_predicates,
-                    (plx.col(name) == v for name, v in constraints.items()),
-                )
+                *chain(compliant_predicates, compliant_constraints)
             )
         else:
             predicate = predicates[0]
-        return self._from_compliant_dataframe(
-            self._compliant_frame.filter(predicate),
-        )
+        return self._from_compliant_dataframe(self._compliant_frame.filter(predicate))
 
     def sort(
         self: Self,
@@ -3140,6 +3142,11 @@ class LazyFrame(BaseFrame[FrameT]):
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         r"""Take every nth row in the DataFrame and return as a new DataFrame.
 
+        !!! warning
+            `LazyFrame.gather_every` is deprecated and will be removed in a future version.
+            Note: this will remain available in `narwhals.stable.v1`.
+            See [stable api](../backcompat.md/) for more information.
+
         Arguments:
             n: Gather every *n*-th row.
             offset: Starting index.
@@ -3147,6 +3154,13 @@ class LazyFrame(BaseFrame[FrameT]):
         Returns:
             The LazyFrame containing only the selected rows.
         """
+        msg = (
+            "`LazyFrame.gather_every` is deprecated and will be removed in a future version.\n\n"
+            "Note: this will remain available in `narwhals.stable.v1`.\n"
+            "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
+        )
+        issue_deprecation_warning(msg, _version="1.29.0")
+
         return super().gather_every(n=n, offset=offset)
 
     def unpivot(
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 04b244be..62ceab1f 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1096,7 +1096,7 @@ class Expr:
             "Note: this will remain available in `narwhals.stable.v1`.\n"
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
-        issue_deprecation_warning(msg, _version="1.22.0")
+        issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
@@ -1485,7 +1485,7 @@ class Expr:
             "Note: this will remain available in `narwhals.stable.v1`.\n"
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
-        issue_deprecation_warning(msg, _version="1.22.0")
+        issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).sample(
                 n, fraction=fraction, with_replacement=with_replacement, seed=seed
@@ -1753,7 +1753,7 @@ class Expr:
             "Note: this will remain available in `narwhals.stable.v1`.\n"
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
-        issue_deprecation_warning(msg, _version="1.22.0")
+        issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).head(n),
             self._metadata.with_kind_and_extra_open_window(ExprKind.FILTRATION),
@@ -1781,7 +1781,7 @@ class Expr:
             "Note: this will remain available in `narwhals.stable.v1`.\n"
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
-        issue_deprecation_warning(msg, _version="1.22.0")
+        issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).tail(n),
             self._metadata.with_kind_and_extra_open_window(ExprKind.FILTRATION),
@@ -1876,7 +1876,7 @@ class Expr:
             "Note: this will remain available in `narwhals.stable.v1`.\n"
             "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
         )
-        issue_deprecation_warning(msg, _version="1.22.0")
+        issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset),
             self._metadata.with_kind_and_extra_open_window(ExprKind.FILTRATION),
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index cececde6..8a0e2b13 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -348,6 +348,20 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
         """
         return super().tail(n)
 
+    def gather_every(self: Self, n: int, offset: int = 0) -> Self:
+        r"""Take every nth row in the DataFrame and return as a new DataFrame.
+
+        Arguments:
+            n: Gather every *n*-th row.
+            offset: Starting index.
+
+        Returns:
+            The LazyFrame containing only the selected rows.
+        """
+        return self._from_compliant_dataframe(
+            self._compliant_frame.gather_every(n=n, offset=offset)
+        )
+
 
 class Series(NwSeries[IntoSeriesT]):
     """Narwhals Series, backed by a native series.


Test patch:
diff --git a/tests/expr_and_series/arg_true_test.py b/tests/expr_and_series/arg_true_test.py
index ec7d1d88..35c6b9bd 100644
--- a/tests/expr_and_series/arg_true_test.py
+++ b/tests/expr_and_series/arg_true_test.py
@@ -14,7 +14,9 @@ def test_arg_true(constructor_eager: ConstructorEager) -> None:
     expected = {"a": [1, 2]}
     assert_equal_data(result, expected)
 
-    with pytest.deprecated_call():
+    with pytest.deprecated_call(
+        match="is deprecated and will be removed in a future version"
+    ):
         df.select(nw.col("a").is_null().arg_true())
 
 
diff --git a/tests/expr_and_series/gather_every_test.py b/tests/expr_and_series/gather_every_test.py
index bcda13d8..7725b287 100644
--- a/tests/expr_and_series/gather_every_test.py
+++ b/tests/expr_and_series/gather_every_test.py
@@ -22,7 +22,9 @@ def test_gather_every_expr(
 
     assert_equal_data(result, expected)
 
-    with pytest.deprecated_call():
+    with pytest.deprecated_call(
+        match="is deprecated and will be removed in a future version"
+    ):
         df.select(nw_main.col("a").gather_every(n=n, offset=offset))
 
 
diff --git a/tests/expr_and_series/head_test.py b/tests/expr_and_series/head_test.py
index 952b3de1..8b5af5c7 100644
--- a/tests/expr_and_series/head_test.py
+++ b/tests/expr_and_series/head_test.py
@@ -19,7 +19,9 @@ def test_head(
     expected = {"a": [1, 2]}
     assert_equal_data(result, expected)
 
-    with pytest.deprecated_call():
+    with pytest.deprecated_call(
+        match="is deprecated and will be removed in a future version"
+    ):
         df.select(nw_main.col("a").head(5))
 
 
diff --git a/tests/expr_and_series/sample_test.py b/tests/expr_and_series/sample_test.py
index af1f5665..e60142f9 100644
--- a/tests/expr_and_series/sample_test.py
+++ b/tests/expr_and_series/sample_test.py
@@ -21,7 +21,9 @@ def test_expr_sample(constructor_eager: ConstructorEager) -> None:
     expected_series = (2,)
     assert result_series == expected_series
 
-    with pytest.deprecated_call():
+    with pytest.deprecated_call(
+        match="is deprecated and will be removed in a future version"
+    ):
         df.select(nw_main.col("a").sample(n=2))
 
 
diff --git a/tests/expr_and_series/sort_test.py b/tests/expr_and_series/sort_test.py
index e35514cd..e3e66592 100644
--- a/tests/expr_and_series/sort_test.py
+++ b/tests/expr_and_series/sort_test.py
@@ -30,7 +30,9 @@ def test_sort_expr(
         nw.col("b").sort(descending=descending, nulls_last=nulls_last),
     )
     assert_equal_data(result, expected)
-    with pytest.deprecated_call():
+    with pytest.deprecated_call(
+        match="is deprecated and will be removed in a future version"
+    ):
         df.select(
             "a",
             nw_main.col("b").sort(descending=descending, nulls_last=nulls_last),
diff --git a/tests/expr_and_series/tail_test.py b/tests/expr_and_series/tail_test.py
index 2baabfd9..ac857dee 100644
--- a/tests/expr_and_series/tail_test.py
+++ b/tests/expr_and_series/tail_test.py
@@ -19,7 +19,9 @@ def test_tail(
     expected = {"a": [2, 3]}
     assert_equal_data(result, expected)
 
-    with pytest.deprecated_call():
+    with pytest.deprecated_call(
+        match="is deprecated and will be removed in a future version"
+    ):
         df.select(nw_main.col("a").tail(5))
 
 
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index a9cb8d29..b6921124 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -54,3 +54,17 @@ def test_filter_raise_on_shape_mismatch(constructor: Constructor) -> None:
     df = nw.from_native(constructor(data))
     with pytest.raises((LengthChangingExprError, ShapeError)):
         df.filter(nw.col("b").unique() > 2).lazy().collect()
+
+
+def test_filter_with_constrains(constructor: Constructor) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6]}
+    df = nw.from_native(constructor(data))
+    result_scalar = df.filter(a=3)
+    expected_scalar = {"a": [3], "b": [4]}
+
+    assert_equal_data(result_scalar, expected_scalar)
+
+    result_expr = df.filter(a=nw.col("b") // 3)
+    expected_expr = {"a": [1, 2], "b": [4, 6]}
+
+    assert_equal_data(result_expr, expected_expr)
diff --git a/tests/frame/gather_every_test.py b/tests/frame/gather_every_test.py
index c151f450..b4568671 100644
--- a/tests/frame/gather_every_test.py
+++ b/tests/frame/gather_every_test.py
@@ -1,7 +1,10 @@
 from __future__ import annotations
 
+from contextlib import nullcontext
+
 import pytest
 
+import narwhals as nw_main
 import narwhals.stable.v1 as nw
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
@@ -14,9 +17,23 @@ data = {"a": list(range(10))}
 def test_gather_every(
     constructor: Constructor, n: int, offset: int, request: pytest.FixtureRequest
 ) -> None:
-    if ("pyspark" in str(constructor)) or "duckdb" in str(constructor):
+    if "pyspark" in str(constructor) or "duckdb" in str(constructor):
         request.applymarker(pytest.mark.xfail)
-    df = nw.from_native(constructor(data))
-    result = df.gather_every(n=n, offset=offset)
+    df_v1 = nw.from_native(constructor(data))
+    result = df_v1.gather_every(n=n, offset=offset)
     expected = {"a": data["a"][offset::n]}
     assert_equal_data(result, expected)
+
+    # Test deprecation for LazyFrame in main namespace
+    df_main = nw_main.from_native(constructor(data))
+
+    context = (
+        pytest.deprecated_call(
+            match="is deprecated and will be removed in a future version"
+        )
+        if isinstance(df_main, nw_main.LazyFrame)
+        else nullcontext()
+    )
+
+    with context:
+        df_main.gather_every(n=n, offset=offset)
diff --git a/tests/stable_api_test.py b/tests/stable_api_test.py
index 071065e6..2f0277a1 100644
--- a/tests/stable_api_test.py
+++ b/tests/stable_api_test.py
@@ -122,7 +122,7 @@ def test_lazyframe_docstrings() -> None:
         if item in {"schema", "columns"}:
             # to avoid performance warning
             continue
-        if item in {"tail"}:
+        if item in {"tail", "gather_every"}:
             # deprecated
             continue
         assert remove_docstring_examples(