From 236e1f59c7afd15c103619cb009672afb75c0f3b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 15 Apr 2026 20:28:28 -0700 Subject: [PATCH] Add PEP 800 (disjoint bases) --- .../mypy/directives_disjoint_base.toml | 14 ++ .../pyrefly/directives_disjoint_base.toml | 19 +++ .../pyright/directives_disjoint_base.toml | 21 +++ conformance/results/results.html | 7 + .../results/ty/directives_disjoint_base.toml | 17 +++ .../zuban/directives_disjoint_base.toml | 18 +++ conformance/tests/directives_disjoint_base.py | 135 ++++++++++++++++++ docs/spec/directives.rst | 74 ++++++++++ 8 files changed, 305 insertions(+) create mode 100644 conformance/results/mypy/directives_disjoint_base.toml create mode 100644 conformance/results/pyrefly/directives_disjoint_base.toml create mode 100644 conformance/results/pyright/directives_disjoint_base.toml create mode 100644 conformance/results/ty/directives_disjoint_base.toml create mode 100644 conformance/results/zuban/directives_disjoint_base.toml create mode 100644 conformance/tests/directives_disjoint_base.py diff --git a/conformance/results/mypy/directives_disjoint_base.toml b/conformance/results/mypy/directives_disjoint_base.toml new file mode 100644 index 000000000..77e4345df --- /dev/null +++ b/conformance/results/mypy/directives_disjoint_base.toml @@ -0,0 +1,14 @@ +conformant = "Pass" +conformance_automated = "Pass" +errors_diff = """ +""" +output = """ +directives_disjoint_base.py:69: error: Class "LeftAndRight" has incompatible disjoint bases [misc] +directives_disjoint_base.py:73: error: Class "LeftChildAndRight" has incompatible disjoint bases [misc] +directives_disjoint_base.py:77: error: Class "LeftAndRightViaChild" has incompatible disjoint bases [misc] +directives_disjoint_base.py:81: error: Class "LeftRecord" has incompatible disjoint bases [misc] +directives_disjoint_base.py:105: error: Class "IncompatibleSlots" has incompatible disjoint bases [misc] +directives_disjoint_base.py:113: error: Value of type variable "_TC" of "disjoint_base" cannot be "Callable[[], None]" [type-var] +directives_disjoint_base.py:118: error: @disjoint_base cannot be used with TypedDict [misc] +directives_disjoint_base.py:123: error: @disjoint_base cannot be used with protocol class [misc] +""" diff --git a/conformance/results/pyrefly/directives_disjoint_base.toml b/conformance/results/pyrefly/directives_disjoint_base.toml new file mode 100644 index 000000000..ba8eec306 --- /dev/null +++ b/conformance/results/pyrefly/directives_disjoint_base.toml @@ -0,0 +1,19 @@ +conformance_automated = "Fail" +conformant = "Unsupported" +notes = """ +Does not support PEP 800 disjoint-base semantics. +""" +errors_diff = """ +Line 69: Expected 1 errors +Line 73: Expected 1 errors +Line 77: Expected 1 errors +Line 105: Expected 1 errors +Line 118: Expected 1 errors +Line 123: Expected 1 errors +Line 60: Unexpected errors ['Named tuples do not support multiple inheritance [invalid-inheritance]'] +""" +output = """ +ERROR directives_disjoint_base.py:60:7-18: Named tuples do not support multiple inheritance [invalid-inheritance] +ERROR directives_disjoint_base.py:81:7-17: Named tuples do not support multiple inheritance [invalid-inheritance] +ERROR directives_disjoint_base.py:113:1-15: `() -> None` is not assignable to upper bound `type[object]` of type variable `_TC` [bad-specialization] +""" diff --git a/conformance/results/pyright/directives_disjoint_base.toml b/conformance/results/pyright/directives_disjoint_base.toml new file mode 100644 index 000000000..023812996 --- /dev/null +++ b/conformance/results/pyright/directives_disjoint_base.toml @@ -0,0 +1,21 @@ +conformance_automated = "Fail" +conformant = "Unsupported" +notes = """ +Does not support PEP 800 disjoint-base semantics. +""" +errors_diff = """ +Line 69: Expected 1 errors +Line 73: Expected 1 errors +Line 77: Expected 1 errors +Line 81: Expected 1 errors +Line 105: Expected 1 errors +Line 118: Expected 1 errors +Line 123: Expected 1 errors +""" +output = """ +directives_disjoint_base.py:113:2 - error: Argument of type "() -> None" cannot be assigned to parameter "cls" of type "_TC@disjoint_base" in function "disjoint_base" +  Type "() -> None" is not assignable to type "type" +    "FunctionType" is not assignable to "type" (reportArgumentType) +directives_disjoint_base.py:135:22 - error: Argument of type "" cannot be assigned to parameter "arg" of type "Never" in function "assert_never" +  Type "" is not assignable to type "Never" (reportArgumentType) +""" diff --git a/conformance/results/results.html b/conformance/results/results.html index 95f5422b5..9a2654d8d 100644 --- a/conformance/results/results.html +++ b/conformance/results/results.html @@ -1167,6 +1167,13 @@

Python Type System Conformance Test Results

Pass
Partial

Does not detect calls to deprecated overloads.

Does not detect implicit calls to deprecated dunder methods, for example via operators.

Does not detect accesses of, or attempts to set, deprecated properties.

+     directives_disjoint_base +Pass +
Unsupported

Does not support PEP 800 disjoint-base semantics.

+
Unsupported

Does not support PEP 800 disjoint-base semantics.

+
Unsupported

Does not support PEP 800 disjoint-base semantics.

+
Partial

Does not reject @disjoint_base on TypedDict or Protocol definitions.

+      directives_no_type_check
Partial

Does not honor `@no_type_check` class decorator (allowed).

Does not reject invalid call of `@no_type_check` function.

Pass*

Does not honor `@no_type_check` class decorator (allowed).

diff --git a/conformance/results/ty/directives_disjoint_base.toml b/conformance/results/ty/directives_disjoint_base.toml new file mode 100644 index 000000000..67748afdd --- /dev/null +++ b/conformance/results/ty/directives_disjoint_base.toml @@ -0,0 +1,17 @@ +conformance_automated = "Fail" +conformant = "Partial" +notes = """ +Does not reject @disjoint_base on TypedDict or Protocol definitions. +""" +errors_diff = """ +Line 118: Expected 1 errors +Line 123: Expected 1 errors +""" +output = """ +directives_disjoint_base.py:69:7: error[instance-layout-conflict] Class will raise `TypeError` at runtime due to incompatible bases: Bases `Left` and `Right` cannot be combined in multiple inheritance +directives_disjoint_base.py:73:7: error[instance-layout-conflict] Class will raise `TypeError` at runtime due to incompatible bases: Bases `LeftChild` and `Right` cannot be combined in multiple inheritance +directives_disjoint_base.py:77:7: error[instance-layout-conflict] Class will raise `TypeError` at runtime due to incompatible bases: Bases `LeftAndPlain` and `Right` cannot be combined in multiple inheritance +directives_disjoint_base.py:81:7: error[instance-layout-conflict] Class will raise `TypeError` at runtime due to incompatible bases: Bases `Left` and `Record` cannot be combined in multiple inheritance +directives_disjoint_base.py:105:7: error[instance-layout-conflict] Class will raise `TypeError` at runtime due to incompatible bases: Bases `SlotBase1` and `SlotBase2` cannot be combined in multiple inheritance +directives_disjoint_base.py:113:1: error[invalid-argument-type] Argument to function `disjoint_base` is incorrect: Argument type `def func() -> None` does not satisfy upper bound `type` of type variable `_TC` +""" diff --git a/conformance/results/zuban/directives_disjoint_base.toml b/conformance/results/zuban/directives_disjoint_base.toml new file mode 100644 index 000000000..e979a2fb8 --- /dev/null +++ b/conformance/results/zuban/directives_disjoint_base.toml @@ -0,0 +1,18 @@ +conformance_automated = "Fail" +conformant = "Unsupported" +notes = """ +Does not support PEP 800 disjoint-base semantics. +""" +errors_diff = """ +Line 69: Expected 1 errors +Line 73: Expected 1 errors +Line 77: Expected 1 errors +Line 81: Expected 1 errors +Line 105: Expected 1 errors +Line 118: Expected 1 errors +Line 123: Expected 1 errors +""" +output = """ +directives_disjoint_base.py:113: error: Value of type variable "_TC" of "disjoint_base" cannot be "Callable[[], None]" [type-var] +directives_disjoint_base.py:135: error: Argument 1 to "assert_never" has incompatible type ""; expected "Never" [arg-type] +""" diff --git a/conformance/tests/directives_disjoint_base.py b/conformance/tests/directives_disjoint_base.py new file mode 100644 index 000000000..49ce5d218 --- /dev/null +++ b/conformance/tests/directives_disjoint_base.py @@ -0,0 +1,135 @@ +""" +Tests the typing.disjoint_base decorator introduced in PEP 800. +""" + +# Specification: https://typing.readthedocs.io/en/latest/spec/directives.html#disjoint-base +# See also https://peps.python.org/pep-0800/ + +from typing import NamedTuple, Protocol, TypedDict, assert_never +from typing_extensions import disjoint_base + + +# > It may only be used on nominal classes, including ``NamedTuple`` +# > definitions + + +@disjoint_base +class Left: + pass + + +@disjoint_base +class Right: + pass + + +@disjoint_base +class LeftChild(Left): + pass + + +@disjoint_base +class Record(NamedTuple): + value: int + + +class Plain: + pass + + +# > If the candidate set contains a single disjoint base, that is the +# > class's disjoint base. + + +class OtherLeftChild(Left): + pass + + +# > If there are multiple candidates, but one of them is a subclass of +# > all other candidates, that class is the disjoint base. + + +class LeftAndPlain(Left, Plain): + pass + + +class LeftChildAndLeft(LeftChild, Left): + pass + + +class PlainRecord(Plain, Record): + pass + + +# > Type checkers must check for a valid disjoint base when checking class definitions, +# > and emit a diagnostic if they encounter a class +# > definition that lacks a valid disjoint base. + + +class LeftAndRight(Left, Right): # E: incompatible disjoint bases + pass + + +class LeftChildAndRight(LeftChild, Right): # E: incompatible disjoint bases + pass + + +class LeftAndRightViaChild(LeftAndPlain, Right): # E: incompatible disjoint bases + pass + + +class LeftRecord(Left, Record): # E: incompatible disjoint bases + pass + + +# > A nominal class is a disjoint base if it [...] contains a non-empty +# > `__slots__` definition. + + +class SlotBase1: + __slots__ = ("x",) + + +class SlotBase2: + __slots__ = ("y",) + + +class EmptySlots: + __slots__ = () + + +class SlotAndEmptySlots(SlotBase1, EmptySlots): + pass + + +class IncompatibleSlots(SlotBase1, SlotBase2): # E: incompatible disjoint bases + pass + + +# > it is a type checker error to use the decorator on a function, +# > ``TypedDict`` definition, or ``Protocol`` definition. + + +@disjoint_base # E: disjoint_base cannot be applied to a function +def func() -> None: + pass + + +@disjoint_base # E: disjoint_base cannot be applied to a TypedDict +class Movie(TypedDict): + name: str + + +@disjoint_base # E: disjoint_base cannot be applied to a Protocol +class SupportsClose(Protocol): + def close(self) -> None: + ... + + +# > Type checkers may use disjoint bases to determine that two classes cannot +# > have a common subclass. + + +def narrow(obj: Left) -> None: + if isinstance(obj, Right): # E?: may be treated as unreachable + assert_never(obj) # E?: may not be narrowed to Never diff --git a/docs/spec/directives.rst b/docs/spec/directives.rst index 6ed7016a7..b3969d681 100644 --- a/docs/spec/directives.rst +++ b/docs/spec/directives.rst @@ -294,3 +294,77 @@ deprecated functionality in their CI pipeline. Therefore, it is recommended that type checkers provide configuration options that cover both use cases. As with any other type checker error, it is also possible to ignore deprecations using ``# type: ignore`` comments. + +.. _`disjoint-base`: + +``@disjoint_base`` +------------------ + +(Originally specified in :pep:`800`.) + +The ``@typing.disjoint_base`` decorator may be used to mark a nominal class as +a disjoint base. It may only be used on nominal classes, including ``NamedTuple`` +definitions; it is a type checker error to use the decorator on a function, +``TypedDict`` definition, or ``Protocol`` definition. + +We define two properties on (nominal) classes: a class may or may not *be* a +disjoint base, and every class must *have* a valid disjoint base. + +A nominal class is a disjoint base if it is decorated with ``@typing.disjoint_base``, +or if it contains a non-empty ``__slots__`` definition. +This includes classes that have ``__slots__`` because of the ``@dataclass(slots=True)`` decorator or +because of the use of the ``dataclass_transform`` mechanism to add slots. +The universal base class, ``object``, is also a disjoint base. + +To determine a class's disjoint base, we look at all of its base classes to +determine a set of candidate disjoint bases. For each base +that is itself a disjoint base, the candidate is the base itself; otherwise, +it is the base's disjoint base. If the candidate set contains +a single disjoint base, that is the class's disjoint base. If there are multiple +candidates, but one of them is a subclass of all other candidates, +that class is the disjoint base. If no such candidate exists, the class does not +have a valid disjoint base, and therefore cannot exist. + +Type checkers must check for a valid disjoint base when checking class definitions, +and emit a diagnostic if they encounter a class +definition that lacks a valid disjoint base. Type checkers may also use the disjoint +base mechanism to determine whether types are disjoint, +for example when checking whether a type narrowing construct like ``isinstance()`` +results in an unreachable branch. + +Example:: + + from typing import disjoint_base, assert_never + + @disjoint_base + class Disjoint1: + pass + + @disjoint_base + class Disjoint2: + pass + + @disjoint_base + class DisjointChild(Disjoint1): + pass + + class C1: # disjoint base is `object` + pass + + # OK: candidate disjoint bases are `Disjoint1` and `object`, and `Disjoint1` is a subclass of `object`. + class C2(Disjoint1, C1): # disjoint base is `Disjoint1` + pass + + # OK: candidate disjoint bases are `DisjointChild` and `Disjoint1`, and `DisjointChild` is a subclass of `Disjoint1`. + class C3(DisjointChild, Disjoint1): # disjoint base is `DisjointChild` + pass + + # error: candidate disjoint bases are `Disjoint1` and `Disjoint2`, but neither is a subclass of the other + class C4(Disjoint1, Disjoint2): + pass + + def narrower(obj: Disjoint1) -> None: + if isinstance(obj, Disjoint2): + assert_never(obj) # OK: child class of `Disjoint1` and `Disjoint2` cannot exist + if isinstance(obj, C1): + reveal_type(obj) # Shows a non-empty type, e.g. `Disjoint1 & C1`