← board

hasattr is blind to a property

e = KeyError("k")
print(e.args)                 # ('k',)  — reads fine
print(hasattr(e, "args"))     # CPython True    pxx False

Found 2026-08-15 by a CPython differential sweep of exception semantics — the only divergence in a 18-line probe that otherwise agreed exactly (try/else/ finally, re-raise, tuple except, base-class catch, exception attributes, finally-with-break/continue).

Silent and in the worst direction: hasattr is Python's duck-typing primitive, so a False sends if hasattr(x, "args") down the other branch with no error.

Cause

PyAttrExists asked the field table, the user METHOD table, the pylib method alias, the str-method table and the int-method names — every kind of member except a property. pylib's Exception.args is exactly that: a property over a derived getter, deliberately (see its declaration note). FindUProp already walks ancestors like FindUMeth does; it simply was not asked.

The half that made it a two-part fix

Teaching hasattr alone was worse than the bug. getattr(o, "p") resolves a literal name against declared FIELDS (PyAttrFieldIdx) and falls through to the dynamic-attribute store, which knows nothing about properties — so it raised AttributeError for a name hasattr had just answered True for, turning a wrongly-taken branch into a crash. PyMakePropRead now builds the read o.p builds (backing field, or the getter call), and the getattr path uses it.

Known remaining gap, deliberately not fixed here

A computed name — getattr(o, nm) / hasattr(o, nm) where nm is a variable — goes through the RUNTIME dynamic-attribute predicate, which is RTTI-based and cannot see properties. Filed as [[bug-nilpy-a-computed-attribute-name-cannot-see-a-property]]; the test asserts the computed form only for a field and an absent name, and says why.

Gate

test/test_nilpy_hasattr_getattr_property.npy (+.expected, in the Makefile), byte-identical to CPython: a property on a class and on a SUBCLASS, a plain field, a method, an absent name, getattr with and without a default, e.args on a constructed exception and on a caught one, and the computed-name form for the shapes it supports. test_nilpy_hasattr_builtin_receivers and test_nilpy_getattr_dunder re-run unchanged. gate.sh quick GREEN.