From bb24c61e8291197be80219dc60d7a635e8923d2f Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:31:22 +0200 Subject: [PATCH 1/2] Add section about metaclass constructors --- docs/conf.py | 5 +- docs/spec/constructors.rst | 288 +++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index f16401bae..cd155fffa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,4 +52,7 @@ html_static_path = [] extensions = ['sphinx.ext.intersphinx'] -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'py315': ('https://docs.python.org/3.15', None), +} diff --git a/docs/spec/constructors.rst b/docs/spec/constructors.rst index c95ecc488..c5aa10b1e 100644 --- a/docs/spec/constructors.rst +++ b/docs/spec/constructors.rst @@ -3,6 +3,8 @@ Constructors Calls to constructors require special handling within type checkers. +.. _`constructor-calls`: + Constructor Calls ----------------- @@ -488,3 +490,289 @@ callable. def __init__[V](self, x: T, y: list[V], z: V) -> None: ... reveal_type(accepts_callable(MyClass)) # ``def [T, V] (x: T, y: list[V], z: V) -> MyClass[T]`` + + +Metaclass Constructors +---------------------- + +A class object is itself an instance of its :term:`python:metaclass`, so the +creation of a class is also a constructor call, one made on the metaclass. While +the sections above describe how a metaclass participates in the construction of +*instances* of a class, the following sections describe the construction of class objects +themselves. + +A metaclass constructor is invoked in one of two ways: + +1. Directly, by calling the metaclass with a class name, a tuple of base + classes, and a namespace dictionary (for example, + ``Meta(name, bases, namespace)``), optionally along with additional keyword + arguments. +2. Implicitly, by a :keyword:`python:class` statement, which assembles these + three arguments from the statement and the class body and then calls the metaclass. + +In both cases, the metaclass call should be evaluated using the same rules +described in the sections above: the :meth:`!__call__` method of the metaclass's +own metaclass (typically :meth:`!type.__call__`) is invoked, which in turn calls +the :meth:`!__new__` and :meth:`!__init__` methods of the metaclass. These methods are +typically inherited from :class:`type`, whose type definitions require special +handling by type checkers, as described below. + +The following example illustrates these rules applied to metaclass calls: + + :: + + class MetaMeta(type): + def __call__(cls, *args, **kwargs) -> Never: + raise TypeError("Classes cannot be created with this metaclass") + + class Meta1(type, metaclass=MetaMeta): + pass + + # The __call__() method of the metaclass's own metaclass is evaluated first: + assert_type(Meta1("A", (), {}), Never) + + class Meta2(type): + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ): + return super().__new__(mcls, name, bases, namespace) + + # Then, the __new__ and __init__ methods of the metaclass are evaluated: + Meta2("B", (), {}, key=1) # OK, evaluates to an instance of Meta2 + Meta2("B", (), {}) # Type error: missing argument "key" + + class Meta3(type): + def __new__( + mcls, name: str, bases: tuple[type, ...], namespace: dict[str, Any] + ) -> int: + return 0 + + # Not evaluated, as __new__ does not return an instance of Meta3: + def __init__(cls, x: str) -> None: + pass + + assert_type(Meta3("C", (), {}), int) + + +The ``type`` Constructor +------------------------ + +In addition to being the default metaclass, :class:`type` serves a second purpose: +when called with a single argument, it returns the type of that argument rather than +creating a new class. :class:`type` therefore supports two distinct call forms, +which are distinguished by the number of positional arguments: + +* ``type(obj, /)`` returns the class of ``obj``. +* ``type(name, bases, dict, /, **kwds)`` creates and returns a new class. + +These two forms are typically declared as overloads of the :meth:`!__new__` and +:meth:`!__init__` methods in the type definition of :class:`type`. Both forms require +special-case handling by type checkers. + +Although the single-argument form is typically declared with a return type of +``type``, type checkers should special-case this form and evaluate its result +as ``type[T]``, where ``T`` is the type of the argument. + + :: + + def func(x: int, y: int | str) -> None: + assert_type(type(x), type[int]) + assert_type(type(y), type[int] | type[str]) + +At runtime, the single-argument form applies only when the class being called +is :class:`type` itself, and is not inherited by metaclasses: a single-argument call +to a subclass of :class:`type` raises a :exc:`TypeError`. + + :: + + class Meta(type): + pass + + assert_type(type(1), type[int]) # OK, uses the single-argument form + Meta(1) # Type error: single-argument form does not apply to subclasses + + Meta("A", (), {}) # OK, uses the three-argument form + +This special-casing applies only to the :meth:`!__new__` and :meth:`!__init__` +methods inherited from :class:`type`. If a metaclass defines its own :meth:`!__new__` +method that accepts a single argument, calls to it should be evaluated +using the rules for regular constructor calls described earlier in this +chapter. + +The evaluated return type of the three-argument form is an instance of the +metaclass being called, consistent with the return type definition of +:meth:`!type.__new__`. Type checkers may infer a more precise type for the returned +class object, for example, one equivalent to a class defined by a :keyword:`class` +statement with the given name, base classes, and namespace. + + +Class Statements +---------------- + +When a :keyword:`class` statement is executed, the runtime performs the following +steps to create the new class object (see :ref:`python:metaclasses`): + +1. The metaclass is determined. If a ``metaclass`` keyword argument is present + in the class statement's argument list, it is used as a candidate; + otherwise, :class:`type` is. The most derived metaclass among the candidate and + the metaclasses of all base classes is selected. If no candidate is a + (non-strict) subclass of all of the others, a :exc:`TypeError` is raised + (see :ref:`py315:metaclass-determination`). +2. The class namespace is prepared. If the metaclass has a :attr:`!__prepare__` + attribute, it is called as ``Meta.__prepare__(name, bases, **kwds)``, and + its result is used as the namespace object. +3. The class body is executed within this namespace. +4. The metaclass is called as ``Meta(name, bases, namespace, **kwds)``, where + ``kwds`` consists of the keyword arguments that appear in the class + statement's argument list, excluding ``metaclass`` itself. + +Type checkers may report an error for a class statement whose base classes +have incompatible metaclasses. + +Type checkers should validate keyword arguments in a class statement's +argument list (other than ``metaclass``) by evaluating the implied metaclass +call using the constructor call rules described in :ref:`constructor-calls`. + + :: + + class Meta(type): + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ): + return super().__new__(mcls, name, bases, namespace) + + class MyClass1(metaclass=Meta, key=3): # OK + pass + + class MyClass2(metaclass=Meta, key=""): # Type error: wrong type for "key" + pass + + class MyClass3(metaclass=Meta): # Type error: missing argument "key" + pass + + Meta("MyClass4", (), {}, key=3) # OK + Meta("MyClass5", (), {}, key="") # Type error: wrong type for "key" + +Keyword arguments in a direct metaclass call (such as the last two calls in the +example above) require no special handling: they are validated as part of +evaluating the call using the standard constructor call rules. + +Regardless of the evaluated return type of the implied metaclass call, a +:keyword:`class` statement defines a class, and type checkers should evaluate the +type of the bound name accordingly (``type[MyClass1]`` in the example above). +The implied metaclass call is evaluated only for the purpose of validating +its arguments. + +Type checkers may validate the implied call to :attr:`!__prepare__`: + + :: + + class Meta(type): + @classmethod + def __prepare__(mcls, name: str, bases: tuple[type, ...]): # No **kwds + return {} + + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ): + return super().__new__(mcls, name, bases, namespace) + + # The 'key' argument may result in a type checker error: + class MyClass6(metaclass=Meta, key=3): + pass + +The ``metaclass`` argument can also be an arbitrary callable that is not a subclass +of :class:`type`. Support for this pattern is currently unspecified. + + +The ``__init_subclass__()`` Method +---------------------------------- + +:meth:`!type.__new__` invokes the :meth:`~object.__init_subclass__` +method of the parent class (the class that follows the newly created class in +its :term:`python:method resolution order`) passing the newly created class as +``cls`` along with the keyword arguments supplied to the metaclass constructor +(see :ref:`python:class-customization`). + +:meth:`~object.__init_subclass__` is implicitly a class method: it is converted to +a :class:`classmethod` even when it is not explicitly decorated as one, and it is +not called for the class that defines it, only for its subclasses. Type checkers +should treat it as a classmethod if it isn't explicitly defined as one. + +If the metaclass of the class being defined does not define its own +:meth:`!__new__` method (including when no explicit metaclass +is specified), type checkers should validate the keyword arguments in a +class statement's argument list against the :meth:`~object.__init_subclass__` method of +the parent class. + + :: + + class Base: + def __init_subclass__(cls, *, flag: bool = False) -> None: + super().__init_subclass__() + + class MyClass1(Base, flag=True): # OK + pass + + class MyClass2(Base, flag=""): # Type error: wrong type for "flag" + pass + + class MyClass3(Base, other=1): # Type error: Base.__init_subclass__() got an unexpected keyword argument 'other' + pass + + class MyClass4(other=1): # Type error: MyClass4.__init_subclass__() takes no keyword arguments + pass + +A metaclass :meth:`!__init__` method has no effect on this rule: when the +metaclass does not define its own :meth:`!__new__` method, :meth:`!type.__new__` +still forwards the keyword arguments to :meth:`~object.__init_subclass__`, so +the keyword arguments should satisfy both the metaclass :meth:`!__init__` +method (as part of validating the implied metaclass call) and the +:meth:`~object.__init_subclass__` method of the parent class. + + :: + + class MetaInit(type): + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ) -> None: + super().__init__(name, bases, namespace) + + # Type error: "key" is accepted by MetaInit.__init__(), but type.__new__() + # forwards it to Base.__init_subclass__(), which does not accept it: + class MyClass5(Base, metaclass=MetaInit, key=1): + pass + +The same forwarding occurs when the metaclass is called directly: +``type("D", (Base,), {}, flag=True)`` passes ``flag`` to +:meth:`!Base.__init_subclass__`. Type checkers may validate keyword +arguments in such calls against the :meth:`~object.__init_subclass__` method of +the parent class when the base classes can be statically determined. + +If the metaclass defines its own :meth:`!__new__` method that accepts keyword +arguments only through a ``**kwargs`` parameter, whether these arguments are +forwarded to :meth:`!type.__new__` (and from there to +:meth:`~object.__init_subclass__`) cannot generally be determined statically. +In this situation, type checkers may additionally validate the keyword +arguments against the :meth:`~object.__init_subclass__` method of the parent +class. From 016cb168eb933098106b774a8968927067c47264 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:31:37 +0200 Subject: [PATCH 2/2] Add temporary type checker analysis --- metaclass-constructors-checkers.md | 356 +++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 metaclass-constructors-checkers.md diff --git a/metaclass-constructors-checkers.md b/metaclass-constructors-checkers.md new file mode 100644 index 000000000..c4c00c767 --- /dev/null +++ b/metaclass-constructors-checkers.md @@ -0,0 +1,356 @@ +# Metaclass constructors: spec examples vs. type checkers + +This document runs the examples from the [Metaclass Constructors](docs/spec/constructors.rst) +spec sections against CPython and four type checkers, to assess how far current +implementations are from the proposed behavior. + +Environment: + +- Runtime: CPython 3.14.5 +- mypy 2.3.0 (default settings) +- pyright 1.1.411 (default settings) +- ty 0.0.65 (default settings) +- pyrefly 1.1.1 (`--preset default`; the implicit `basic` preset disables + call-shape validation entirely and reports nothing on these examples) + +Each significant line is annotated with a comment of the form: + +``` +# spec: | runtime: | mypy: | pyright: | ty: | pyrefly: +``` + +- `spec:` is what the spec expects a type checker to report: `error` (should), + `may error` (optional), or `ok` (no error). For `assert_type()` lines, `ok` + means the assertion should pass. +- `runtime:` is what CPython does when the statement is executed. Note that a + line can be a type error while running fine (e.g. a wrongly typed keyword + value is not checked at runtime), and vice versa (an `assert_type()` whose + argument raises). +- A checker column says `error` if the checker reports any diagnostic on that + line, `ok` otherwise. + +## Metaclass Constructors (intro example) + +```python +from typing import Any, Never, assert_type + + +class MetaMeta(type): + def __call__(cls, *args, **kwargs) -> Never: + raise TypeError("Classes cannot be created with this metaclass") + + +class Meta1(type, metaclass=MetaMeta): + pass + + +# spec: ok | runtime: TypeError (by design) | mypy: error | pyright: ok | ty: ok | pyrefly: ok +assert_type(Meta1("A", (), {}), Never) + + +class Meta2(type): + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ): + return super().__new__(mcls, name, bases, namespace) + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +Meta2("B", (), {}, key=1) + +# spec: error | runtime: TypeError | mypy: error | pyright: error | ty: error | pyrefly: error +# runtime: TypeError: Meta2.__new__() missing 1 required keyword-only argument: 'key' +Meta2("B", (), {}) + + +class Meta3(type): + # spec: ok | mypy: error (rejects an int-returning __new__ at the definition) | pyright: ok | ty: ok | pyrefly: ok + def __new__( + mcls, name: str, bases: tuple[type, ...], namespace: dict[str, Any] + ) -> int: + return 0 + + def __init__(cls, x: str) -> None: + pass + + +# spec: ok | runtime: ok | mypy: error | pyright: ok | ty: ok | pyrefly: ok +assert_type(Meta3("C", (), {}), int) +``` + +Notes: + +- pyright, ty, and pyrefly all honor the metametaclass `__call__()` returning + `Never` and the `__init__()`-skipping rule when `__new__()` returns `int` + (`reveal_type` confirms `Never` and `int` for ty and pyrefly). mypy evaluates + `Meta1(...)` as `Meta1` and rejects the `Meta3.__new__()` definition outright. +- The direct call missing `key` is flagged by **all four** checkers — direct + metaclass calls go through the standard constructor-call rules everywhere. + +## The `type` Constructor — single-argument form inference + +```python +from typing import assert_type + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +def func(x: int, y: int | str) -> None: + assert_type(type(x), type[int]) + assert_type(type(y), type[int] | type[str]) +``` + +All four checkers already special-case `type(obj)` to `type[T]` +(pyrefly reveals exactly `type[int]`; ty infers the even more precise class +literal `` — see next example). + +## The `type` Constructor — single-argument form on subclasses + +```python +from typing import assert_type + + +class Meta(type): + pass + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: error | pyrefly: ok +assert_type(type(1), type[int]) + +# spec: error | runtime: TypeError | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +# runtime: TypeError: type.__new__() takes exactly 3 arguments (1 given) +Meta(1) + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +Meta("A", (), {}) +``` + +Notes: + +- **No checker currently reports `Meta(1)`**: all four inherit the + single-argument overload of `type.__new__()`/`type.__init__()` into the + subclass. +- ty's error on `assert_type(type(1), type[int])` is an artifact of it inferring + the *more precise* class literal `` for `type(1)` and reporting + the mismatch as `assert-type-unspellable-subtype`; the inference itself is + compliant. + +## Class Statements — keyword arguments vs. the metaclass constructor + +```python +from typing import Any + + +class Meta(type): + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ): + return super().__new__(mcls, name, bases, namespace) + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +class MyClass1(metaclass=Meta, key=3): + pass + + +# spec: error (wrong type for "key") | runtime: ok | mypy: ok | pyright: error | ty: ok | pyrefly: ok +class MyClass2(metaclass=Meta, key=""): + pass + + +# spec: error (missing "key") | runtime: TypeError | mypy: ok | pyright: error | ty: ok | pyrefly: ok +# runtime: TypeError: Meta.__new__() missing 1 required keyword-only argument: 'key' +class MyClass3(metaclass=Meta): + pass + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +Meta("MyClass4", (), {}, key=3) + +# spec: error (wrong type for "key") | runtime: ok | mypy: error | pyright: error | ty: error | pyrefly: error +Meta("MyClass5", (), {}, key="") +``` + +Notes: + +- Only pyright validates class-statement keyword arguments against a custom + metaclass `__new__()` today. mypy, ty, and pyrefly all miss `MyClass2` and + `MyClass3` — including the missing-argument case that fails at runtime. +- The equivalent *direct* calls are validated by all four checkers through the + standard constructor-call rules. + +## Class Statements — the implied `__prepare__` call + +```python +from typing import Any + + +class Meta(type): + # all four checkers report an override-incompatibility error at this + # definition (parameter "**kwds" missing vs. type.__prepare__); that check + # is unrelated to validating the implied __prepare__ call below + @classmethod + def __prepare__(mcls, name: str, bases: tuple[type, ...]): # No **kwds + return {} + + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ): + return super().__new__(mcls, name, bases, namespace) + + +# spec: may error | runtime: TypeError | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +# runtime: TypeError: Meta.__prepare__() got an unexpected keyword argument 'key' +class MyClass6(metaclass=Meta, key=3): + pass +``` + +No checker validates the implied `__prepare__` call at the class statement +(consistent with the spec's "may"). All four do flag the `__prepare__` +*definition* as an incompatible override of `type.__prepare__()`, which +indirectly catches this class of bug. + +## The `__init_subclass__()` Method — no custom metaclass `__new__()` + +```python +class Base: + def __init_subclass__(cls, *, flag: bool = False) -> None: + super().__init_subclass__() + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +class MyClass1(Base, flag=True): + pass + + +# spec: error (wrong type for "flag") | runtime: ok | mypy: error | pyright: error | ty: error | pyrefly: ok +class MyClass2(Base, flag=""): + pass + + +# spec: error (unknown argument) | runtime: TypeError | mypy: error | pyright: error | ty: error | pyrefly: ok +# runtime: TypeError: Base.__init_subclass__() got an unexpected keyword argument 'other' +class MyClass3(Base, other=1): + pass + + +# spec: error (object.__init_subclass__ accepts no kwargs) | runtime: TypeError | mypy: error | pyright: error | ty: ok | pyrefly: ok +# runtime: TypeError: MyClass4.__init_subclass__() takes no keyword arguments +class MyClass4(other=1): + pass +``` + +mypy, pyright, and ty implement this rule (ty misses only the +`object.__init_subclass__()` case); pyrefly performs no class-keyword validation. + +## The `__init_subclass__()` Method — metaclass with only a custom `__init__()` + +```python +from typing import Any + + +class Base: + def __init_subclass__(cls, *, flag: bool = False) -> None: + super().__init_subclass__() + + +class MetaInit(type): + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + *, + key: int, + ) -> None: + super().__init__(name, bases, namespace) + + +# spec: error | runtime: TypeError | mypy: ok | pyright: error | ty: error | pyrefly: ok +# runtime: TypeError: Base.__init_subclass__() got an unexpected keyword argument 'key' +class MyClass5(Base, metaclass=MetaInit, key=1): + pass +``` + +`key` is accepted by `MetaInit.__init__()`, but `type.__new__()` (not +overridden) still forwards it to `Base.__init_subclass__()`, which rejects it. +pyright and ty report it; mypy and pyrefly do not. + +## The `__init_subclass__()` Method — forwarding through direct calls and `**kwargs` + +```python +from typing import Any + + +class Base: + def __init_subclass__(cls, *, flag: bool = False) -> None: + super().__init_subclass__() + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +type("D", (Base,), {}, flag=True) + +# spec: may error | runtime: TypeError | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +# runtime: TypeError: Base.__init_subclass__() got an unexpected keyword argument 'other' +type("E", (Base,), {}, other=1) + + +class MetaKwargs(type): + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kwargs: Any, + ): + return super().__new__(mcls, name, bases, namespace, **kwargs) + + +# spec: ok | runtime: ok | mypy: ok | pyright: ok | ty: ok | pyrefly: ok +class MyClass7(Base, metaclass=MetaKwargs, flag=True): + pass + + +# spec: may error | runtime: TypeError | mypy: ok | pyright: ok | ty: error | pyrefly: ok +# runtime: TypeError: Base.__init_subclass__() got an unexpected keyword argument 'other' +class MyClass8(Base, metaclass=MetaKwargs, other=1): + pass +``` + +No checker validates `__init_subclass__()` through a *direct* `type(...)` call. +Only ty follows the forwarding through a `**kwargs`-accepting metaclass +`__new__()` in a class statement, a consequence of ty checking +`__init_subclass__()` unconditionally — which also produces false positives +when a strict metaclass *consumes* a keyword argument (e.g. +`class C(Base, metaclass=MetaStrict, key=1)` where `key` never reaches +`__init_subclass__()`). + +## Summary of divergences from the proposed spec text + +| Rule | mypy | pyright | ty | pyrefly | +| --- | --- | --- | --- | --- | +| `type(obj)` evaluates to `type[T]` | yes | yes | yes (more precise) | yes | +| One-argument form rejected on `type` subclasses | no | no | no | no | +| Metametaclass `__call__()` governs metaclass calls | no | yes | yes | yes | +| `__init__()` skipped when metaclass `__new__()` returns non-instance | no | yes | yes | yes | +| Direct metaclass call arguments validated | yes | yes | yes | yes | +| Class-statement kwargs vs. custom metaclass `__new__()` | no | yes | no | no | +| Class-statement kwargs vs. `__init_subclass__()` (should) | yes | yes | mostly | no | +| `__init__()`-only metaclass still checks `__init_subclass__()` | no | yes | yes | no | +| `__prepare__()` implied call (may) | no | no | no | no | +| Direct-call `__init_subclass__()` forwarding (may) | no | no | no | no | +| `**kwargs` metaclass forwarding (may) | no | no | yes | no |