Skip to content
6 changes: 6 additions & 0 deletions src/narwhals/_arrow/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/narwhals/_compliant/column.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...

Expand Down
3 changes: 3 additions & 0 deletions src/narwhals/_compliant/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
36 changes: 36 additions & 0 deletions src/narwhals/_compliant/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/narwhals/_compliant/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
1 change: 1 addition & 0 deletions src/narwhals/_dask/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/narwhals/_pandas_like/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions src/narwhals/_polars/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines +280 to +282

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm confused by this, sorry - do you have an example please?

is it a bug which needs fixing in polars upstream?

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)))

Expand Down
4 changes: 4 additions & 0 deletions src/narwhals/_polars/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@
"__ror__",
"__rsub__",
"__rtruediv__",
"__rxor__",
"__sub__",
"__truediv__",
"__xor__",
"abs",
"all",
"any",
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions src/narwhals/_sql/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/narwhals/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 18 additions & 0 deletions src/narwhals/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/narwhals/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__())
Expand Down
41 changes: 39 additions & 2 deletions tests/expr_and_series/operators_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -60,6 +64,35 @@ def test_logic_operators_expr(
assert_equal_data(result, {"a": expected})


def test_xor_operator_expr(constructor: Constructor) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we also have a test_xor_operator_expr_nulls test, to check that null values propagate?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in commit c8e6cf9. The test covers True/True, True/None, False/None, None/None cases and checks that null propagates for pandas-style constructors differently (pandas treats nulls as False in boolean ops).

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:
Expand Down Expand Up @@ -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(
Expand All @@ -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]}
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions tests/selectors_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
],
)
Expand Down Expand Up @@ -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)):
Expand All @@ -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,
Expand Down
Loading