← board

&, |, ^, <<, >> on a variant user object skip the dunder

class C:
    def __init__(self, v):     self.v = v
    def __and__(self, o):      return C(self.v & o)
    def __lshift__(self, o):   return C(self.v << o)

def p_and(c):   return (c & 12).v      # CPython 4    pxx AttributeError: 'int' object has no attribute 'v'
def p_shl(c):   return (c << 2).v      # CPython 80   pxx Runtime error 219 (not convertible to an integer)

print(p_and(C(20)), p_shl(C(20)))

Measured 2026-09-15 against CPython, one row per operator. The seven ARITHMETIC operators (+ - * / // % **) all reach their dunder correctly on the same receiver; the five bitwise/shift ones do not. &, | and ^ answer a plain int (silent); << and >> die with RunError 219 (loud).

Mechanism

IRLowerAST's variant-dispatch arms cover +, -, *, /, //, % and the four orderings (ir.inc, the pyadd_v/pysub_v/pymul_v/pyfloordiv_v blocks). There is no such arm for &, |, ^, <<, >>.

Verified by dumping the IR of both, rather than inferred — an earlier draft of this ticket said the node lowers to a raw IR_BINOP over the operand's handle, and that was wrong. PXXDBG=a.ir:f on return c - 12 and return c & 12, same class, same receiver:

c - 12    ->  call <pysub_v>                    (the dispatch arm)
c & 12    ->  var_binop  ... (no pylib call)    (the generic fallback)

So it reaches IR_VAR_BINOP, the generic variant-operator path, which coerces both operands numerically. That is why & | ^ answer a plain int (silent) and << >> raise RunError 219 — a coercion refusing an object, not a pointer being used as a number. The distinction matters for the fix: the value is not garbage, it is a correct numeric answer about the wrong thing.

pybitand_v, pybitor_v, pybitxor_v, pyshl_v and pyshr_v already exist in pylib.pas — they are what pyeval uses. Verified: nothing in ir.inc or pyparser.inc references any of the five, so the lowering never calls them; and each is a one-line pyvar_of_int(pyvar_to_int(a) <op> pyvar_to_int(b)) with no PyVarUserArith try, unlike every arithmetic helper.

Open question, stated as one: the c & 12 IR also carries a guarded runtime call before the var_binop which c - 12 does not need. Whatever that guard tries, it does not admit & — a class declaring __and__ still gets the numeric answer. Worth identifying before adding the arm, in case the arm belongs there instead.

This is the same shape as [[bug-nilpy-mixed-type-arithmetic-silently-does-pointer-math]], which is the ticket that added the - and / arms. The bitwise family was not in it.

Why it is filed rather than fixed

It is the BLOCKER under [[bug-n-augmented-assignment-to-an-unannotated-parameter-silently-loses-the-mutation]], whose augmented halves &=, |=, ^=, <<=, >>= cannot be repaired from above: the augmented marker selects a pyaug<op>_v from the variant-dispatch arm, and for these five there is no arm to select from. PyAugMarkedTok in pyparser.inc names them and says so.

Fixing this one delivers both — the plain form and, with a pyaug<op>_v twin and a row in PyAugMarkedTok, the augmented form.

Shape of a fix

Three parts, none large:

  1. PyVarUserArith(a, b, '__and__', '__rand__', Result) as the FIRST line of each of the five py*_v helpers, exactly as pysub_v/pymul_v already do.
  2. An IR arm routing tkAmp/tkPipe/tkXor/tkShl/tkShr to them when either operand reads as a variant. The ///% block is the template.
  3. pyaugbitand_v &c., each trying __iand__ then calling the plain twin, plus the five tokens added to PyAugMarkedTok.

Beware the token spelling, and PyBinOpDunderName's own comment says why: in the raw NilPy stream a bare & is tkAmp and a bare | is tkPipe, while tkAnd/tkOr are the and/or KEYWORDS with no dunder at all. PyAugBinTok normalises &= to tkAnd, so the augmented and plain sides key on DIFFERENT tokens for the same operator. Delegating one to the other is the trap.

Gate

.npy diffed against CPython: each of the five operators plain and augmented, on a variant receiver; a variant holding an int must keep its ordinary bitwise answer; set |= set and any other pylib-owned class must be unaffected (PyVarUserObj excludes them, which is the existing guard).

Addendum 2026-09-15 — @ is the same gap, one operator over

Measured while fixing bug-n-annotating-a-dunder-operand-breaks-the-operator-on-a-variant-receiver: h.a @ h.b with h a bare parameter raises unsupported operand type(s) for this operator with a BARE def __matmul__(self, o), while a @ b on statically typed locals answers 12.0. compiler/builtin/pylib.pas has no __matmul__ string anywhere — the variant @ entry point never consults a user dunder, exactly like the five bitwise/shift operators this ticket names. The parser side is wired (PyBinOpDunderName maps tkAt), so it is the runtime arm only, and the fix shape is the one the arithmetic entry points already have: if PyVarUserArith(a, b, '__matmul__', '__rmatmul__', Result) then Exit; at the top of the variant @ routine. Not fixed in that commit because the dispatch ticket's fixture had to stay about dispatch; six operators now share this ticket.