[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
23 changed files with 1255 additions and 442 deletions

View File

@@ -0,0 +1,302 @@
# Tests for ty's `instance-layout-conflict` error code
## `__slots__`: not specified or empty
```py
class A: ...
class B:
__slots__ = ()
class C:
__slots__ = ("lorem", "ipsum")
class AB(A, B): ... # fine
class AC(A, C): ... # fine
class BC(B, C): ... # fine
class ABC(A, B, C): ... # fine
```
## `__slots__`: incompatible tuples
<!-- snapshot-diagnostics -->
```py
class A:
__slots__ = ("a", "b")
class B:
__slots__ = ("c", "d")
class C( # error: [instance-layout-conflict]
A,
B,
): ...
```
## `__slots__` are the same value
```py
class A:
__slots__ = ("a", "b")
class B:
__slots__ = ("a", "b")
class C( # error: [instance-layout-conflict]
A,
B,
): ...
```
## `__slots__` is a string
```py
class A:
__slots__ = "abc"
class B:
__slots__ = ("abc",)
class AB( # error: [instance-layout-conflict]
A,
B,
): ...
```
## Invalid `__slots__` definitions
TODO: Emit diagnostics
```py
class NonString1:
__slots__ = 42
class NonString2:
__slots__ = b"ar"
class NonIdentifier1:
__slots__ = "42"
class NonIdentifier2:
__slots__ = ("lorem", "42")
class NonIdentifier3:
__slots__ = (e for e in ("lorem", "42"))
```
## Inherited `__slots__`
```py
class A:
__slots__ = ("a", "b")
class B(A): ...
class C:
__slots__ = ("c", "d")
class D(C): ...
class E( # error: [instance-layout-conflict]
B,
D,
): ...
```
## A single "solid base"
```py
class A:
__slots__ = ("a", "b")
class B(A): ...
class C(A): ...
class D(B, A): ... # fine
class E(B, C, A): ... # fine
```
## Post-hoc modifications to `__slots__`
```py
class A:
__slots__ = ()
__slots__ += ("a", "b")
reveal_type(A.__slots__) # revealed: tuple[Literal["a"], Literal["b"]]
class B:
__slots__ = ("c", "d")
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 `__slots__`
```py
def _(flag: bool):
class A:
if flag:
__slots__ = ("a", "b")
class B:
__slots__ = ("c", "d")
# Might or might not be fine at runtime
class C(A, B): ...
```
### Bound `__slots__` but with different types
```py
def _(flag: bool):
class A:
if flag:
__slots__ = ("a", "b")
else:
__slots__ = ()
class B:
__slots__ = ("c", "d")
# Might or might not be fine at runtime
class C(A, B): ...
```
### Non-tuple `__slots__` definitions
```py
class A:
__slots__ = ["a", "b"] # This is treated as "dynamic"
class B:
__slots__ = ("c", "d")
# False negative: [incompatible-slots]
class C(A, B): ...
```
### Diagnostic if `__slots__` is externally modified
We special-case type inference for `__slots__` and return the pure inferred type, even if the symbol
is not declared — a case in which we union with `Unknown` for other public symbols. The reason for
this is that `__slots__` has a special handling in the runtime. Modifying it externally is actually
allowed, but those changes do not take effect. If you have a class `C` with `__slots__ = ("foo",)`
and externally set `C.__slots__ = ("bar",)`, you still can't access `C.bar`. And you can still
access `C.foo`. We therefore issue a diagnostic for such assignments:
```py
class A:
__slots__ = ("a",)
# Modifying `__slots__` from within the class body is fine:
__slots__ = ("a", "b")
# No `Unknown` here:
reveal_type(A.__slots__) # revealed: tuple[Literal["a"], Literal["b"]]
# But modifying it externally is not:
# error: [invalid-assignment]
A.__slots__ = ("a",)
# error: [invalid-assignment]
A.__slots__ = ("a", "b_new")
# error: [invalid-assignment]
A.__slots__ = ("a", "b", "c")
```