← board

hasattr through an untyped parameter is always False

Filed 2026-08-19 from [[feature-b-mimic-collections-abc-mapping-and-mutablemapping]], where it made MutableMapping.update({'x': 1}) take the pairs branch and index a one-character string.

Measured on pinned v356 (2bb09afb0cff):

def probe(x):
    return hasattr(x, 'keys')
class C:
    def take(self, other):
        return hasattr(other, 'keys')
print(probe({'a': 1}), probe([1]), probe('s'))
print(C().take({'a': 1}), C().take([1]))
CPython True False False / True False
pxx (pinned v356) False False False / False False

Not dict-specific and not container-specific: through a dynamic receiver hasattr answers False uniformly, including hasattr(a_list, 'append'). Written against a local whose static type is known it answers correctly, so the resolution is happening at compile time against the declared type and there is no runtime fallback for the unknown case.

The failure mode is the expensive kind — no error, a plausible wrong value, and a crash somewhere else entirely. hasattr-based duck-typing is how a large fraction of real Python dispatches, so this will keep resurfacing under different symptoms.

Track B workaround: lib/rtl/mimic_collections_abc.py's update() discriminates with isinstance(other, dict) or isinstance(other, Mapping) instead of hasattr(other, 'keys'). Registered in devdocs/dev/track-b-workarounds.md; that workaround is narrower than CPython (a non-Mapping object exposing keys() takes the wrong branch) and should be reverted when this closes.

Resolution (2026-08-26)

The predicate was asking a STATIC-TYPE question of a receiver that has no static type. Not "no runtime fallback" as the ticket guessed — a runtime fallback was there and doing half the job — and not "answers False uniformly", which measurement contradicted before any code changed.

What it was actually consulting

hasattr(<variant>, "lit") lowered to pydynattr_has_any_v(v, name) OR PyHasAttrClassChain(v, name), plus a compile-time PyAttrExists short-circuit before either. Three consultations, and each was blind in a different way:

consultation what it can see what it misses
PyAttrExists (compile time) the receiver's STATIC type's str/int tables a variant has no static type — except its int arm asked PyIsIntMethodBaseTk, whose variant clause is a statement about the PROGRAM (not PyAnyClassDeclares(nm)) and so was constant True
PyHasAttrClassChain (run time) user classes declaring the name as a field methods, properties, and every pylib method reached under a Python ALIAS
pydynattr_has_any_v (run time, pylib) the dynamic store, declared fields, properties, RTTI methods, __getattr__object tags only the alias (RTTI knows keylist, the user wrote keys) and every scalar tag: Exits outright when pyvartag(v) <> 7

So the answer was wrong in both directions, which is why "always False" did not survive contact with a wider probe:

hasattr(a_list, "append") was already True at v376, contrary to the ticket's "answers False uniformly, including hasattr(a_list, 'append')" — that arm rides the RTTI lookup, and append happens to be spelled the same in Pascal. The ticket was filed against v356 and the builtin-receiver work landed in between.

How many sites shared the rule, and how many were already right

PyParseVariantMethod lowers the CALL on the identical receiver and it was right on every row: it has an object-tag arm over the candidate classes (through PyMethNameFor, so it sees the alias), a pyvar_is_strtag arm, and a pyvar_is_floattag arm with an int-tag branch for the three shared names. Every one of x.upper(), x.is_integer(), x.hex(), x.keys(), x.__len__() ran correctly on an untyped parameter while hasattr said the name did not exist. One question, two mechanisms, and only one of them looking at the receiver.

So the fix delegates rather than adding a third list:

No pylib change, so nothing here waits on a pin: everything is emitted from tables the frontend already owns.

The trade, stated plainly

list and set are both TPyList, so an is-test chain cannot separate them. hasattr([1], "add") was already True before this (wrong); routing the alias through PyMethNameFor adds hasattr([1], "update") to that list, in exchange for set.update and the dict views going right. That is one defect — one Pascal class serving two Python types — not a new one, and hiding it behind a per-name exclusion in hasattr would be the compiler-appeasement workaround CLAUDE.md forbids. Filed with the measured rows as [[bug-n-a-list-and-a-set-share-one-class-so-introspection-cannot-tell-them-apart]].

What stays False on purpose

hasattr('s', '__len__') and hasattr(b'ab', '__getitem__') — because 's'.__len__() raises here and b'ab'.__getitem__(0) returns empty. Answering True would be a claim the call cannot honour, the rule the float arm was held to last time. Both become right for free when [[bug-n-bytes-getitem-returns-empty-instead-of-the-byte-value]] lands, since the predicate reads those same tables.

Filed rather than fixed

Nothing here is Track A's: no AST node, IR op, symtab field or backend was touched. The VT_* constants are read from defs.inc, not edited.

Gate

test/test_nilpy_hasattr_untyped_parameter.npy, .expected generated by CPython, wired into test-core (native tier). It is a witness: at pinned v376 ten of its rows are wrong — in both directions — and the describe() duck dispatch at the end returns scalar scalar sequence scalar instead of mapping text sequence scalar, which is the wrong-branch failure mode in one line. Every wrong row sits beside an already-correct sibling, and the call * rows assert the calls that worked all along, so the predicate disagreeing with its own call is what fails.

make compiler/pascal26 byte-identical self-host fixedpoint green. The ten attribute-related NilPy tests re-run individually and all match (hasattr_builtin_receivers, hasattr_variant_receiver, hasattr_getattr_property, getattr_dunder, getattr_computed_name, attrs, dynattr, dynattr_class, missing_attribute_raises, setattr).

Log