Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions conformance/results/mypy/directives_disjoint_base.toml
Original file line number Diff line number Diff line change
@@ -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]
"""
19 changes: 19 additions & 0 deletions conformance/results/pyrefly/directives_disjoint_base.toml
Original file line number Diff line number Diff line change
@@ -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]
"""
21 changes: 21 additions & 0 deletions conformance/results/pyright/directives_disjoint_base.toml
Original file line number Diff line number Diff line change
@@ -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 "<subclass of Left and Right>" cannot be assigned to parameter "arg" of type "Never" in function "assert_never"
  Type "<subclass of Left and Right>" is not assignable to type "Never" (reportArgumentType)
"""
7 changes: 7 additions & 0 deletions conformance/results/results.html
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,13 @@ <h3>Python Type System Conformance Test Results</h3>
<th class="column col2 conformant">Pass</th>
<th class="column col2 partially-conformant"><div class="hover-text">Partial<span class="tooltip-text" id="bottom"><p>Does not detect calls to deprecated overloads.</p><p>Does not detect implicit calls to deprecated dunder methods, for example via operators.</p><p>Does not detect accesses of, or attempts to set, deprecated properties.</p></span></div></th>
</tr>
<tr><th class="column col1">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;directives_disjoint_base</th>
<th class="column col2 conformant">Pass</th>
<th class="column col2 not-conformant"><div class="hover-text">Unsupported<span class="tooltip-text" id="bottom"><p>Does not support PEP 800 disjoint-base semantics.</p></span></div></th>
<th class="column col2 not-conformant"><div class="hover-text">Unsupported<span class="tooltip-text" id="bottom"><p>Does not support PEP 800 disjoint-base semantics.</p></span></div></th>
<th class="column col2 not-conformant"><div class="hover-text">Unsupported<span class="tooltip-text" id="bottom"><p>Does not support PEP 800 disjoint-base semantics.</p></span></div></th>
<th class="column col2 partially-conformant"><div class="hover-text">Partial<span class="tooltip-text" id="bottom"><p>Does not reject @disjoint_base on TypedDict or Protocol definitions.</p></span></div></th>
</tr>
<tr><th class="column col1">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;directives_no_type_check</th>
<th class="column col2 partially-conformant"><div class="hover-text">Partial<span class="tooltip-text" id="bottom"><p>Does not honor `@no_type_check` class decorator (allowed).</p><p>Does not reject invalid call of `@no_type_check` function.</p></span></div></th>
<th class="column col2 conformant"><div class="hover-text">Pass*<span class="tooltip-text" id="bottom"><p>Does not honor `@no_type_check` class decorator (allowed).</p></span></div></th>
Expand Down
17 changes: 17 additions & 0 deletions conformance/results/ty/directives_disjoint_base.toml
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, this error is actually inaccurate; there's no runtime error if the class is just marked with @disjoint_base at runtime.

Copy link
Copy Markdown
Member

@carljm carljm Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the error is correct for the intended use of disjoint_base, though. I'm not sure it would be preferable to adjust the error message to account for misuse on classes that aren't actually disjoint bases.

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`
"""
18 changes: 18 additions & 0 deletions conformance/results/zuban/directives_disjoint_base.toml
Original file line number Diff line number Diff line change
@@ -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 "<subclass of "tests.directives_disjoint_base.Left" and "tests.directives_disjoint_base.Right">"; expected "Never" [arg-type]
"""
135 changes: 135 additions & 0 deletions conformance/tests/directives_disjoint_base.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions docs/spec/directives.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`