Skip to content

[ty] Improve handling of disjointness for NominalInstanceTypes and SubclassOfTypes #18864

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a238153
Remove `NominalInstanceType::is_disjoint_from_nominal_instance_of_class`
AlexWaygood Jun 22, 2025
1be0e3e
Add an understanding of "solid bases" to ty
AlexWaygood Jun 22, 2025
b1daaeb
Remove generalized logic in favour of special-cased logic
AlexWaygood Jun 22, 2025
37ee566
Detect multiple solid bases in class definitions
AlexWaygood Jun 23, 2025
bedbd89
fix bug and add more tests
AlexWaygood Jun 23, 2025
dfbafaa
ensure empty `__slots__` are okay even if the variable is declared wi…
AlexWaygood Jun 23, 2025
2db3cd9
run pre-commit
AlexWaygood Jun 23, 2025
8ef43ab
update generate files
AlexWaygood Jun 23, 2025
93e2d4d
cleanup
AlexWaygood Jun 23, 2025
5c3312b
more tests and docs
AlexWaygood Jun 23, 2025
c965a63
minor fixups
AlexWaygood Jun 23, 2025
e54b1a4
fix new test failure after rebase
AlexWaygood Jun 23, 2025
d51812a
Update crates/ty_python_semantic/resources/mdtest/slots.md
AlexWaygood Jun 24, 2025
79daf6e
wip
AlexWaygood Jun 24, 2025
48fb21b
Merge branch 'alex/disjointness' of https://github.com/astral-sh/ruff…
AlexWaygood Jun 24, 2025
242ccab
completely rewrite `TypeIs` suite to no longer use builtins
AlexWaygood Jun 24, 2025
2a8e985
remove redundant check and add Carl's other test
AlexWaygood Jun 24, 2025
ad3d451
many diagnostics improvements
AlexWaygood Jun 24, 2025
3287902
Update crates/ty_python_semantic/src/types/class.rs
AlexWaygood Jun 24, 2025
c03e155
Merge branch 'main' into alex/disjointness
AlexWaygood Jun 24, 2025
9e40334
rewrite docs
AlexWaygood Jun 24, 2025
9c63a94
more concise message
AlexWaygood Jun 24, 2025
6aad76f
less use of "solid base" in mdtests
AlexWaygood Jun 24, 2025
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
262 changes: 143 additions & 119 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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: ...
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -83,7 +85,7 @@ class NonIdentifier3:
__slots__ = (e for e in ("lorem", "42"))
```

## Inheritance
## Inherited `__slots__`

```py
class A:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -165,7 +257,7 @@ def _(flag: bool):
class C(A, B): ...
```

### Non-tuples
### Non-tuple `__slots__` definitions

```py
class A:
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/resources/mdtest/narrow/match.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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
```
Expand Down
Loading
Loading