ruff/crates/ty_python_semantic/resources/mdtest/call/dunder.md
David Peter f76d3f87cf
Some checks are pending
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
[ty] Allow declared-only class-level attributes to be accessed on the class (#19071)
## Summary

Allow declared-only class-level attributes to be accessed on the class:
```py
class C:
    attr: int

C.attr  # this is now allowed
``` 

closes https://github.com/astral-sh/ty/issues/384
closes https://github.com/astral-sh/ty/issues/553

## Ecosystem analysis


* We see many removed `unresolved-attribute` false-positives for code
that makes use of sqlalchemy, as expected (see changes for `prefect`)
* We see many removed `call-non-callable` false-positives for uses of
`pytest.skip` and similar, as expected
* Most new diagnostics seem to be related to cases like the following,
where we previously inferred `int` for `Derived().x`, but now we infer
`int | None`. I think this should be a
conflicting-declarations/bad-override error anyway? The new behavior may
even be preferred here?
  ```py
  class Base:
      x: int | None
  
  
  class Derived(Base):
      def __init__(self):
          self.x: int = 1
  ```
2025-07-02 18:03:56 +02:00

7.3 KiB

Dunder calls

Introduction

This test suite explains and documents how dunder methods are looked up and called. Throughout the document, we use __getitem__ as an example, but the same principles apply to other dunder methods.

Dunder methods are implicitly called when using certain syntax. For example, the index operator obj[key] calls the __getitem__ method under the hood. Exactly how a dunder method is looked up and called works slightly different from regular methods. Dunder methods are not looked up on obj directly, but rather on type(obj). But in many ways, they still act as if they were called on obj directly. If the __getitem__ member of type(obj) is a descriptor, it is called with obj as the instance argument to __get__. A desugared version of obj[key] is roughly equivalent to getitem_desugared(obj, key) as defined below:

from typing import Any

def find_name_in_mro(typ: type, name: str) -> Any:
    # See implementation in https://docs.python.org/3/howto/descriptor.html#invocation-from-an-instance
    pass

def getitem_desugared(obj: object, key: object) -> object:
    getitem_callable = find_name_in_mro(type(obj), "__getitem__")
    if hasattr(getitem_callable, "__get__"):
        getitem_callable = getitem_callable.__get__(obj, type(obj))

    return getitem_callable(key)

In the following tests, we demonstrate that we implement this behavior correctly.

Operating on class objects

If we invoke a dunder method on a class, it is looked up on the meta class, since any class is an instance of its metaclass:

class Meta(type):
    def __getitem__(cls, key: int) -> str:
        return str(key)

class DunderOnMetaclass(metaclass=Meta):
    pass

reveal_type(DunderOnMetaclass[0])  # revealed: str

If the dunder method is only present on the class itself, it will not be called:

class ClassWithNormalDunder:
    def __getitem__(self, key: int) -> str:
        return str(key)

# error: [non-subscriptable]
ClassWithNormalDunder[0]

Operating on instances

Attaching dunder methods to instances in methods

When invoking a dunder method on an instance of a class, it is looked up on the class:

class ClassWithNormalDunder:
    def __getitem__(self, key: int) -> str:
        return str(key)

class_with_normal_dunder = ClassWithNormalDunder()

reveal_type(class_with_normal_dunder[0])  # revealed: str

Which can be demonstrated by trying to attach a dunder method to an instance, which will not work:

def external_getitem(instance, key: int) -> str:
    return str(key)

class ThisFails:
    def __init__(self):
        self.__getitem__ = external_getitem

this_fails = ThisFails()

# error: [non-subscriptable] "Cannot subscript object of type `ThisFails` with no `__getitem__` method"
reveal_type(this_fails[0])  # revealed: Unknown

However, the attached dunder method can be called if accessed directly:

reveal_type(this_fails.__getitem__(this_fails, 0))  # revealed: Unknown | str

The instance-level method is also not called when the class-level method is present:

def external_getitem1(instance, key) -> str:
    return "a"

def external_getitem2(key) -> int:
    return 1

def _(flag: bool):
    class ThisFails:
        if flag:
            __getitem__ = external_getitem1

        def __init__(self):
            self.__getitem__ = external_getitem2

    this_fails = ThisFails()

    # error: [possibly-unbound-implicit-call]
    reveal_type(this_fails[0])  # revealed: Unknown | str

Dunder methods as class-level annotations with no value

Class-level annotations with no value assigned are considered to be accessible on the class:

from typing import Callable

class C:
    __call__: Callable[..., None]

C()()

_: Callable[..., None] = C()

And of course the same is true if we have only an implicit assignment inside a method:

from typing import Callable

class C:
    def __init__(self):
        self.__call__ = lambda *a, **kw: None

# error: [call-non-callable]
C()()

# error: [invalid-assignment]
_: Callable[..., None] = C()

When the dunder is not a method

A dunder can also be a non-method callable:

class SomeCallable:
    def __call__(self, key: int) -> str:
        return str(key)

class ClassWithNonMethodDunder:
    __getitem__: SomeCallable = SomeCallable()

class_with_callable_dunder = ClassWithNonMethodDunder()

reveal_type(class_with_callable_dunder[0])  # revealed: str

Dunders are looked up using the descriptor protocol

Here, we demonstrate that the descriptor protocol is invoked when looking up a dunder method. Note that the instance argument is on object of type ClassWithDescriptorDunder:

from __future__ import annotations

class SomeCallable:
    def __call__(self, key: int) -> str:
        return str(key)

class Descriptor:
    def __get__(self, instance: ClassWithDescriptorDunder, owner: type[ClassWithDescriptorDunder]) -> SomeCallable:
        return SomeCallable()

class ClassWithDescriptorDunder:
    __getitem__: Descriptor = Descriptor()

class_with_descriptor_dunder = ClassWithDescriptorDunder()

reveal_type(class_with_descriptor_dunder[0])  # revealed: str

Dunders can not be overwritten on instances

If we attempt to overwrite a dunder method on an instance, it does not affect the behavior of implicit dunder calls:

class C:
    def __getitem__(self, key: int) -> str:
        return str(key)

    def f(self):
        # TODO: This should emit an `invalid-assignment` diagnostic once we understand the type of `self`
        self.__getitem__ = None

# This is still fine, and simply calls the `__getitem__` method on the class
reveal_type(C()[0])  # revealed: str

Calling a union of dunder methods

def _(flag: bool):
    class C:
        if flag:
            def __getitem__(self, key: int) -> str:
                return str(key)
        else:
            def __getitem__(self, key: int) -> bytes:
                return bytes()

    c = C()
    reveal_type(c[0])  # revealed: str | bytes

    if flag:
        class D:
            def __getitem__(self, key: int) -> str:
                return str(key)

    else:
        class D:
            def __getitem__(self, key: int) -> bytes:
                return bytes()

    d = D()
    reveal_type(d[0])  # revealed: str | bytes

Calling a union of types without dunder methods

We add instance attributes here to make sure that we don't treat the implicit dunder calls here like regular method calls.

def external_getitem(instance, key: int) -> str:
    return str(key)

class NotSubscriptable1:
    def __init__(self, value: int):
        self.__getitem__ = external_getitem

class NotSubscriptable2:
    def __init__(self, value: int):
        self.__getitem__ = external_getitem

def _(union: NotSubscriptable1 | NotSubscriptable2):
    # error: [non-subscriptable] "Cannot subscript object of type `NotSubscriptable2` with no `__getitem__` method"
    # error: [non-subscriptable] "Cannot subscript object of type `NotSubscriptable1` with no `__getitem__` method"
    union[0]

Calling a possibly-unbound dunder method

def _(flag: bool):
    class C:
        if flag:
            def __getitem__(self, key: int) -> str:
                return str(key)

    c = C()
    # error: [possibly-unbound-implicit-call]
    reveal_type(c[0])  # revealed: str