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

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.

is this still needed if you've implemented it for Expr?

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.

Not anymore, no. In 2e943a2 it falls back to self._to_expr() ^ other like __and__/__or__, and the narwhals-level Selector.__xor__ does the same instead of raising; added a test_xor_expr case. Polars needed a small tweak since it coerces the right operand to a selector and takes the set difference.


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
11 changes: 11 additions & 0 deletions src/narwhals/_polars/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,17 @@ 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 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)))

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

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

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.

in __or__ we have

        return self._to_expr()._append_node(
            ExprNode(ExprKind.ELEMENTWISE, "__or__", exprs=(other,), str_as_lit=True)
        )

why are we raising an error here?

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.

Good question. __and__/__or__ fall through to _to_expr() because narwhals Expr defines __and__ and __or__, so selector & expr is a valid elementwise boolean op. Expr has no __xor__, so there is no sensible expr-level fallback for ^, hence the TypeError. Happy to drop the branch and let the AttributeError surface instead if you prefer.

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.

ah thanks - i kinda feel like, if we're adding __xor__ for selectors, we should add it for Expr too.

i'd suggest

  • either do it here
  • or, open an issue to do that as a follow-up

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.

@MarcoGorelli circling back on your review thread (3269698893): I went with option A and added Expr.__xor__ plus __rxor__ in this PR rather than as a follow-up. The commit is c8e6cf9 (feat(expr): add __xor__ for symmetric difference); tests for the Expr path live in tests/expr_and_series/operators_test.py alongside the Selector ones. Happy to split into a separate PR if you would rather land the Selector work first.


def __rsub__(self, other: Any) -> NoReturn:
raise NotImplementedError

Expand All @@ -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.
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
24 changes: 22 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,18 @@ 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_logic_operators_expr_kleene(
constructor: Constructor, request: pytest.FixtureRequest
) -> None:
Expand Down Expand Up @@ -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(
Expand All @@ -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]}
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 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 @@ -247,13 +251,20 @@ 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,
match=re.escape("unsupported operand type(s) for op: ('Selector' + 'Selector')"),
):
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:
Expand Down
Loading