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/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/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/selectors.py b/src/narwhals/_compliant/selectors.py index 71bb3d6988..a32b670d04 100644 --- a/src/narwhals/_compliant/selectors.py +++ b/src/narwhals/_compliant/selectors.py @@ -310,6 +310,42 @@ 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) + return self._to_expr() ^ other + def __invert__(self) -> CompliantSelector[FrameT, SeriesOrExprT]: return self.selectors.all() - self diff --git a/src/narwhals/_compliant/series.py b/src/narwhals/_compliant/series.py index d340f7e957..b86f3b6177 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 3961b5ee7e..22e1874a33 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/expr.py b/src/narwhals/_polars/expr.py index f4f0cc0ea9..81efa44f26 100644 --- a/src/narwhals/_polars/expr.py +++ b/src/narwhals/_polars/expr.py @@ -272,6 +272,27 @@ 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: + 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) + 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) + ) + 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/_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..d4dd62a04a 100644 --- a/src/narwhals/_sql/expr.py +++ b/src/narwhals/_sql/expr.py @@ -394,6 +394,15 @@ 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). + 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: return self._when( diff --git a/src/narwhals/expr.py b/src/narwhals/expr.py index 6df7a70145..41b8b948e9 100644 --- a/src/narwhals/expr.py +++ b/src/narwhals/expr.py @@ -213,6 +213,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/selectors.py b/src/narwhals/selectors.py index 67c7e3abfc..e92e59947e 100644 --- a/src/narwhals/selectors.py +++ b/src/narwhals/selectors.py @@ -56,6 +56,21 @@ 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, + ) + ) + return self._to_expr()._append_node( + ExprNode(ExprKind.ELEMENTWISE, "__xor__", exprs=(other,), str_as_lit=True) + ) + def __rsub__(self, other: Any) -> NoReturn: raise NotImplementedError @@ -65,6 +80,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/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..79eae44a8e 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,35 @@ 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_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: @@ -92,6 +125,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 +138,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 +196,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( diff --git a/tests/selectors_test.py b/tests/selectors_test.py index be3d9d594c..9122eaecbc 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"]), ], ) @@ -239,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)): @@ -247,6 +258,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,