[ty] Improve disjointness inference for NominalInstanceTypes and SubclassOfTypes (#18864)

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
Alex Waygood 2025-06-24 21:27:37 +01:00 committed by GitHub
parent d89f75f9cc
commit 9d8cba4e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1255 additions and 442 deletions

View file

@ -192,16 +192,18 @@ def _(
from typing import Callable, Union
from ty_extensions import Intersection, Not
class Foo: ...
def _(
c: Intersection[Callable[[Union[int, str]], int], int],
d: Intersection[int, Callable[[Union[int, str]], int]],
e: Intersection[int, Callable[[Union[int, str]], int], str],
f: Intersection[Not[Callable[[int, str], Intersection[int, str]]]],
e: Intersection[int, Callable[[Union[int, str]], int], Foo],
f: Intersection[Not[Callable[[int, str], Intersection[int, Foo]]]],
):
reveal_type(c) # revealed: ((int | str, /) -> int) & int
reveal_type(d) # revealed: int & ((int | str, /) -> int)
reveal_type(e) # revealed: int & ((int | str, /) -> int) & str
reveal_type(f) # revealed: ~((int, str, /) -> int & str)
reveal_type(e) # revealed: int & ((int | str, /) -> int) & Foo
reveal_type(f) # revealed: ~((int, str, /) -> int & Foo)
```
## Nested

View file

@ -88,3 +88,26 @@ def assigns_complex(x: complex):
def f(x: complex):
reveal_type(x) # revealed: int | float | complex
```
## Narrowing
`int`, `float` and `complex` are all disjoint, which means that the union `int | float` can easily
be narrowed to `int` or `float`:
```py
from typing_extensions import assert_type
from ty_extensions import JustFloat
def f(x: complex):
reveal_type(x) # revealed: int | float | complex
if isinstance(x, int):
reveal_type(x) # revealed: int
elif isinstance(x, float):
reveal_type(x) # revealed: float
else:
reveal_type(x) # revealed: complex
assert isinstance(x, float)
assert_type(x, JustFloat)
```

View file

@ -271,7 +271,9 @@ def _(target: int | None | float):
reveal_type(y) # revealed: Literal[1, 2]
def _(target: None | str):
class Foo: ...
def _(target: None | Foo):
y = 1
match target:

View file

@ -653,7 +653,7 @@ from ty_extensions import Not
def remove_constraint[T: (int, str, bool)](t: T) -> None:
def _(x: Intersection[T, Not[int]]) -> None:
reveal_type(x) # revealed: str & ~int
reveal_type(x) # revealed: str
def _(x: Intersection[T, Not[str]]) -> None:
# With OneOf this would be OneOf[int, bool]

View file

@ -1,6 +1,6 @@
# `__slots__`
# Tests for ty's `instance-layout-conflict` error code
## Not specified and empty
## `__slots__`: not specified or empty
```py
class A: ...
@ -17,7 +17,9 @@ class BC(B, C): ... # fine
class ABC(A, B, C): ... # fine
```
## Incompatible tuples
## `__slots__`: incompatible tuples
<!-- snapshot-diagnostics -->
```py
class A:
@ -26,13 +28,13 @@ class A:
class B:
__slots__ = ("c", "d")
class C(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
class C( # error: [instance-layout-conflict]
A,
B,
): ...
```
## Same value
## `__slots__` are the same value
```py
class A:
@ -41,13 +43,13 @@ class A:
class B:
__slots__ = ("a", "b")
class C(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
class C( # error: [instance-layout-conflict]
A,
B,
): ...
```
## Strings
## `__slots__` is a string
```py
class A:
@ -56,13 +58,13 @@ class A:
class B:
__slots__ = ("abc",)
class AB(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
class AB( # error: [instance-layout-conflict]
A,
B,
): ...
```
## Invalid
## Invalid `__slots__` definitions
TODO: Emit diagnostics
@ -83,7 +85,7 @@ class NonIdentifier3:
__slots__ = (e for e in ("lorem", "42"))
```
## Inheritance
## Inherited `__slots__`
```py
class A:
@ -95,13 +97,13 @@ class C:
__slots__ = ("c", "d")
class D(C): ...
class E(
B, # error: [incompatible-slots]
D, # error: [incompatible-slots]
class E( # error: [instance-layout-conflict]
B,
D,
): ...
```
## Single solid base
## A single "solid base"
```py
class A:
@ -113,7 +115,7 @@ class D(B, A): ... # fine
class E(B, C, A): ... # fine
```
## Post-hoc modifications
## Post-hoc modifications to `__slots__`
```py
class A:
@ -125,15 +127,105 @@ reveal_type(A.__slots__) # revealed: tuple[Literal["a"], Literal["b"]]
class B:
__slots__ = ("c", "d")
class C(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
class C( # error: [instance-layout-conflict]
A,
B,
): ...
```
## Explicitly annotated `__slots__`
We do not emit false positives on classes with empty `__slots__` definitions, even if the
`__slots__` definitions are annotated with variadic tuples:
```py
class Foo:
__slots__: tuple[str, ...] = ()
class Bar:
__slots__: tuple[str, ...] = ()
class Baz(Foo, Bar): ... # fine
```
## Built-ins with implicit layouts
<!-- snapshot-diagnostics -->
Certain classes implemented in C extensions also have an extended instance memory layout, in the
same way as classes that define non-empty `__slots__`. (CPython internally calls all such classes
with a unique instance memory layout "solid bases", and we also borrow this term.) There is
currently no generalized way for ty to detect such a C-extension class, as there is currently no way
of expressing the fact that a class is a solid base in a stub file. However, ty special-cases
certain builtin classes in order to detect that attempting to combine them in a single MRO would
fail:
```py
# fmt: off
class A( # error: [instance-layout-conflict]
int,
str
): ...
class B:
__slots__ = ("b",)
class C( # error: [instance-layout-conflict]
int,
B,
): ...
class D(int): ...
class E( # error: [instance-layout-conflict]
D,
str
): ...
class F(int, str, bytes, bytearray): ... # error: [instance-layout-conflict]
# fmt: on
```
We avoid emitting an `instance-layout-conflict` diagnostic for this class definition, because
`range` is `@final`, so we'll complain about the `class` statement anyway:
```py
class Foo(range, str): ... # error: [subclass-of-final-class]
```
## Multiple "solid bases" where one is a subclass of the other
A class is permitted to multiple-inherit from multiple solid bases if one is a subclass of the
other:
```py
class A:
__slots__ = ("a",)
class B(A):
__slots__ = ("b",)
class C(B, A): ... # fine
```
The same principle, but a more complex example:
```py
class AA:
__slots__ = ("a",)
class BB(AA):
__slots__ = ("b",)
class CC(BB): ...
class DD(AA): ...
class FF(CC, DD): ... # fine
```
## False negatives
### Possibly unbound
### Possibly unbound `__slots__`
```py
def _(flag: bool):
@ -148,7 +240,7 @@ def _(flag: bool):
class C(A, B): ...
```
### Bound but with different types
### Bound `__slots__` but with different types
```py
def _(flag: bool):
@ -165,7 +257,7 @@ def _(flag: bool):
class C(A, B): ...
```
### Non-tuples
### Non-tuple `__slots__` definitions
```py
class A:
@ -178,13 +270,6 @@ class B:
class C(A, B): ...
```
### Built-ins with implicit layouts
```py
# False negative: [incompatible-slots]
class A(int, str): ...
```
### Diagnostic if `__slots__` is externally modified
We special-case type inference for `__slots__` and return the pure inferred type, even if the symbol

View file

@ -46,12 +46,15 @@ def _(flag1: bool, flag2: bool):
## Assignment expressions
```py
def f() -> int | str | None: ...
class Foo: ...
class Bar: ...
if isinstance(x := f(), int):
reveal_type(x) # revealed: int
elif isinstance(x, str):
reveal_type(x) # revealed: str & ~int
def f() -> Foo | Bar | None: ...
if isinstance(x := f(), Foo):
reveal_type(x) # revealed: Foo
elif isinstance(x, Bar):
reveal_type(x) # revealed: Bar & ~Foo
else:
reveal_type(x) # revealed: None
```

View file

@ -87,7 +87,7 @@ match x:
case 6.0:
reveal_type(x) # revealed: float
case 1j:
reveal_type(x) # revealed: complex & ~float
reveal_type(x) # revealed: complex
case b"foo":
reveal_type(x) # revealed: Literal[b"foo"]
@ -137,7 +137,7 @@ match x:
case True | False:
reveal_type(x) # revealed: bool
case 3.14 | 2.718 | 1.414:
reveal_type(x) # revealed: float & ~tuple[Unknown, ...]
reveal_type(x) # revealed: float
reveal_type(x) # revealed: object
```

View file

@ -178,25 +178,26 @@ def _(d: Any):
from typing import Any
from typing_extensions import TypeGuard, TypeIs
def guard_str(a: object) -> TypeGuard[str]:
class Foo: ...
class Bar: ...
def guard_foo(a: object) -> TypeGuard[Foo]:
return True
def is_int(a: object) -> TypeIs[int]:
def is_bar(a: object) -> TypeIs[Bar]:
return True
```
```py
def _(a: str | int):
if guard_str(a):
# TODO: Should be `str`
reveal_type(a) # revealed: str | int
def _(a: Foo | Bar):
if guard_foo(a):
# TODO: Should be `Foo`
reveal_type(a) # revealed: Foo | Bar
else:
reveal_type(a) # revealed: str | int
reveal_type(a) # revealed: Foo | Bar
if is_int(a):
reveal_type(a) # revealed: int
if is_bar(a):
reveal_type(a) # revealed: Bar
else:
reveal_type(a) # revealed: str & ~int
reveal_type(a) # revealed: Foo & ~Bar
```
Attribute and subscript narrowing is supported:
@ -209,68 +210,68 @@ T = TypeVar("T")
class C(Generic[T]):
v: T
def _(a: tuple[str, int] | tuple[int, str], c: C[Any]):
# TODO: Should be `TypeGuard[str @ a[1]]`
if reveal_type(guard_str(a[1])): # revealed: @Todo(`TypeGuard[]` special form)
# TODO: Should be `tuple[int, str]`
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
# TODO: Should be `str`
reveal_type(a[1]) # revealed: int | str
def _(a: tuple[Foo, Bar] | tuple[Bar, Foo], c: C[Any]):
# TODO: Should be `TypeGuard[Foo @ a[1]]`
if reveal_type(guard_foo(a[1])): # revealed: @Todo(`TypeGuard[]` special form)
# TODO: Should be `tuple[Bar, Foo]`
reveal_type(a) # revealed: tuple[Foo, Bar] | tuple[Bar, Foo]
# TODO: Should be `Foo`
reveal_type(a[1]) # revealed: Bar | Foo
if reveal_type(is_int(a[0])): # revealed: TypeIs[int @ a[0]]
# TODO: Should be `tuple[int, str]`
reveal_type(a) # revealed: tuple[str, int] | tuple[int, str]
reveal_type(a[0]) # revealed: int
if reveal_type(is_bar(a[0])): # revealed: TypeIs[Bar @ a[0]]
# TODO: Should be `tuple[Bar, Bar & Foo]`
reveal_type(a) # revealed: tuple[Foo, Bar] | tuple[Bar, Foo]
reveal_type(a[0]) # revealed: Bar
# TODO: Should be `TypeGuard[str @ c.v]`
if reveal_type(guard_str(c.v)): # revealed: @Todo(`TypeGuard[]` special form)
# TODO: Should be `TypeGuard[Foo @ c.v]`
if reveal_type(guard_foo(c.v)): # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(c) # revealed: C[Any]
# TODO: Should be `str`
# TODO: Should be `Foo`
reveal_type(c.v) # revealed: Any
if reveal_type(is_int(c.v)): # revealed: TypeIs[int @ c.v]
if reveal_type(is_bar(c.v)): # revealed: TypeIs[Bar @ c.v]
reveal_type(c) # revealed: C[Any]
reveal_type(c.v) # revealed: Any & int
reveal_type(c.v) # revealed: Any & Bar
```
Indirect usage is supported within the same scope:
```py
def _(a: str | int):
b = guard_str(a)
c = is_int(a)
def _(a: Foo | Bar):
b = guard_foo(a)
c = is_bar(a)
reveal_type(a) # revealed: str | int
# TODO: Should be `TypeGuard[str @ a]`
reveal_type(a) # revealed: Foo | Bar
# TODO: Should be `TypeGuard[Foo @ a]`
reveal_type(b) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(c) # revealed: TypeIs[int @ a]
reveal_type(c) # revealed: TypeIs[Bar @ a]
if b:
# TODO should be `str`
reveal_type(a) # revealed: str | int
# TODO should be `Foo`
reveal_type(a) # revealed: Foo | Bar
else:
reveal_type(a) # revealed: str | int
reveal_type(a) # revealed: Foo | Bar
if c:
# TODO should be `int`
reveal_type(a) # revealed: str | int
# TODO should be `Bar`
reveal_type(a) # revealed: Foo | Bar
else:
# TODO should be `str & ~int`
reveal_type(a) # revealed: str | int
# TODO should be `Foo & ~Bar`
reveal_type(a) # revealed: Foo | Bar
```
Further writes to the narrowed place invalidate the narrowing:
```py
def _(x: str | int, flag: bool) -> None:
b = is_int(x)
reveal_type(b) # revealed: TypeIs[int @ x]
def _(x: Foo | Bar, flag: bool) -> None:
b = is_bar(x)
reveal_type(b) # revealed: TypeIs[Bar @ x]
if flag:
x = ""
x = Foo()
if b:
reveal_type(x) # revealed: str | int
reveal_type(x) # revealed: Foo | Bar
```
The `TypeIs` type remains effective across generic boundaries:
@ -280,19 +281,19 @@ from typing_extensions import TypeVar, reveal_type
T = TypeVar("T")
def f(v: object) -> TypeIs[int]:
def f(v: object) -> TypeIs[Bar]:
return True
def g(v: T) -> T:
return v
def _(a: str):
def _(a: Foo):
# `reveal_type()` has the type `[T]() -> T`
if reveal_type(f(a)): # revealed: TypeIs[int @ a]
reveal_type(a) # revealed: str & int
if reveal_type(f(a)): # revealed: TypeIs[Bar @ a]
reveal_type(a) # revealed: Foo & Bar
if g(f(a)):
reveal_type(a) # revealed: str & int
reveal_type(a) # revealed: Foo & Bar
```
## `TypeGuard` special cases
@ -301,28 +302,32 @@ def _(a: str):
from typing import Any
from typing_extensions import TypeGuard, TypeIs
def guard_int(a: object) -> TypeGuard[int]:
class Foo: ...
class Bar: ...
class Baz(Bar): ...
def guard_foo(a: object) -> TypeGuard[Foo]:
return True
def is_int(a: object) -> TypeIs[int]:
def is_bar(a: object) -> TypeIs[Bar]:
return True
def does_not_narrow_in_negative_case(a: str | int):
if not guard_int(a):
# TODO: Should be `str`
reveal_type(a) # revealed: str | int
def does_not_narrow_in_negative_case(a: Foo | Bar):
if not guard_foo(a):
# TODO: Should be `Bar`
reveal_type(a) # revealed: Foo | Bar
else:
reveal_type(a) # revealed: str | int
reveal_type(a) # revealed: Foo | Bar
def narrowed_type_must_be_exact(a: object, b: bool):
if guard_int(b):
# TODO: Should be `int`
reveal_type(b) # revealed: bool
def narrowed_type_must_be_exact(a: object, b: Baz):
if guard_foo(b):
# TODO: Should be `Foo`
reveal_type(b) # revealed: Baz
if isinstance(a, bool) and is_int(a):
reveal_type(a) # revealed: bool
if isinstance(a, Baz) and is_bar(a):
reveal_type(a) # revealed: Baz
if isinstance(a, bool) and guard_int(a):
# TODO: Should be `int`
reveal_type(a) # revealed: bool
if isinstance(a, Bar) and guard_foo(a):
# TODO: Should be `Foo`
reveal_type(a) # revealed: Bar
```

View file

@ -0,0 +1,173 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: instance_layout_conflict.md - Tests for ty's `instance-layout-conflict` error code - Built-ins with implicit layouts
mdtest path: crates/ty_python_semantic/resources/mdtest/instance_layout_conflict.md
---
# Python source files
## mdtest_snippet.py
```
1 | # fmt: off
2 |
3 | class A( # error: [instance-layout-conflict]
4 | int,
5 | str
6 | ): ...
7 |
8 | class B:
9 | __slots__ = ("b",)
10 |
11 | class C( # error: [instance-layout-conflict]
12 | int,
13 | B,
14 | ): ...
15 | class D(int): ...
16 |
17 | class E( # error: [instance-layout-conflict]
18 | D,
19 | str
20 | ): ...
21 |
22 | class F(int, str, bytes, bytearray): ... # error: [instance-layout-conflict]
23 |
24 | # fmt: on
25 | class Foo(range, str): ... # error: [subclass-of-final-class]
```
# Diagnostics
```
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> src/mdtest_snippet.py:3:7
|
1 | # fmt: off
2 |
3 | class A( # error: [instance-layout-conflict]
| _______^
4 | | int,
5 | | str
6 | | ): ...
| |_^ Bases `int` and `str` cannot be combined in multiple inheritance
7 |
8 | class B:
|
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> src/mdtest_snippet.py:4:5
|
3 | class A( # error: [instance-layout-conflict]
4 | int,
| --- `int` instances have a distinct memory layout because of the way `int` is implemented in a C extension
5 | str
| --- `str` instances have a distinct memory layout because of the way `str` is implemented in a C extension
6 | ): ...
|
info: rule `instance-layout-conflict` is enabled by default
```
```
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> src/mdtest_snippet.py:11:7
|
9 | __slots__ = ("b",)
10 |
11 | class C( # error: [instance-layout-conflict]
| _______^
12 | | int,
13 | | B,
14 | | ): ...
| |_^ Bases `int` and `B` cannot be combined in multiple inheritance
15 | class D(int): ...
|
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> src/mdtest_snippet.py:12:5
|
11 | class C( # error: [instance-layout-conflict]
12 | int,
| --- `int` instances have a distinct memory layout because of the way `int` is implemented in a C extension
13 | B,
| - `B` instances have a distinct memory layout because `B` defines non-empty `__slots__`
14 | ): ...
15 | class D(int): ...
|
info: rule `instance-layout-conflict` is enabled by default
```
```
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> src/mdtest_snippet.py:17:7
|
15 | class D(int): ...
16 |
17 | class E( # error: [instance-layout-conflict]
| _______^
18 | | D,
19 | | str
20 | | ): ...
| |_^ Bases `D` and `str` cannot be combined in multiple inheritance
21 |
22 | class F(int, str, bytes, bytearray): ... # error: [instance-layout-conflict]
|
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> src/mdtest_snippet.py:18:5
|
17 | class E( # error: [instance-layout-conflict]
18 | D,
| -
| |
| `D` instances have a distinct memory layout because `D` inherits from `int`
| `int` instances have a distinct memory layout because of the way `int` is implemented in a C extension
19 | str
| --- `str` instances have a distinct memory layout because of the way `str` is implemented in a C extension
20 | ): ...
|
info: rule `instance-layout-conflict` is enabled by default
```
```
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> src/mdtest_snippet.py:22:7
|
20 | ): ...
21 |
22 | class F(int, str, bytes, bytearray): ... # error: [instance-layout-conflict]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Bases `int`, `str`, `bytes` and `bytearray` cannot be combined in multiple inheritance
23 |
24 | # fmt: on
|
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> src/mdtest_snippet.py:22:9
|
20 | ): ...
21 |
22 | class F(int, str, bytes, bytearray): ... # error: [instance-layout-conflict]
| --- --- ----- --------- `bytearray` instances have a distinct memory layout because of the way `bytearray` is implemented in a C extension
| | | |
| | | `bytes` instances have a distinct memory layout because of the way `bytes` is implemented in a C extension
| | `str` instances have a distinct memory layout because of the way `str` is implemented in a C extension
| `int` instances have a distinct memory layout because of the way `int` is implemented in a C extension
23 |
24 | # fmt: on
|
info: rule `instance-layout-conflict` is enabled by default
```
```
error[subclass-of-final-class]: Class `Foo` cannot inherit from final class `range`
--> src/mdtest_snippet.py:25:11
|
24 | # fmt: on
25 | class Foo(range, str): ... # error: [subclass-of-final-class]
| ^^^^^
|
info: rule `subclass-of-final-class` is enabled by default
```

View file

@ -0,0 +1,54 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: instance_layout_conflict.md - Tests for ty's `instance-layout-conflict` error code - `__slots__`: incompatible tuples
mdtest path: crates/ty_python_semantic/resources/mdtest/instance_layout_conflict.md
---
# Python source files
## mdtest_snippet.py
```
1 | class A:
2 | __slots__ = ("a", "b")
3 |
4 | class B:
5 | __slots__ = ("c", "d")
6 |
7 | class C( # error: [instance-layout-conflict]
8 | A,
9 | B,
10 | ): ...
```
# Diagnostics
```
error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases
--> src/mdtest_snippet.py:7:7
|
5 | __slots__ = ("c", "d")
6 |
7 | class C( # error: [instance-layout-conflict]
| _______^
8 | | A,
9 | | B,
10 | | ): ...
| |_^ Bases `A` and `B` cannot be combined in multiple inheritance
|
info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts
--> src/mdtest_snippet.py:8:5
|
7 | class C( # error: [instance-layout-conflict]
8 | A,
| - `A` instances have a distinct memory layout because `A` defines non-empty `__slots__`
9 | B,
| - `B` instances have a distinct memory layout because `B` defines non-empty `__slots__`
10 | ): ...
|
info: rule `instance-layout-conflict` is enabled by default
```

View file

@ -20,8 +20,6 @@ static_assert(not is_disjoint_from(Any, Not[Any]))
static_assert(not is_disjoint_from(LiteralString, LiteralString))
static_assert(not is_disjoint_from(str, LiteralString))
static_assert(not is_disjoint_from(str, type))
static_assert(not is_disjoint_from(str, type[Any]))
```
## Class hierarchies
@ -71,6 +69,88 @@ class UsesMeta2(metaclass=Meta2): ...
static_assert(is_disjoint_from(UsesMeta1, UsesMeta2))
```
## `@final` builtin types
Some builtins types are declared as `@final`:
```py
from ty_extensions import static_assert, is_disjoint_from
class Foo: ...
# `range`, `slice` and `memoryview` are all declared as `@final`:
static_assert(is_disjoint_from(range, Foo))
static_assert(is_disjoint_from(type[range], type[Foo]))
static_assert(is_disjoint_from(slice, Foo))
static_assert(is_disjoint_from(type[slice], type[Foo]))
static_assert(is_disjoint_from(memoryview, Foo))
static_assert(is_disjoint_from(type[memoryview], type[Foo]))
```
## "Solid base" builtin types
Most other builtins can be subclassed and can even be used in multiple inheritance. However, builtin
classes *cannot* generally be used in multiple inheritance with other builtin types. This is because
the CPython interpreter considers these classes "solid bases": due to the way they are implemented
in C, they have atypical instance memory layouts. No class can ever have more than one "solid base"
in its MRO.
It's not currently possible for ty to detect in a generalized way whether a class is a "solid base"
or not, but we special-case some commonly used builtin types:
```py
from typing import Any
from ty_extensions import static_assert, is_disjoint_from
class Foo: ...
static_assert(is_disjoint_from(list, dict))
static_assert(is_disjoint_from(list[Foo], dict))
static_assert(is_disjoint_from(list[Any], dict))
static_assert(is_disjoint_from(list, dict[Foo, Foo]))
static_assert(is_disjoint_from(list[Foo], dict[Foo, Foo]))
static_assert(is_disjoint_from(list[Any], dict[Foo, Foo]))
static_assert(is_disjoint_from(list, dict[Any, Any]))
static_assert(is_disjoint_from(list[Foo], dict[Any, Any]))
static_assert(is_disjoint_from(list[Any], dict[Any, Any]))
static_assert(is_disjoint_from(type[list], type[dict]))
```
## Other solid bases
As well as certain classes that are implemented in C extensions, any class that declares non-empty
`__slots__` is also considered a "solid base"; these types are also considered to be disjoint by ty:
```py
from ty_extensions import static_assert, is_disjoint_from
class A:
__slots__ = ("a",)
class B:
__slots__ = ("a",)
class C:
__slots__ = ()
static_assert(is_disjoint_from(A, B))
static_assert(is_disjoint_from(type[A], type[B]))
static_assert(not is_disjoint_from(A, C))
static_assert(not is_disjoint_from(type[A], type[C]))
static_assert(not is_disjoint_from(B, C))
static_assert(not is_disjoint_from(type[B], type[C]))
```
Two solid bases are not disjoint if one inherits from the other, however:
```py
class D(A):
__slots__ = ("d",)
static_assert(is_disjoint_from(D, B))
static_assert(not is_disjoint_from(D, A))
```
## Tuple types
```py
@ -396,8 +476,10 @@ reveal_type(C.prop) # revealed: property
class D:
pass
static_assert(not is_disjoint_from(int, TypeOf[C.prop]))
static_assert(not is_disjoint_from(TypeOf[C.prop], int))
class Whatever: ...
static_assert(not is_disjoint_from(Whatever, TypeOf[C.prop]))
static_assert(not is_disjoint_from(TypeOf[C.prop], Whatever))
static_assert(is_disjoint_from(TypeOf[C.prop], D))
static_assert(is_disjoint_from(D, TypeOf[C.prop]))
```

View file

@ -263,8 +263,10 @@ reveal_type(top_materialization(Intersection[Any | int, tuple[str, Unknown]]))
# revealed: Never
reveal_type(bottom_materialization(Intersection[Any | int, tuple[str, Unknown]]))
# revealed: int & tuple[str]
reveal_type(bottom_materialization(Intersection[Any | int, tuple[str]]))
class Foo: ...
# revealed: Foo & tuple[str]
reveal_type(bottom_materialization(Intersection[Any | Foo, tuple[str]]))
reveal_type(top_materialization(Intersection[list[Any], list[int]])) # revealed: list[T_all] & list[int]
reveal_type(bottom_materialization(Intersection[list[Any], list[int]])) # revealed: list[T_all] & list[int]