From 81c6263e17858efdc1c09cbc1af08f32aa1df333 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 13 May 2026 12:41:48 -0700 Subject: [PATCH 1/7] feat(selectors): add `Selector.__xor__` for symmetric difference --- src/narwhals/_compliant/selectors.py | 39 ++++++++++++++++++++++++++++ src/narwhals/_polars/expr.py | 9 +++++++ src/narwhals/selectors.py | 19 ++++++++++++++ tests/selectors_test.py | 11 ++++++++ 4 files changed, 78 insertions(+) diff --git a/src/narwhals/_compliant/selectors.py b/src/narwhals/_compliant/selectors.py index 71bb3d6988..b4605d6a84 100644 --- a/src/narwhals/_compliant/selectors.py +++ b/src/narwhals/_compliant/selectors.py @@ -310,6 +310,45 @@ def names(df: FrameT) -> Sequence[str]: return self.selectors._selector.from_callables(series, names, context=self) return self._to_expr() & other + @overload # type: ignore[override] + def __xor__(self, other: Self) -> Self: ... + @overload + def __xor__( + self, other: CompliantExpr[FrameT, SeriesOrExprT] + ) -> CompliantExpr[FrameT, SeriesOrExprT]: ... + def __xor__( + self, other: SelectorOrExpr[FrameT, SeriesOrExprT] + ) -> SelectorOrExpr[FrameT, SeriesOrExprT]: + if self._is_selector(other): + + def series(df: FrameT) -> Sequence[SeriesOrExprT]: + lhs_names, rhs_names = _eval_lhs_rhs(df, self, other) + return [ + *( + x + for x, name in zip(self(df), lhs_names, strict=True) + if name not in rhs_names + ), + *( + x + for x, name in zip(other(df), rhs_names, strict=True) + if name not in lhs_names + ), + ] + + def names(df: FrameT) -> Sequence[str]: + lhs_names, rhs_names = _eval_lhs_rhs(df, self, other) + return [ + *(x for x in lhs_names if x not in rhs_names), + *(x for x in rhs_names if x not in lhs_names), + ] + + return self.selectors._selector.from_callables(series, names, context=self) + msg = ( + f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')" + ) + raise TypeError(msg) + def __invert__(self) -> CompliantSelector[FrameT, SeriesOrExprT]: return self.selectors.all() - self diff --git a/src/narwhals/_polars/expr.py b/src/narwhals/_polars/expr.py index f4f0cc0ea9..ae7a9c6a0d 100644 --- a/src/narwhals/_polars/expr.py +++ b/src/narwhals/_polars/expr.py @@ -272,6 +272,15 @@ def __and__(self, other: PolarsExpr) -> Self: def __or__(self, other: PolarsExpr) -> Self: return self._with_native(self.native.__or__(extract_native(other))) + def __xor__(self, other: PolarsExpr) -> Self: + if self._backend_version >= (1,): + return self._with_native(self.native.__xor__(extract_native(other))) + # Polars added Selector.__xor__ in 1.0.0; emulate via existing set ops. + other_native = extract_native(other) + return self._with_native( + self.native.__or__(other_native).__sub__(self.native.__and__(other_native)) + ) + def __add__(self, other: Any) -> Self: return self._with_native(self.native.__add__(extract_native(other))) diff --git a/src/narwhals/selectors.py b/src/narwhals/selectors.py index 67c7e3abfc..df81b792c4 100644 --- a/src/narwhals/selectors.py +++ b/src/narwhals/selectors.py @@ -56,6 +56,22 @@ def __and__(self, other: Any) -> Expr: # type: ignore[override] ExprNode(ExprKind.ELEMENTWISE, "__and__", exprs=(other,), str_as_lit=True) ) + def __xor__(self, other: Any) -> Expr: # type: ignore[override] + if isinstance(other, Selector): + return self._append_node( + ExprNode( + ExprKind.ELEMENTWISE, + "__xor__", + exprs=(other,), + str_as_lit=True, + allow_multi_output=True, + ) + ) + msg = ( + f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')" + ) + raise TypeError(msg) + def __rsub__(self, other: Any) -> NoReturn: raise NotImplementedError @@ -65,6 +81,9 @@ def __rand__(self, other: Any) -> NoReturn: def __ror__(self, other: Any) -> NoReturn: raise NotImplementedError + def __rxor__(self, other: Any) -> NoReturn: + raise NotImplementedError + def by_dtype(*dtypes: DType | type[DType] | Iterable[DType | type[DType]]) -> Selector: """Select columns based on their dtype. diff --git a/tests/selectors_test.py b/tests/selectors_test.py index be3d9d594c..231ed7756d 100644 --- a/tests/selectors_test.py +++ b/tests/selectors_test.py @@ -207,6 +207,10 @@ def test_datetime_no_tz(constructor: Constructor) -> None: (ncs.boolean() & True, ["d"]), (ncs.boolean() | True, ["d"]), (ncs.numeric() - 1, ["a", "c"]), + (ncs.numeric() ^ ncs.boolean(), ["a", "c", "d"]), + (ncs.numeric() ^ ncs.by_dtype(nw.Int64), ["c"]), + (ncs.by_dtype(nw.Int64) ^ ncs.numeric(), ["c"]), + (ncs.numeric() ^ ncs.numeric(), []), (ncs.all(), ["a", "b", "c", "d"]), ], ) @@ -247,6 +251,8 @@ def test_set_ops_invalid(constructor: Constructor) -> None: df.select(1 | ncs.numeric()) with pytest.raises((NotImplementedError, ValueError)): df.select(1 & ncs.numeric()) + with pytest.raises((NotImplementedError, TypeError, ValueError)): + df.select(1 ^ ncs.numeric()) with pytest.raises( TypeError, @@ -254,6 +260,11 @@ def test_set_ops_invalid(constructor: Constructor) -> None: ): df.select(ncs.boolean() + ncs.numeric()) + with pytest.raises( + TypeError, match=re.escape("unsupported operand type(s) for op: ('Selector' ^ ") + ): + df.select(ncs.boolean() ^ 1) + @pytest.mark.skipif(is_windows(), reason="windows is what it is") def test_tz_aware(constructor: Constructor, request: pytest.FixtureRequest) -> None: From 35e9fcfaa6cbb2b1ca51743274dafcb3869803d7 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 18 May 2026 16:26:41 -0700 Subject: [PATCH 2/7] fix(selectors): resolve typing and coverage CI failures on __xor__ --- src/narwhals/_compliant/selectors.py | 10 +++++----- src/narwhals/_polars/expr.py | 14 ++++++++------ src/narwhals/selectors.py | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/narwhals/_compliant/selectors.py b/src/narwhals/_compliant/selectors.py index b4605d6a84..c649e9a4db 100644 --- a/src/narwhals/_compliant/selectors.py +++ b/src/narwhals/_compliant/selectors.py @@ -310,7 +310,7 @@ def names(df: FrameT) -> Sequence[str]: return self.selectors._selector.from_callables(series, names, context=self) return self._to_expr() & other - @overload # type: ignore[override] + @overload def __xor__(self, other: Self) -> Self: ... @overload def __xor__( @@ -344,10 +344,10 @@ def names(df: FrameT) -> Sequence[str]: ] return self.selectors._selector.from_callables(series, names, context=self) - msg = ( - f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')" - ) - raise TypeError(msg) + # The narwhals-level Selector.__xor__ rejects non-selectors before reaching + # here, so this branch is a defensive guard only. + msg = f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')" # pragma: no cover + raise TypeError(msg) # pragma: no cover def __invert__(self) -> CompliantSelector[FrameT, SeriesOrExprT]: return self.selectors.all() - self diff --git a/src/narwhals/_polars/expr.py b/src/narwhals/_polars/expr.py index ae7a9c6a0d..be6ad93b74 100644 --- a/src/narwhals/_polars/expr.py +++ b/src/narwhals/_polars/expr.py @@ -273,13 +273,15 @@ def __or__(self, other: PolarsExpr) -> Self: return self._with_native(self.native.__or__(extract_native(other))) def __xor__(self, other: PolarsExpr) -> Self: - if self._backend_version >= (1,): - return self._with_native(self.native.__xor__(extract_native(other))) - # Polars added Selector.__xor__ in 1.0.0; emulate via existing set ops. other_native = extract_native(other) - return self._with_native( - self.native.__or__(other_native).__sub__(self.native.__and__(other_native)) - ) + if self._backend_version < (1,): # pragma: no cover + # Polars added Selector.__xor__ in 1.0.0; emulate via existing set ops. + return self._with_native( + self.native.__or__(other_native).__sub__( + self.native.__and__(other_native) + ) + ) + return self._with_native(self.native.__xor__(other_native)) def __add__(self, other: Any) -> Self: return self._with_native(self.native.__add__(extract_native(other))) diff --git a/src/narwhals/selectors.py b/src/narwhals/selectors.py index df81b792c4..a8d06c08ef 100644 --- a/src/narwhals/selectors.py +++ b/src/narwhals/selectors.py @@ -56,7 +56,7 @@ def __and__(self, other: Any) -> Expr: # type: ignore[override] ExprNode(ExprKind.ELEMENTWISE, "__and__", exprs=(other,), str_as_lit=True) ) - def __xor__(self, other: Any) -> Expr: # type: ignore[override] + def __xor__(self, other: Any) -> Expr: if isinstance(other, Selector): return self._append_node( ExprNode( From c8e6cf97156c1bc059e25167bb622f4161c8f908 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Tue, 19 May 2026 20:13:28 -0700 Subject: [PATCH 3/7] feat(expr): add __xor__ for symmetric difference --- src/narwhals/_arrow/series.py | 6 ++++++ src/narwhals/_compliant/expr.py | 3 +++ src/narwhals/_compliant/series.py | 2 ++ src/narwhals/_dask/expr.py | 1 + src/narwhals/_pandas_like/series.py | 6 ++++++ src/narwhals/_polars/series.py | 4 ++++ src/narwhals/_sql/expr.py | 7 +++++++ src/narwhals/expr.py | 6 ++++++ src/narwhals/series.py | 10 ++++++++++ tests/expr_and_series/operators_test.py | 24 ++++++++++++++++++++++-- 10 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/narwhals/_arrow/series.py b/src/narwhals/_arrow/series.py index 19f003f868..20e45ae57f 100644 --- a/src/narwhals/_arrow/series.py +++ b/src/narwhals/_arrow/series.py @@ -256,6 +256,12 @@ def __or__(self, other: Any) -> Self: def __ror__(self, other: Any) -> Self: return self._with_binary_right(pc.or_kleene, other) + def __xor__(self, other: Any) -> Self: + return self._with_binary(pc.xor, other) + + def __rxor__(self, other: Any) -> Self: + return self._with_binary_right(pc.xor, other) + def __add__(self, other: Any) -> Self: return self._with_binary(pc.add, other) diff --git a/src/narwhals/_compliant/expr.py b/src/narwhals/_compliant/expr.py index f3d3eeeb42..a204009de8 100644 --- a/src/narwhals/_compliant/expr.py +++ b/src/narwhals/_compliant/expr.py @@ -471,6 +471,9 @@ def __and__(self, other: Self) -> Self: def __or__(self, other: Self) -> Self: return self._with_binary("__or__", other) + def __xor__(self, other: Self) -> Self: + return self._with_binary("__xor__", other) + def __add__(self, other: Self) -> Self: return self._with_binary("__add__", other) diff --git a/src/narwhals/_compliant/series.py b/src/narwhals/_compliant/series.py index 077e6a86ad..45a06bcf2a 100644 --- a/src/narwhals/_compliant/series.py +++ b/src/narwhals/_compliant/series.py @@ -126,6 +126,8 @@ def __radd__(self, other: Any) -> Self: ... def __rand__(self, other: Any) -> Self: ... def __rmul__(self, other: Any) -> Self: ... def __ror__(self, other: Any) -> Self: ... + def __rxor__(self, other: Any) -> Self: ... + def __xor__(self, other: Any) -> Self: ... def all(self) -> bool: ... def any(self) -> bool: ... def any_value(self, *, ignore_nulls: bool) -> PythonLiteral: ... diff --git a/src/narwhals/_dask/expr.py b/src/narwhals/_dask/expr.py index b6e5d4d844..ca5be16721 100644 --- a/src/narwhals/_dask/expr.py +++ b/src/narwhals/_dask/expr.py @@ -95,6 +95,7 @@ class DaskExpr( __le__ = simple_binary("__le__") __and__ = simple_binary("__and__") __or__ = simple_binary("__or__") + __xor__ = simple_binary("__xor__") __rsub__ = trivial_binary_right(lambda x, y: x - y) __rtruediv__ = trivial_binary_right(lambda x, y: x / y) __rpow__ = trivial_binary_right(lambda x, y: x**y) diff --git a/src/narwhals/_pandas_like/series.py b/src/narwhals/_pandas_like/series.py index 6917061519..98f5e5fd62 100644 --- a/src/narwhals/_pandas_like/series.py +++ b/src/narwhals/_pandas_like/series.py @@ -455,6 +455,12 @@ def __or__(self, other: Any) -> Self: def __ror__(self, other: Any) -> Self: return self._with_binary_right(operator.or_, other) + def __xor__(self, other: Any) -> Self: + return self._with_binary(operator.xor, other) + + def __rxor__(self, other: Any) -> Self: + return self._with_binary_right(operator.xor, other) + def __add__(self, other: Any) -> Self: return self._with_binary(operator.add, other) diff --git a/src/narwhals/_polars/series.py b/src/narwhals/_polars/series.py index b82a911f61..be62ce0640 100644 --- a/src/narwhals/_polars/series.py +++ b/src/narwhals/_polars/series.py @@ -74,8 +74,10 @@ "__ror__", "__rsub__", "__rtruediv__", + "__rxor__", "__sub__", "__truediv__", + "__xor__", "abs", "all", "any", @@ -703,8 +705,10 @@ def struct(self) -> PolarsSeriesStructNamespace: __ror__: Method[Self] __rsub__: Method[Self] __rtruediv__: Method[Self] + __rxor__: Method[Self] __sub__: Method[Self] __truediv__: Method[Self] + __xor__: Method[Self] abs: Method[Self] all: Method[bool] any: Method[bool] diff --git a/src/narwhals/_sql/expr.py b/src/narwhals/_sql/expr.py index e9f9d76d7d..f9cded9276 100644 --- a/src/narwhals/_sql/expr.py +++ b/src/narwhals/_sql/expr.py @@ -394,6 +394,13 @@ def __and__(self, other: Self) -> Self: def __or__(self, other: Self) -> Self: return self._with_binary(lambda expr, other: expr.__or__(other), other) + def __xor__(self, other: Self) -> Self: + # SQL backends lack a native `^`; emulate via (a | b) & ~(a & b). + def func(expr: NativeExprT, other: NativeExprT) -> NativeExprT: + return (expr | other) & ~(expr & other) + + return self._with_binary(func, other) + def __floordiv__(self, other: Self) -> Self: def func(expr: NativeExprT, other: NativeExprT) -> NativeExprT: return self._when( diff --git a/src/narwhals/expr.py b/src/narwhals/expr.py index 5ba35c782e..209c28d053 100644 --- a/src/narwhals/expr.py +++ b/src/narwhals/expr.py @@ -212,6 +212,12 @@ def __or__(self, other: Any) -> Self: def __ror__(self, other: Any) -> Self: return (self | other).alias("literal") # type: ignore[no-any-return] + def __xor__(self, other: Any) -> Self: + return self._with_binary("__xor__", other) + + def __rxor__(self, other: Any) -> Self: + return (self ^ other).alias("literal") # type: ignore[no-any-return] + def __add__(self, other: Any) -> Self: return self._with_binary("__add__", other) diff --git a/src/narwhals/series.py b/src/narwhals/series.py index 727a67ccf2..f6d0f72511 100644 --- a/src/narwhals/series.py +++ b/src/narwhals/series.py @@ -1756,6 +1756,16 @@ def __ror__(self, other: Any) -> Self: self._compliant_series.__ror__(self._extract_native(other)) ) + def __xor__(self, other: Any) -> Self: + return self._with_compliant( + self._compliant_series.__xor__(self._extract_native(other)) + ) + + def __rxor__(self, other: Any) -> Self: + return self._with_compliant( + self._compliant_series.__rxor__(self._extract_native(other)) + ) + # unary def __invert__(self) -> Self: return self._with_compliant(self._compliant_series.__invert__()) diff --git a/tests/expr_and_series/operators_test.py b/tests/expr_and_series/operators_test.py index f505c8f972..be9dec835f 100644 --- a/tests/expr_and_series/operators_test.py +++ b/tests/expr_and_series/operators_test.py @@ -48,7 +48,11 @@ def test_comparand_operators_expr( @pytest.mark.parametrize( ("operator", "expected"), - [("__and__", [True, False, False, False]), ("__or__", [True, True, True, False])], + [ + ("__and__", [True, False, False, False]), + ("__or__", [True, True, True, False]), + ("__xor__", [False, True, True, False]), + ], ) def test_logic_operators_expr( constructor: Constructor, operator: str, expected: list[bool] @@ -60,6 +64,18 @@ def test_logic_operators_expr( assert_equal_data(result, {"a": expected}) +def test_xor_operator_expr(constructor: Constructor) -> None: + data = {"a": [True, False, True, False], "b": [True, True, False, False]} + df = nw.from_native(constructor(data)) + result = df.select(a=nw.col("a") ^ nw.col("b")).lazy().collect() + column = result.get_column("a").to_list() + assert len(column) == 4 + assert column == [False, True, True, False] + # Default __and__ still produces the conjunction. + and_result = df.select(a=nw.col("a") & nw.col("b")).lazy().collect() + assert and_result.get_column("a").to_list() == [True, False, False, False] + + def test_logic_operators_expr_kleene( constructor: Constructor, request: pytest.FixtureRequest ) -> None: @@ -92,6 +108,8 @@ def test_logic_operators_expr_kleene( ("__rand__", [False, False, False, False]), ("__or__", [True, True, False, False]), ("__ror__", [True, True, False, False]), + ("__xor__", [True, True, False, False]), + ("__rxor__", [True, True, False, False]), ], ) def test_logic_operators_expr_scalar( @@ -103,7 +121,7 @@ def test_logic_operators_expr_scalar( if ( "dask" in str(constructor) and DASK_VERSION < (2024, 10) - and operator in {"__rand__", "__ror__"} + and operator in {"__rand__", "__ror__", "__rxor__"} ): request.applymarker(pytest.mark.xfail) data = {"a": [True, True, False, False]} @@ -161,6 +179,8 @@ def test_comparand_operators_series( ("__rand__", [True, False, False, False]), ("__or__", [True, True, True, False]), ("__ror__", [True, True, True, False]), + ("__xor__", [False, True, True, False]), + ("__rxor__", [False, True, True, False]), ], ) def test_logic_operators_series( From 1cb834ea437c958e7a916cb3fb18d4783c0ac1f6 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 20 May 2026 11:11:46 -0700 Subject: [PATCH 4/7] fix(polars): emulate pre-1.0 Selector.__xor__ via column set diff --- src/narwhals/_polars/expr.py | 12 +++++++----- tests/expr_and_series/operators_test.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/narwhals/_polars/expr.py b/src/narwhals/_polars/expr.py index be6ad93b74..99afafb947 100644 --- a/src/narwhals/_polars/expr.py +++ b/src/narwhals/_polars/expr.py @@ -275,12 +275,14 @@ def __or__(self, other: PolarsExpr) -> Self: def __xor__(self, other: PolarsExpr) -> Self: other_native = extract_native(other) if self._backend_version < (1,): # pragma: no cover - # Polars added Selector.__xor__ in 1.0.0; emulate via existing set ops. - return self._with_native( - self.native.__or__(other_native).__sub__( - self.native.__and__(other_native) + # Polars added Selector.__xor__ in 1.0.0; emulate via set ops. + selector_cls = pl.selectors._selector_proxy_ + if isinstance(self.native, selector_cls) and isinstance( + other_native, selector_cls + ): + return self._with_native( + (self.native - other_native) | (other_native - self.native) ) - ) return self._with_native(self.native.__xor__(other_native)) def __add__(self, other: Any) -> Self: diff --git a/tests/expr_and_series/operators_test.py b/tests/expr_and_series/operators_test.py index be9dec835f..79eae44a8e 100644 --- a/tests/expr_and_series/operators_test.py +++ b/tests/expr_and_series/operators_test.py @@ -76,6 +76,23 @@ def test_xor_operator_expr(constructor: Constructor) -> None: assert and_result.get_column("a").to_list() == [True, False, False, False] +def test_xor_operator_expr_nulls( + constructor: Constructor, request: pytest.FixtureRequest +) -> None: + if "cudf" in str(constructor): + request.applymarker(pytest.mark.xfail) + if "dask" in str(constructor): + request.applymarker(pytest.mark.xfail) + data = {"a": [True, True, False, None], "b": [True, None, None, None]} + df = nw.from_native(constructor(data)) + result = df.select(nw.col("a") ^ nw.col("b")) + if any(x in str(constructor) for x in ("pandas_constructor",)): + expected: list[bool | None] = [False, True, False, False] + else: + expected = [False, None, None, None] + assert_equal_data(result, {"a": expected}) + + def test_logic_operators_expr_kleene( constructor: Constructor, request: pytest.FixtureRequest ) -> None: From dd5f155479ae9e19f86f96f85c71344ee148b046 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Thu, 21 May 2026 14:52:14 -0700 Subject: [PATCH 5/7] fix(typing): use getattr for polars selector_proxy and method form for sql xor --- src/narwhals/_polars/expr.py | 8 +++++--- src/narwhals/_sql/expr.py | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/narwhals/_polars/expr.py b/src/narwhals/_polars/expr.py index 99afafb947..b2012d030c 100644 --- a/src/narwhals/_polars/expr.py +++ b/src/narwhals/_polars/expr.py @@ -276,9 +276,11 @@ def __xor__(self, other: PolarsExpr) -> Self: other_native = extract_native(other) if self._backend_version < (1,): # pragma: no cover # Polars added Selector.__xor__ in 1.0.0; emulate via set ops. - selector_cls = pl.selectors._selector_proxy_ - if isinstance(self.native, selector_cls) and isinstance( - other_native, selector_cls + selector_cls = getattr(pl.selectors, "_selector_proxy_", None) + if ( + selector_cls is not None + and isinstance(self.native, selector_cls) + and isinstance(other_native, selector_cls) ): return self._with_native( (self.native - other_native) | (other_native - self.native) diff --git a/src/narwhals/_sql/expr.py b/src/narwhals/_sql/expr.py index f9cded9276..d4dd62a04a 100644 --- a/src/narwhals/_sql/expr.py +++ b/src/narwhals/_sql/expr.py @@ -396,10 +396,12 @@ def __or__(self, other: Self) -> Self: def __xor__(self, other: Self) -> Self: # SQL backends lack a native `^`; emulate via (a | b) & ~(a & b). - def func(expr: NativeExprT, other: NativeExprT) -> NativeExprT: - return (expr | other) & ~(expr & other) - - return self._with_binary(func, other) + return self._with_binary( + lambda expr, other: expr.__or__(other).__and__( + expr.__and__(other).__invert__() + ), + other, + ) def __floordiv__(self, other: Self) -> Self: def func(expr: NativeExprT, other: NativeExprT) -> NativeExprT: From b742f4101fbd159af021c5a2aa3280de7a34dac3 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Sun, 24 May 2026 22:00:21 -0700 Subject: [PATCH 6/7] fix(typing): add type: ignore[override] to Selector.__xor__ Signed-off-by: Sai Asish Y --- src/narwhals/selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/narwhals/selectors.py b/src/narwhals/selectors.py index a8d06c08ef..df81b792c4 100644 --- a/src/narwhals/selectors.py +++ b/src/narwhals/selectors.py @@ -56,7 +56,7 @@ def __and__(self, other: Any) -> Expr: # type: ignore[override] ExprNode(ExprKind.ELEMENTWISE, "__and__", exprs=(other,), str_as_lit=True) ) - def __xor__(self, other: Any) -> Expr: + def __xor__(self, other: Any) -> Expr: # type: ignore[override] if isinstance(other, Selector): return self._append_node( ExprNode( From 2e943a2d72160d6d6e8e870adeb3273a5665ef93 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Tue, 9 Jun 2026 14:37:08 -0700 Subject: [PATCH 7/7] fix(selectors): fall back to Expr for Selector.__xor__ with non-selectors Signed-off-by: Sai Asish Y --- src/narwhals/_compliant/column.py | 1 + src/narwhals/_compliant/selectors.py | 7 ++----- src/narwhals/_polars/expr.py | 6 ++++++ src/narwhals/selectors.py | 5 ++--- tests/selectors_test.py | 12 +++++++----- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/narwhals/_compliant/column.py b/src/narwhals/_compliant/column.py index 31275efc07..7797549b1f 100644 --- a/src/narwhals/_compliant/column.py +++ b/src/narwhals/_compliant/column.py @@ -55,6 +55,7 @@ def __rsub__(self, other: Self) -> Self: ... def __rtruediv__(self, other: Self) -> Self: ... def __sub__(self, other: Self) -> Self: ... def __truediv__(self, other: Self) -> Self: ... + def __xor__(self, other: Self) -> Self: ... def __narwhals_namespace__(self) -> CompliantNamespace[Any, Any]: ... diff --git a/src/narwhals/_compliant/selectors.py b/src/narwhals/_compliant/selectors.py index c649e9a4db..a32b670d04 100644 --- a/src/narwhals/_compliant/selectors.py +++ b/src/narwhals/_compliant/selectors.py @@ -310,7 +310,7 @@ def names(df: FrameT) -> Sequence[str]: return self.selectors._selector.from_callables(series, names, context=self) return self._to_expr() & other - @overload + @overload # type: ignore[override] def __xor__(self, other: Self) -> Self: ... @overload def __xor__( @@ -344,10 +344,7 @@ def names(df: FrameT) -> Sequence[str]: ] return self.selectors._selector.from_callables(series, names, context=self) - # The narwhals-level Selector.__xor__ rejects non-selectors before reaching - # here, so this branch is a defensive guard only. - msg = f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')" # pragma: no cover - raise TypeError(msg) # pragma: no cover + return self._to_expr() ^ other def __invert__(self) -> CompliantSelector[FrameT, SeriesOrExprT]: return self.selectors.all() - self diff --git a/src/narwhals/_polars/expr.py b/src/narwhals/_polars/expr.py index b2012d030c..81efa44f26 100644 --- a/src/narwhals/_polars/expr.py +++ b/src/narwhals/_polars/expr.py @@ -274,6 +274,12 @@ def __or__(self, other: PolarsExpr) -> Self: def __xor__(self, other: PolarsExpr) -> Self: other_native = extract_native(other) + if pl.selectors.is_selector(self.native) and not pl.selectors.is_selector( + other_native + ): + # Polars coerces the right operand to a selector and takes the + # set difference; degrade to expressions for an elementwise xor. + return self._with_native(self.native.as_expr().__xor__(other_native)) if self._backend_version < (1,): # pragma: no cover # Polars added Selector.__xor__ in 1.0.0; emulate via set ops. selector_cls = getattr(pl.selectors, "_selector_proxy_", None) diff --git a/src/narwhals/selectors.py b/src/narwhals/selectors.py index df81b792c4..e92e59947e 100644 --- a/src/narwhals/selectors.py +++ b/src/narwhals/selectors.py @@ -67,10 +67,9 @@ def __xor__(self, other: Any) -> Expr: # type: ignore[override] allow_multi_output=True, ) ) - msg = ( - f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')" + return self._to_expr()._append_node( + ExprNode(ExprKind.ELEMENTWISE, "__xor__", exprs=(other,), str_as_lit=True) ) - raise TypeError(msg) def __rsub__(self, other: Any) -> NoReturn: raise NotImplementedError diff --git a/tests/selectors_test.py b/tests/selectors_test.py index 231ed7756d..9122eaecbc 100644 --- a/tests/selectors_test.py +++ b/tests/selectors_test.py @@ -243,6 +243,13 @@ def test_subtract_expr(constructor: Constructor) -> None: assert_equal_data(result, expected) +def test_xor_expr(constructor: Constructor) -> None: + df = nw.from_native(constructor(data)) + result = df.select(ncs.boolean() ^ nw.col("d")) + expected = {"d": [False, False, False]} + assert_equal_data(result, expected) + + def test_set_ops_invalid(constructor: Constructor) -> None: df = nw.from_native(constructor(data)) with pytest.raises((NotImplementedError, ValueError)): @@ -260,11 +267,6 @@ def test_set_ops_invalid(constructor: Constructor) -> None: ): df.select(ncs.boolean() + ncs.numeric()) - with pytest.raises( - TypeError, match=re.escape("unsupported operand type(s) for op: ('Selector' ^ ") - ): - df.select(ncs.boolean() ^ 1) - @pytest.mark.skipif(is_windows(), reason="windows is what it is") def test_tz_aware(constructor: Constructor, request: pytest.FixtureRequest) -> None: