Skip to content

Commit 2928b1b

Browse files
committed
feat(selectors): add Selector.__xor__ for symmetric difference
1 parent 9200736 commit 2928b1b

4 files changed

Lines changed: 78 additions & 0 deletions

File tree

narwhals/_compliant/selectors.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,45 @@ def names(df: FrameT) -> Sequence[str]:
310310
return self.selectors._selector.from_callables(series, names, context=self)
311311
return self._to_expr() & other
312312

313+
@overload # type: ignore[override]
314+
def __xor__(self, other: Self) -> Self: ...
315+
@overload
316+
def __xor__(
317+
self, other: CompliantExpr[FrameT, SeriesOrExprT]
318+
) -> CompliantExpr[FrameT, SeriesOrExprT]: ...
319+
def __xor__(
320+
self, other: SelectorOrExpr[FrameT, SeriesOrExprT]
321+
) -> SelectorOrExpr[FrameT, SeriesOrExprT]:
322+
if self._is_selector(other):
323+
324+
def series(df: FrameT) -> Sequence[SeriesOrExprT]:
325+
lhs_names, rhs_names = _eval_lhs_rhs(df, self, other)
326+
return [
327+
*(
328+
x
329+
for x, name in zip(self(df), lhs_names, strict=True)
330+
if name not in rhs_names
331+
),
332+
*(
333+
x
334+
for x, name in zip(other(df), rhs_names, strict=True)
335+
if name not in lhs_names
336+
),
337+
]
338+
339+
def names(df: FrameT) -> Sequence[str]:
340+
lhs_names, rhs_names = _eval_lhs_rhs(df, self, other)
341+
return [
342+
*(x for x in lhs_names if x not in rhs_names),
343+
*(x for x in rhs_names if x not in lhs_names),
344+
]
345+
346+
return self.selectors._selector.from_callables(series, names, context=self)
347+
msg = (
348+
f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')"
349+
)
350+
raise TypeError(msg)
351+
313352
def __invert__(self) -> CompliantSelector[FrameT, SeriesOrExprT]:
314353
return self.selectors.all() - self
315354

narwhals/_polars/expr.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ def __and__(self, other: PolarsExpr) -> Self:
272272
def __or__(self, other: PolarsExpr) -> Self:
273273
return self._with_native(self.native.__or__(extract_native(other)))
274274

275+
def __xor__(self, other: PolarsExpr) -> Self:
276+
if self._backend_version >= (1,):
277+
return self._with_native(self.native.__xor__(extract_native(other)))
278+
# Polars added Selector.__xor__ in 1.0.0; emulate via existing set ops.
279+
other_native = extract_native(other)
280+
return self._with_native(
281+
self.native.__or__(other_native).__sub__(self.native.__and__(other_native))
282+
)
283+
275284
def __add__(self, other: Any) -> Self:
276285
return self._with_native(self.native.__add__(extract_native(other)))
277286

narwhals/selectors.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ def __and__(self, other: Any) -> Expr: # type: ignore[override]
5656
ExprNode(ExprKind.ELEMENTWISE, "__and__", exprs=(other,), str_as_lit=True)
5757
)
5858

59+
def __xor__(self, other: Any) -> Expr: # type: ignore[override]
60+
if isinstance(other, Selector):
61+
return self._append_node(
62+
ExprNode(
63+
ExprKind.ELEMENTWISE,
64+
"__xor__",
65+
exprs=(other,),
66+
str_as_lit=True,
67+
allow_multi_output=True,
68+
)
69+
)
70+
msg = (
71+
f"unsupported operand type(s) for op: ('Selector' ^ '{type(other).__name__}')"
72+
)
73+
raise TypeError(msg)
74+
5975
def __rsub__(self, other: Any) -> NoReturn:
6076
raise NotImplementedError
6177

@@ -65,6 +81,9 @@ def __rand__(self, other: Any) -> NoReturn:
6581
def __ror__(self, other: Any) -> NoReturn:
6682
raise NotImplementedError
6783

84+
def __rxor__(self, other: Any) -> NoReturn:
85+
raise NotImplementedError
86+
6887

6988
def by_dtype(*dtypes: DType | type[DType] | Iterable[DType | type[DType]]) -> Selector:
7089
"""Select columns based on their dtype.

tests/selectors_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ def test_datetime_no_tz(constructor: Constructor) -> None:
207207
(ncs.boolean() & True, ["d"]),
208208
(ncs.boolean() | True, ["d"]),
209209
(ncs.numeric() - 1, ["a", "c"]),
210+
(ncs.numeric() ^ ncs.boolean(), ["a", "c", "d"]),
211+
(ncs.numeric() ^ ncs.by_dtype(nw.Int64), ["c"]),
212+
(ncs.by_dtype(nw.Int64) ^ ncs.numeric(), ["c"]),
213+
(ncs.numeric() ^ ncs.numeric(), []),
210214
(ncs.all(), ["a", "b", "c", "d"]),
211215
],
212216
)
@@ -247,13 +251,20 @@ def test_set_ops_invalid(constructor: Constructor) -> None:
247251
df.select(1 | ncs.numeric())
248252
with pytest.raises((NotImplementedError, ValueError)):
249253
df.select(1 & ncs.numeric())
254+
with pytest.raises((NotImplementedError, TypeError, ValueError)):
255+
df.select(1 ^ ncs.numeric())
250256

251257
with pytest.raises(
252258
TypeError,
253259
match=re.escape("unsupported operand type(s) for op: ('Selector' + 'Selector')"),
254260
):
255261
df.select(ncs.boolean() + ncs.numeric())
256262

263+
with pytest.raises(
264+
TypeError, match=re.escape("unsupported operand type(s) for op: ('Selector' ^ ")
265+
):
266+
df.select(ncs.boolean() ^ 1)
267+
257268

258269
@pytest.mark.skipif(is_windows(), reason="windows is what it is")
259270
def test_tz_aware(constructor: Constructor, request: pytest.FixtureRequest) -> None:

0 commit comments

Comments
 (0)