← board

What was measured

2026-09-13, at the commit that fixed the user-class half, binary built from that tree. CPython in the left column is the oracle, not an aspiration.

d = {'a': 1, 'b': 2}
s = "xy"

hasattr(d, "keys")          CPython True   pxx True
hasattr(s, "upper")         CPython True   pxx True
d.keys()                    CPython works  pxx works
s.upper()                   CPython 'XY'   pxx 'XY'
getattr(d, "keys")          CPython works  pxx AttributeError at RUN time
getattr(s, "upper")         CPython 'XY'   pxx REFUSED at COMPILE time:
  "Nil Python: getattr on an attribute this type does not declare needs a default"

Two different failures for one question, which is the tell that two code paths own it. The str case never reaches run time; the dict case compiles and then raises.

Why the user-class fix does not cover it

That fix added an arm gated on

atRec := ResolveNodeRec(atRecv);
if atRec >= REC_UCLASS_BASE then ...

A dict or a str receiver is not a user class, so ResolveNodeRec answers below REC_UCLASS_BASE and the arm correctly declines. This is a scope boundary, not an oversight: the method predicate it uses is FindUMeth over a user class index, and a pylib container method is not in those tables under that name.

PyAttrExists already knows how to answer the existence question for these receivers — it has a PyStrMethodInfo arm and a PyPylibMethodAlias arm, which is exactly why hasattr is right here and getattr is not. The shape of a fix is therefore probably the same shape as the one that landed: give the literal getattr path the same arms the hasattr half already consults, and hand the name to a resolver that can reach a pylib method.

Why this is not ranked higher

Both faces are LOUD. A raise and a compile diagnostic are recoverable by the programmer; the defect just fixed was dangerous because getattr(o, "m", None) returned the DEFAULT silently and the program took the wrong branch with exit 0. Nothing here answers a wrong value, so no program can be quietly wrong because of it.

The argument for doing it anyway is the one in the sibling ticket: hasattr and getattr disagreeing about one name breaks the guarded idiom if hasattr(x, "read"): f = getattr(x, "read"), which is ordinary Python and which raises inside its own guard.

Population

Not censused. The sibling's sweep of lekkerzeilen found ZERO hasattr->getattr pairings and ZERO literal getattr with no default, so that package is not exposed; no claim is made about any other.

The family, and a shared-cause candidate

bug-n-hasattr-with-a-computed-name-cannot-see-a-builtin-method [p 55] is the neighbour, and putting the four cells in one table is the reason to read them together rather than fix either alone. Receiver is a builtin container / str:

spelling                         pxx answer          correct?
hasattr, LITERAL name            True                yes
hasattr, COMPUTED name           False               NO  (the p55 ticket)
getattr, LITERAL name            raises / refused    NO  (this ticket)
getattr, COMPUTED name           works               yes

Two of four are wrong and they are wrong in OPPOSITE directions on opposite axes — one spelling of hasattr is wrongly False, the other spelling of getattr wrongly refuses. The diagonal is what suggests a shared cause: the compile-time predicate and the runtime resolver each know about builtin methods, and each spelling consults only ONE of them. If that is right, the fix is to make both spellings ask both, which is the same move that fixed the user-class half ([[bug-n-getattr-cannot-see-a-method-and-segfaults-through-a-dynamic-receiver]]), and then these are one ticket and not two.

NOT VERIFIED. The table is measured; the shared-cause reading is a hypothesis, and whoever takes either ticket should take both and find out.