Skip to content

Restore read-only properties on builtins.type - #16674

Draft
AlexWaygood wants to merge 2 commits into
mainfrom
alex/restore-type-data-descriptors
Draft

AlexWaygood wants to merge 2 commits into
mainfrom
alex/restore-type-data-descriptors

Conversation

@AlexWaygood

@AlexWaygood AlexWaygood commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

#16549 changed type.__mro__ from a getter-only property to a Final attribute, and #16554 made the same change for seven other attributes of type. All eight are read-only data descriptors on CPython. If a class C defines __mro__ = "instance value" in its body, C().__mro__ reads the string, while C.__mro__ returns the MRO tuple because the descriptor on its metaclass, type, takes precedence.

This PR restores the getter-only properties so the stubs express that lookup rule. I think the motivating examples in #16549 (comment) should instead be reported as bugs to pyright; I'm happy to do that if this is merged.


Rationale

Runtime behaviour of the eight descriptors

On CPython, __mro__ and __text_signature__ are get-set descriptors. The other six are read-only member descriptors. Looking in type.__dict__ retrieves the descriptors themselves, before attribute access invokes their getters. CPython’s get-set definitions · Read-only member definitions

>>> from inspect import isdatadescriptor
>>> def descriptor_info(name):
...     attr = type.__dict__[name]
...     return type(attr).__name__, isdatadescriptor(attr)
...
>>> descriptor_info("__mro__")
('getset_descriptor', True)
>>> descriptor_info("__text_signature__")
('getset_descriptor', True)
>>> descriptor_info("__base__")
('member_descriptor', True)
>>> descriptor_info("__basicsize__")
('member_descriptor', True)
>>> descriptor_info("__dictoffset__")
('member_descriptor', True)
>>> descriptor_info("__flags__")
('member_descriptor', True)
>>> descriptor_info("__itemsize__")
('member_descriptor', True)
>>> descriptor_info("__weakrefoffset__")
('member_descriptor', True)
Class and instance lookup, and why restoring the getters permits compatible metaclass overrides

Consider a class that puts its own string named __mro__ in its namespace. The same attribute name reads two different values depending on the receiver.

>>> class C:
...     __mro__ = "instance value"
...
>>> C.__dict__["__mro__"]
'instance value'
>>> C().__mro__
'instance value'
>>> C.__mro__
(<class '__main__.C'>, <class 'object'>)

For C().__mro__, Python finds the string on C. For C.__mro__, the receiver is C itself, whose metaclass is type. Python finds the data descriptor on type and calls its getter with C as the instance, before it ever consults vars(C). A getter-only property has the same precedence. It implements __set__ even when there is no setter, in which case assignment raises AttributeError. That also matches what happens if you try to reassign C.__mro__. CPython’s class attribute lookup

With __mro__: Final[tuple[type, ...]], ty infers str for both reads and accepts C.__mro__.upper(), which fails on a tuple at runtime. Restoring the getter-only property lets ty infer a tuple for C.__mro__ and a string for C().__mro__. This PR restores that spelling for all eight descriptors.

Defining an attribute on C, as above, leaves the descriptor on type intact. Defining a property on a subclass of type can override the descriptor for classes created by that metaclass. CPython allows the following, as do Pyright and ty with the restored property. With the Final stub, both checkers reject the definition on M.

>>> class M(type):
...     @property
...     def __flags__(cls) -> int:
...         return 42
...
>>> class X(metaclass=M):
...     pass
...
>>> X.__flags__
42
>>> type.__dict__["__flags__"].__get__(X, M)
22044

Here the override returns 42, while the built-in descriptor returns the interpreter’s flags for X (22044 in this session). #16554 deliberately discouraged overrides that make these values differ. Restoring the getter also restores the previous policy of allowing a compatible override. Discussion of the override policy

Why declaring type.__mro__ as Final makes Pyright accept unpacking

The original report in #16549 concerns type.__mro__ itself. The same lookup rule applies because type is its own metaclass.

>>> type(type) is type
True
>>> type.__dict__["__mro__"].__get__(type, type)
(<class 'type'>, <class 'object'>)
>>> type.__mro__
(<class 'type'>, <class 'object'>)
>>> (*type.__mro__,)
(<class 'type'>, <class 'object'>)

With the property stub, Pyright 1.1.414 infers a property and rejects the unpacking.

from typing import reveal_type

reveal_type(type.__mro__)  # Pyright: property
unpacked = (*type.__mro__,)  # Pyright: "property" is not iterable

Pyright incorrectly skips its metaclass data-descriptor check when the metaclass is the built-in type or when the class and metaclass are the same. Both guards apply here. Pyright consequently finds the property in the class namespace and leaves it unbound. With the Final stub, looking in that same namespace yields the declared tuple type, so unpacking works. The lookup order is still observable in the C example. Pyright infers str for C.__mro__ with either stub, and accepts C.__mro__.upper(), which raises AttributeError at runtime. Pyright’s lookup implementation

Both D and its metaclass Meta define field in the following example. Pyright gives the property on Meta precedence.

>>> class Meta(type):
...     @property
...     def field(cls) -> int:
...         return 42
...
>>> class D(metaclass=Meta):
...     field = "instance value"
...
>>> D.field
42
>>> D().field
'instance value'

Here Meta and D are distinct, and the metaclass of D is Meta. Neither guard skips the descriptor check. Pyright, mypy, and ty all correctly infer int for D.field and str for D().field. Pyrefly incorrectly infers str for both.

ty's latest typeshed-sync PR exposed two conformance regressions. For both cls: type[Any] and cls: typing.Type[Any], the suite expects cls.__mro__ to have type tuple[type, ...]. With Final, ty infers plain Any and fails both assertions. Restoring the property fixes both.

The two conformance assertions and the types inferred by ty

The ty PR syncing these stubs introduced two new false positives. The typing conformance suite checks __mro__ through two equivalent annotations. ty typeshed-sync PR · CI conformance report · Conformance test

from typing import Any, Type, assert_type

def check_mro(cls: type[Any], alias: Type[Any]) -> None:
    assert_type(cls.__mro__, tuple[type, ...])
    assert_type(alias.__mro__, tuple[type, ...])

With the property stub, ty knows that a same-named class attribute cannot hide the getter inherited from type (due to the fact that the getter appears on the metaclass of type, which is itself type). The precise metaclass of an object of type type[Any] is unknown and could override the getter, so ty infers tuple[type, ...] & Any, which is "close enough" to the results expected by the conformance suite for ty to pass these assertions.

With the Final stub, however, ty sees an ordinary value on type's metaclass (which is type) that a value in the unknown class namespace could shadow. It infers plain Any for both reads and reports that Any does not match the asserted tuple type. Restoring the properties removes exactly those two new diagnostics. ty’s rationale for the intersection.

Comparison across type checkers

Full results for Pyright, mypy, Pyrefly, and ty, with the examples used in the table

The first two rows use a class whose body defines __mro__ as a string, with this definition:

class C:
    __mro__ = "instance value"

C().__mro__ reads the string from C.__dict__. The class C itself is an instance of the built-in type, whose data descriptor takes precedence for C.__mro__. A checker that infers str for C.__mro__ has picked the class-body string over the descriptor.

Each checker targeted Python 3.12 with the same typeshed revision. An arrow shows how its result changes from the current Final stub to the proposed @property stub. A single result means both stubs gave that result. In the table, tuple abbreviates tuple[type, ...] (displayed by Pyrefly as tuple[type[Any], ...]).

Expression Expected Pyright 1.1.414 mypy 2.4.0 Pyrefly 1.4.0 ty 0.0.91
C.__mro__ tuple str tuple str str → tuple
C().__mro__ str str str str str
type.__mro__ tuple tuple → property tuple → getter function tuple → getter function tuple
(*type.__mro__,) accepts accepts → rejects accepts → rejects accepts → rejects accepts
cls.__mro__ (cls: type[Any]) tuple tuple tuple tuple Any → tuple & Any

The last row comes from the conformance test, where cls is a function parameter annotated as type[Any]. ty passes the assertion that cls.__mro__ has type tuple[type, ...] when it infers tuple[type, ...] & Any. It fails the same assertion when it infers plain Any.

For the first two rows, CPython 3.14.9 returns the following values.

>>> class C:
...     __mro__ = "instance value"
...
>>> type(C) is type
True
>>> C.__mro__
(<class '__main__.C'>, <class 'object'>)
>>> C().__mro__
'instance value'

Proposed polices regarding type-checker bugs

In #8403, Shantanu argued for preserving a checker’s correct behavior when a workaround for another checker causes a significant regression. Because type is its own metaclass, type.__mro__ returns a tuple and unpacking it is valid. Pyright, mypy, and Pyrefly accept (*type.__mro__,) with Final and reject it with the property. The cost of that workaround is that ty also accepts C.__mro__.upper(), which raises AttributeError at runtime.

The policy proposed in #16070 generally favors fixing bugs in the checker, while allowing workarounds for bugs that affect many users or alternative spellings that are equally accurate and benefit many users. Here, only the getter expresses why C.__mro__ still returns a tuple when C defines its own __mro__. The mypy_primer run on #16549 found no change in its sample, so how often users encounter the unpacking error remains unclear. I think typeshed should restore the getters because they accurately describe how these attributes are descriptors at runtime, which is already fully modeled by ty. Pyright, mypy, and Pyrefly can then fix the unpacking error by applying the descriptor to type itself when resolving type.__mro__.

@AlexWaygood AlexWaygood added the stubs: improvement Improve/refactor existing annotations, other stubs issues label Nov 1, 2026
@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stubs: improvement Improve/refactor existing annotations, other stubs issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant