[ty] default-specialize class-literal types in assignment to generic-alias types (#21883)

Fixes https://github.com/astral-sh/ty/issues/1832, fixes
https://github.com/astral-sh/ty/issues/1513

## Summary

A class object `C` (for which we infer an unspecialized `ClassLiteral`
type) should always be assignable to the type `type[C]` (which is
default-specialized, if `C` is generic). We already implemented this for
most cases, but we missed the case of a generic final type, where we
simplify `type[C]` to the `GenericAlias` type for the default
specialization of `C`. So we also need to implement this assignability
of generic `ClassLiteral` types as-if default-specialized.

## Test Plan

Added mdtests that failed before this PR.

---------

Co-authored-by: David Peter <mail@david-peter.de>
This commit is contained in:
Carl Meyer 2025-12-10 08:18:08 -08:00 committed by GitHub
parent 7bf50e70a7
commit 951766d1fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 323 additions and 55 deletions

View File

@ -220,6 +220,48 @@ def f(x: type[int | str | bytes | range]):
reveal_type(x) # revealed: <class 'range'> reveal_type(x) # revealed: <class 'range'>
``` ```
## `classinfo` is a generic final class
```toml
[environment]
python-version = "3.12"
```
When we check a generic `@final` class against `type[GenericFinal]`, we can conclude that the check
always succeeds:
```py
from typing import final
@final
class GenericFinal[T]:
x: T # invariant
def f(x: type[GenericFinal]):
reveal_type(x) # revealed: <class 'GenericFinal[Unknown]'>
if issubclass(x, GenericFinal):
reveal_type(x) # revealed: <class 'GenericFinal[Unknown]'>
else:
reveal_type(x) # revealed: Never
```
This also works if the typevar has an upper bound:
```py
@final
class BoundedGenericFinal[T: int]:
x: T # invariant
def g(x: type[BoundedGenericFinal]):
reveal_type(x) # revealed: <class 'BoundedGenericFinal[Unknown]'>
if issubclass(x, BoundedGenericFinal):
reveal_type(x) # revealed: <class 'BoundedGenericFinal[Unknown]'>
else:
reveal_type(x) # revealed: Never
```
## Special cases ## Special cases
### Emit a diagnostic if the first argument is of wrong type ### Emit a diagnostic if the first argument is of wrong type

View File

@ -152,61 +152,6 @@ class Foo(type[int]): ...
reveal_mro(Foo) # revealed: (<class 'Foo'>, <class 'type'>, <class 'object'>) reveal_mro(Foo) # revealed: (<class 'Foo'>, <class 'type'>, <class 'object'>)
``` ```
## Display of generic `type[]` types
```toml
[environment]
python-version = "3.12"
```
```py
from typing import Generic, TypeVar
class Foo[T]: ...
S = TypeVar("S")
class Bar(Generic[S]): ...
def _(x: Foo[int], y: Bar[str], z: list[bytes]):
reveal_type(type(x)) # revealed: type[Foo[int]]
reveal_type(type(y)) # revealed: type[Bar[str]]
reveal_type(type(z)) # revealed: type[list[bytes]]
```
## Checking generic `type[]` types
```toml
[environment]
python-version = "3.12"
```
```py
class C[T]:
pass
class D[T]:
pass
var: type[C[int]] = C[int]
var: type[C[int]] = D[int] # error: [invalid-assignment] "Object of type `<class 'D[int]'>` is not assignable to `type[C[int]]`"
```
However, generic `Protocol` classes are still TODO:
```py
from typing import Protocol
class Proto[U](Protocol):
def some_method(self): ...
# TODO: should be error: [invalid-assignment]
var: type[Proto[int]] = C[int]
def _(p: type[Proto[int]]):
reveal_type(p) # revealed: type[@Todo(type[T] for protocols)]
```
## `@final` classes ## `@final` classes
`type[]` types are eagerly converted to class-literal types if a class decorated with `@final` is `type[]` types are eagerly converted to class-literal types if a class decorated with `@final` is

View File

@ -274,3 +274,180 @@ class Foo[T]: ...
# error: [invalid-parameter-default] "Default value of type `<class 'Foo'>` is not assignable to annotated parameter type `type[T@f]`" # error: [invalid-parameter-default] "Default value of type `<class 'Foo'>` is not assignable to annotated parameter type `type[T@f]`"
def f[T: Foo[Any]](x: type[T] = Foo): ... def f[T: Foo[Any]](x: type[T] = Foo): ...
``` ```
## Display of generic `type[]` types
```toml
[environment]
python-version = "3.12"
```
```py
from typing import Generic, TypeVar
class Foo[T]: ...
S = TypeVar("S")
class Bar(Generic[S]): ...
def _(x: Foo[int], y: Bar[str], z: list[bytes]):
reveal_type(type(x)) # revealed: type[Foo[int]]
reveal_type(type(y)) # revealed: type[Bar[str]]
reveal_type(type(z)) # revealed: type[list[bytes]]
```
## Checking generic `type[]` types
```toml
[environment]
python-version = "3.12"
```
```py
class C[T]:
pass
class D[T]:
pass
var: type[C[int]] = C[int]
var: type[C[int]] = D[int] # error: [invalid-assignment] "Object of type `<class 'D[int]'>` is not assignable to `type[C[int]]`"
```
However, generic `Protocol` classes are still TODO:
```py
from typing import Protocol
class Proto[U](Protocol):
def some_method(self): ...
# TODO: should be error: [invalid-assignment]
var: type[Proto[int]] = C[int]
def _(p: type[Proto[int]]):
reveal_type(p) # revealed: type[@Todo(type[T] for protocols)]
```
## Generic `@final` classes
```toml
[environment]
python-version = "3.13"
```
An unspecialized generic final class object is assignable to its default-specialized `type[]` type
(which is actually internally simplified to a GenericAlias type, since there cannot be subclasses.)
```py
from typing import final
@final
class P[T]:
x: T
def expects_type_p(x: type[P]):
pass
def expects_type_p_of_int(x: type[P[int]]):
pass
# OK, the default specialization of `P` is assignable to `type[P[Unknown]]`
expects_type_p(P)
# Also OK, because `P[int]` and `P[str]` are both assignable to `P[Unknown]`
expects_type_p(P[int])
expects_type_p(P[str])
# Also OK, because the default specialization is `P[Unknown]` which is assignable to `P[int]`
expects_type_p_of_int(P)
expects_type_p_of_int(P[int])
# Not OK, because `P[str]` is not assignable to `P[int]`
expects_type_p_of_int(P[str]) # error: [invalid-argument-type]
```
The same principles apply when typevar defaults are used, but the results are a bit different
because the default-specialization is no longer a forgiving `Unknown` type:
```py
@final
class P[T = str]:
x: T
def expects_type_p(x: type[P]):
pass
def expects_type_p_of_int(x: type[P[int]]):
pass
def expects_type_p_of_str(x: type[P[str]]):
pass
# OK, the default specialization is now `P[str]`, but we have the default specialization on both
# sides, so it is assignable.
expects_type_p(P)
# Also OK if the explicit specialization lines up with the default, in either direction:
expects_type_p(P[str])
expects_type_p_of_str(P)
expects_type_p_of_str(P[str])
# Not OK if the specializations don't line up:
expects_type_p(P[int]) # error: [invalid-argument-type]
expects_type_p_of_int(P[str]) # error: [invalid-argument-type]
expects_type_p_of_int(P) # error: [invalid-argument-type]
expects_type_p_of_str(P[int]) # error: [invalid-argument-type]
```
This also works with `ParamSpec`:
```py
@final
class C[**P]: ...
def expects_type_c(f: type[C]): ...
def expects_type_c_of_int_and_str(x: type[C[int, str]]): ...
# OK, the unspecialized `C` is assignable to `type[C[...]]`
expects_type_c(C)
# Also OK, any specialization is assignable to the unspecialized `C`
expects_type_c(C[int])
expects_type_c(C[str, int, bytes])
# Ok, the unspecialized `C` is assignable to `type[C[int, str]]`
expects_type_c_of_int_and_str(C)
# Also OK, the specialized `C[int, str]` is assignable to `type[C[int, str]]`
expects_type_c_of_int_and_str(C[int, str])
# TODO: these should be errors
expects_type_c_of_int_and_str(C[str])
expects_type_c_of_int_and_str(C[int, str, bytes])
expects_type_c_of_int_and_str(C[str, int])
```
And with a `ParamSpec` that has a default:
```py
@final
class C[**P = [int, str]]: ...
def expects_type_c_default(f: type[C]): ...
def expects_type_c_default_of_int(f: type[C[int]]): ...
def expects_type_c_default_of_int_str(f: type[C[int, str]]): ...
expects_type_c_default(C)
expects_type_c_default(C[int, str])
expects_type_c_default_of_int(C)
expects_type_c_default_of_int(C[int])
expects_type_c_default_of_int_str(C)
expects_type_c_default_of_int_str(C[int, str])
# TODO: these should be errors
expects_type_c_default(C[int])
expects_type_c_default_of_int(C[str])
expects_type_c_default_of_int_str(C[str, int])
```

View File

@ -1338,6 +1338,40 @@ def g3(obj: Foo[tuple[A]]):
f3(obj) f3(obj)
``` ```
## Generic aliases
```py
from typing import final
from ty_extensions import static_assert, is_assignable_to, TypeOf
class GenericClass[T]:
x: T # invariant
static_assert(is_assignable_to(TypeOf[GenericClass], type[GenericClass]))
static_assert(is_assignable_to(TypeOf[GenericClass[int]], type[GenericClass]))
static_assert(is_assignable_to(TypeOf[GenericClass], type[GenericClass[int]]))
static_assert(is_assignable_to(TypeOf[GenericClass[int]], type[GenericClass[int]]))
static_assert(not is_assignable_to(TypeOf[GenericClass[str]], type[GenericClass[int]]))
class GenericClassIntBound[T: int]:
x: T # invariant
static_assert(is_assignable_to(TypeOf[GenericClassIntBound], type[GenericClassIntBound]))
static_assert(is_assignable_to(TypeOf[GenericClassIntBound[int]], type[GenericClassIntBound]))
static_assert(is_assignable_to(TypeOf[GenericClassIntBound], type[GenericClassIntBound[int]]))
static_assert(is_assignable_to(TypeOf[GenericClassIntBound[int]], type[GenericClassIntBound[int]]))
@final
class GenericFinalClass[T]:
x: T # invariant
static_assert(is_assignable_to(TypeOf[GenericFinalClass], type[GenericFinalClass]))
static_assert(is_assignable_to(TypeOf[GenericFinalClass[int]], type[GenericFinalClass]))
static_assert(is_assignable_to(TypeOf[GenericFinalClass], type[GenericFinalClass[int]]))
static_assert(is_assignable_to(TypeOf[GenericFinalClass[int]], type[GenericFinalClass[int]]))
static_assert(not is_assignable_to(TypeOf[GenericFinalClass[str]], type[GenericFinalClass[int]]))
```
## `TypeGuard` and `TypeIs` ## `TypeGuard` and `TypeIs`
`TypeGuard[...]` and `TypeIs[...]` are always assignable to `bool`. `TypeGuard[...]` and `TypeIs[...]` are always assignable to `bool`.

View File

@ -666,6 +666,48 @@ static_assert(is_disjoint_from(Path, tuple[Path | None, str, int]))
static_assert(is_disjoint_from(Path, Path2)) static_assert(is_disjoint_from(Path, Path2))
``` ```
## Generic aliases
```toml
[environment]
python-version = "3.12"
```
```py
from typing import final
from ty_extensions import static_assert, is_disjoint_from, TypeOf
class GenericClass[T]:
x: T # invariant
static_assert(not is_disjoint_from(TypeOf[GenericClass], type[GenericClass]))
# TODO: these should not error
static_assert(not is_disjoint_from(TypeOf[GenericClass[int]], type[GenericClass])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericClass], type[GenericClass[int]])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericClass[int]], type[GenericClass[int]]))
static_assert(is_disjoint_from(TypeOf[GenericClass[str]], type[GenericClass[int]]))
class GenericClassIntBound[T: int]:
x: T # invariant
static_assert(not is_disjoint_from(TypeOf[GenericClassIntBound], type[GenericClassIntBound]))
# TODO: these should not error
static_assert(not is_disjoint_from(TypeOf[GenericClassIntBound[int]], type[GenericClassIntBound])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericClassIntBound], type[GenericClassIntBound[int]])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericClassIntBound[int]], type[GenericClassIntBound[int]]))
@final
class GenericFinalClass[T]:
x: T # invariant
# TODO: these should not error
static_assert(not is_disjoint_from(TypeOf[GenericFinalClass], type[GenericFinalClass])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericFinalClass[int]], type[GenericFinalClass])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericFinalClass], type[GenericFinalClass[int]])) # error: [static-assert-error]
static_assert(not is_disjoint_from(TypeOf[GenericFinalClass[int]], type[GenericFinalClass[int]]))
static_assert(is_disjoint_from(TypeOf[GenericFinalClass[str]], type[GenericFinalClass[int]]))
```
## Callables ## Callables
No two callable types are disjoint because there exists a non-empty callable type No two callable types are disjoint because there exists a non-empty callable type

View File

@ -2709,6 +2709,34 @@ impl<'db> Type<'db> {
) )
}) })
.unwrap_or_else(|| ConstraintSet::from(relation.is_assignability())), .unwrap_or_else(|| ConstraintSet::from(relation.is_assignability())),
// Similarly, `<class 'C'>` is assignable to `<class 'C[...]'>` (a generic-alias type)
// if the default specialization of `C` is assignable to `C[...]`. This scenario occurs
// with final generic types, where `type[C[...]]` is simplified to the generic-alias
// type `<class 'C[...]'>`, due to the fact that `C[...]` has no subclasses.
(Type::ClassLiteral(class), Type::GenericAlias(target_alias)) => {
class.default_specialization(db).has_relation_to_impl(
db,
ClassType::Generic(target_alias),
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
// For generic aliases, we delegate to the underlying class type.
(Type::GenericAlias(self_alias), Type::GenericAlias(target_alias)) => {
ClassType::Generic(self_alias).has_relation_to_impl(
db,
ClassType::Generic(target_alias),
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
(Type::GenericAlias(alias), Type::SubclassOf(target_subclass_ty)) => target_subclass_ty (Type::GenericAlias(alias), Type::SubclassOf(target_subclass_ty)) => target_subclass_ty
.subclass_of() .subclass_of()
.into_class(db) .into_class(db)