An int method on a None receiver returns 0 instead of raising
Filed 2026-08-26 while resolving
[[bug-n-hasattr-through-an-untyped-parameter-is-always-false]], which fixed the
PREDICATE half of the same over-claim (hasattr(None, 'bit_length') was True and
is now correctly False). This is the CALL half, and it is the worse one because
it produces a value.
Measured (self-hosted at that fix's sha; receiver is an untyped parameter)
def c(x):
return x.bit_length()
| receiver | CPython | pxx |
|---|---|---|
7 |
3 | 3 |
{'a': 1} |
AttributeError | raises (expected a number, got object) |
[1, 2] |
AttributeError | raises (expected a number, got object) |
'abc' |
AttributeError | raises (expected a number, got str) |
None |
AttributeError | 0 — no error at all |
Cause
PyIsIntMethodBaseTk(tk, nm) answers (tk = tyVariant) and not PyAnyClassDeclares(nm) — i.e. for a variant receiver it is a statement about the
PROGRAM, not the receiver, so the int-method intercept claims every variant and
unboxes it. Three of the four wrong receivers are caught downstream by the unbox
refusing a non-number; None's VT_EMPTY payload reads as 0 and sails through.
The predicate side of this was fixed by asking PyIsIntBaseTk (a statement about
the receiver) and emitting run-time tag tests for the variant case — see
PyHasAttrRuntimeChain. The call has the same options: guard the intercept with
pyvar_is_inttag-shaped tests the way PyParseVariantMethod's str and float
arms already do, and raise pydynattr_no_method otherwise. Those two arms are
the model; the int one never got it.
Note the wrong messages on the other three rows are error-reporting parity and
low prio by CLAUDE.md's rule; the None row is a silent wrong value and is why
this is filed as a bug rather than a compat item.
Gate
The table above diffed against CPython (the message text normalised or the row
asserted as "raises"), plus to_bytes and bit_count on the same receivers, and
(7).bit_length() / True.bit_length() still answering 3 / 1.