← board

keys() through an untyped receiver is not dispatched, across a module edge

This REOPENS a closed ticket

[[bug-n-a-user-classs-keys-items-values-is-dispatched-as-a-dict-view]] is in done/, and every probe in this session confirms it is fixed within one compilation unit. It is not fixed when the call and the class are separated by a module edge. The closing evidence was single-module, which is why it read as green.

Repro — two files, 22 lines

helper.py:

class Mapping:
    def __iter__(self):
        raise NotImplementedError('abstract')
    def __getitem__(self, key):
        raise KeyError(key)
    def keys(self):              # a self-iterating keys(), as an ABC's mixin is
        out = []
        for k in self:
            out.append(k)
        return out

class Caller:
    def take_keys(self, other):  # `other` is untyped
        out = []
        for k in other.keys():
            out.append(k)
        return out

main.npy:

from helper import Caller

class Duck:
    def keys(self):
        return ["d1"]

print(Caller().take_keys(Duck()))   # CPython: ['d1']   pxx: SIGSEGV
$ pxx -Fu. main.npy x && ./x
Segmentation fault (core dumped)
$ python3 main.npy
['d1']

Two distinct wrong outcomes, one cause

The call is resolved statically at the call site instead of dispatching on the receiver. What that produces depends on what the callee's module can see:

the calling module declares result
no class with keys() SIGSEGV — falls through to the dict-view builtin, which reads a non-dict
a keys() that iterates self (an ABC mixin's shape) SIGSEGV — the foreign object reaches that body and for k in self faults on an object with no __iter__
a keys() that returns a plain list correct — dispatches to the receiver's own

The third row is why isolated probes pass: give the module any simple keys() and the bug hides. Adding an unrelated, never-instantiated class with a one-line keys() to helper.py above makes the repro print ['d1'].

items() and values() are named in the original ticket and are presumably the same path; only keys() was measured here.

No spelling of the call escapes it — ks = other.keys(), list(other.keys()) all segfault identically, and getattr(other, 'keys')() fails differently (TypeError: expected a str, a list or a dict, got int), which is its own evidence that the receiver is not reaching the resolver.

Why it matters beyond the repro

It is the sole remaining blocker on MutableMapping.update() accepting a duck-typed mapping, which is CPython's documented contract for it:

elif hasattr(other, "keys"):      # hasattr is FIXED as of v389
    for k in other.keys():        # this line is the one that faults
        self[k] = other[k]

hasattr(other, "keys") now answers correctly through an untyped parameter, so the discrimination CPython uses is available and the branch it selects is not. Track B has therefore kept its isinstance(other, dict) or isinstance(other, Mapping) workaround — safe only because both of its branches iterate other directly and neither calls keys() — and re-pointed the registry entry in devdocs/dev/track-b-workarounds.md at this ticket.

Gate

The two-file repro prints ['d1'], and mimic_collections_abc.update() reverts to CPython's three-branch form (isinstancehasattr → pairs) with test/lib_mimic_collections_abc.npy gaining a duck-typed update() row.