Skip to content

feat: add Selector.__xor__ and Expr.__xor__ for symmetric difference#3621

Open
SAY-5 wants to merge 9 commits into
narwhals-dev:mainfrom
SAY-5:feat/selector-xor
Open

feat: add Selector.__xor__ and Expr.__xor__ for symmetric difference#3621
SAY-5 wants to merge 9 commits into
narwhals-dev:mainfrom
SAY-5:feat/selector-xor

Conversation

@SAY-5

@SAY-5 SAY-5 commented May 13, 2026

Copy link
Copy Markdown

Description

Adds Selector.__xor__ for symmetric difference between selectors, rounding out the set operator set (&, |, -, ~, ^) as discussed in #2589. selA ^ selB returns the columns in either selector but not both. Implementation mirrors the existing __or__/__sub__ pattern; for Polars, falls back to (a | b) - (a & b) on versions before 1.0.0 where native Selector.__xor__ was added.

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

Related issues

Checklist

  • Code follows style guide (ruff)
  • Tests added
  • Documented the changes

@MarcoGorelli

Copy link
Copy Markdown
Member

@dangotbanned i'm confused, why is this deemed "AI SPAM"?

@dangotbanned

Copy link
Copy Markdown
Member

@dangotbanned i'm confused, why is this deemed "AI SPAM"?

Look at their profile, they've opened like 800+ PRs across 600+ repos this month

@MarcoGorelli MarcoGorelli reopened this May 14, 2026
@MarcoGorelli

Copy link
Copy Markdown
Member

This is a small and targeted PR which addresses an open issue, has tests, and a description of the changes

I wouldn't class this as spam tbh

@SAY-5 thanks for your PR, there's a couple of CI failures:

uncovered lines

Name                               Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------------------------
narwhals/_compliant/selectors.py     150      2     14      1    98%   347-350
narwhals/_polars/expr.py             274      2      6      1    99%   279-280
------------------------------------------------------------------------------
TOTAL                              26864      4   2784      2    99%

typing

narwhals/_compliant/selectors.py:313: error: Unused "type: ignore" comment 
[unused-ignore]
        @overload  # type: ignore[override]
        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
narwhals/selectors.py:59: error: Unused "type: ignore" comment  [unused-ignore]
        def __xor__(self, other: Any) -> Expr:  # type: ignore[override]
        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Found 2 errors in 2 files (checked 473 source files)
make: *** [Makefile:27: typing] Error 1

could you address these please? the shiny downstream failure is unrelated

@FBruzzesi FBruzzesi mentioned this pull request May 16, 2026
6 tasks
@dangotbanned dangotbanned added suggest close Closing requires a second opinion and removed AI SPAM labels May 18, 2026
@SAY-5

SAY-5 commented May 18, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Removed the two unused type: ignore[override] comments and resolved the coverage gaps: the Polars pre-1.0 __xor__ emulation is gated behind a version check with a pragma: no cover (matching the existing pattern in that file), and the compliant-layer TypeError branch is similarly marked since the narwhals-level selector rejects non-selectors before reaching it.

@SAY-5 SAY-5 force-pushed the feat/selector-xor branch from 42e8308 to 35e9fcf Compare May 19, 2026 06:37
@MarcoGorelli MarcoGorelli removed the suggest close Closing requires a second opinion label May 19, 2026
Comment thread src/narwhals/selectors.py Outdated
Comment on lines +70 to +73
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.

@SAY-5

SAY-5 commented May 19, 2026

Copy link
Copy Markdown
Author

Makes sense. I'll keep this PR scoped to selectors and open a follow-up issue to track adding __xor__ to Expr so the two can land together if you prefer.

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?

@MarcoGorelli MarcoGorelli added the enhancement New feature or request label May 20, 2026
@SAY-5

SAY-5 commented May 20, 2026

Copy link
Copy Markdown
Author

Added the null test and fixed the pre-1.0 emulation. The previous (or - and) path panicked on bool dtypes; switched to a true selector set-diff that emulates Selector.__xor__ on Polars <1.0, and pass through to the native operator otherwise. Local run of tests/selectors_test.py + tests/expr_and_series/operators_test.py is green on both polars 0.20.8 and 1.40.

@SAY-5 SAY-5 changed the title feat: add Selector.__xor__ for symmetric difference feat: add Selector.__xor__ and Expr.__xor__ for symmetric difference May 21, 2026
assert_equal_data(result, {"a": expected})


def test_xor_operator_expr(constructor: Constructor) -> None:

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

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
@SAY-5

SAY-5 commented May 25, 2026

Copy link
Copy Markdown
Author

Fixed the mypy typing failure: added # type: ignore[override] to Selector.__xor__ (matching the existing pattern on Selector.__and__). The other CI failures (ibis, pyspark, modin, pytest-310) all fail on tests/frame/schema_test.py::test_dtypes which is unrelated to this PR's xor changes (1 failure out of 7900+ passing).

Comment thread src/narwhals/_compliant/selectors.py Outdated
Comment on lines +347 to +350
# 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.

…tors

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
Comment on lines +280 to +282
# 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))

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?

@SAY-5

SAY-5 commented Jun 20, 2026

Copy link
Copy Markdown
Author

Sure. It's not a polars bug, it's the selector operator overloading. When the left side is a selector, polars makes ^ mean column-set symmetric difference rather than an elementwise xor, and it coerces the right operand into a selector to do that:

import polars as pl
import polars.selectors as cs

df = pl.DataFrame({"a": [True, False], "b": [True, True]})

# selector on the left: ^ becomes a set op over column *names*
df.select(cs.by_name("a", "b") ^ pl.col("b")).columns
# -> ['a']   ({a, b} xor {b})

# plain expr on the left: normal elementwise xor
df.select(cs.by_name("a", "b").as_expr() ^ pl.col("b"))
# -> shape (2, 1) column 'a' = [False, True]

So when our left operand is backed by a selector but the user wrote an elementwise __xor__, calling .as_expr() first keeps it elementwise instead of silently turning into a column-name set difference. If both sides are selectors I leave it as the set op, which matches polars.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Selector.__xor__ (symmetric difference)

4 participants