[ty] Make tuple subclass constructors sound (#19469)

This commit is contained in:
Alex Waygood 2025-07-21 22:25:11 +01:00 committed by GitHub
parent fcdffe4ac9
commit cb5a9ff8dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 201 additions and 66 deletions

View file

@ -93,14 +93,14 @@ class SingleElementTupleSubclass(tuple[int]): ...
reveal_type(bool(SingleElementTupleSubclass((0,)))) # revealed: Literal[True]
reveal_type(SingleElementTupleSubclass.__bool__) # revealed: (self: tuple[int], /) -> Literal[True]
reveal_type(SingleElementTupleSubclass().__bool__) # revealed: () -> Literal[True]
reveal_type(SingleElementTupleSubclass((1,)).__bool__) # revealed: () -> Literal[True]
# Unknown length, but we know the length is guaranteed to be >=2
class MixedTupleSubclass(tuple[int, *tuple[str, ...], bytes]): ...
reveal_type(bool(MixedTupleSubclass((1, b"foo")))) # revealed: Literal[True]
reveal_type(MixedTupleSubclass.__bool__) # revealed: (self: tuple[int, *tuple[str, ...], bytes], /) -> Literal[True]
reveal_type(MixedTupleSubclass().__bool__) # revealed: () -> Literal[True]
reveal_type(MixedTupleSubclass((1, b"foo")).__bool__) # revealed: () -> Literal[True]
# Unknown length with an overridden `__bool__`:
class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]):

View file

@ -19,14 +19,20 @@ def _(p: P, q: Q):
## Instantiating tuples
Like all classes, tuples can be instantiated by invoking the `tuple` class. When instantiating a
specialization of `tuple` we (TODO: should) check that the values passed in match the element types
defined in the specialization.
specialization of `tuple` we check that the values passed in match the element types defined in the
specialization.
```toml
[environment]
python-version = "3.11"
```
```py
from typing_extensions import Iterable, Never
reveal_type(tuple()) # revealed: tuple[()]
reveal_type(tuple[int]((1,))) # revealed: tuple[int]
reveal_type(tuple[int, *tuple[str, ...]]((1,))) # revealed: tuple[int, *tuple[str, ...]]
reveal_type(().__class__()) # revealed: tuple[()]
reveal_type((1, 2).__class__((1, 2))) # revealed: tuple[Literal[1], Literal[2]]
@ -56,6 +62,63 @@ reveal_type((1,).__class__()) # revealed: tuple[Literal[1]]
reveal_type((1, 2).__class__()) # revealed: tuple[Literal[1], Literal[2]]
```
## Instantiating tuple subclasses
Tuple subclasses inherit the special-cased constructors from their tuple superclasses:
```toml
[environment]
python-version = "3.11"
```
```py
from typing_extensions import Iterable, Never
class UnspecializedTupleSubclass(tuple): ...
class EmptyTupleSubclass(tuple[()]): ...
class SingleElementTupleSubclass(tuple[int]): ...
class VariadicTupleSubclass(tuple[int, ...]): ...
class MixedTupleSubclass(tuple[int, *tuple[str, ...]]): ...
reveal_type(UnspecializedTupleSubclass()) # revealed: UnspecializedTupleSubclass
reveal_type(UnspecializedTupleSubclass(())) # revealed: UnspecializedTupleSubclass
reveal_type(UnspecializedTupleSubclass((1, 2, "foo"))) # revealed: UnspecializedTupleSubclass
reveal_type(UnspecializedTupleSubclass([1, 2, "foo", b"bar"])) # revealed: UnspecializedTupleSubclass
reveal_type(EmptyTupleSubclass()) # revealed: EmptyTupleSubclass
reveal_type(EmptyTupleSubclass(())) # revealed: EmptyTupleSubclass
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[()]`, found `tuple[Literal[1], Literal[2]]`"
reveal_type(EmptyTupleSubclass((1, 2))) # revealed: EmptyTupleSubclass
reveal_type(SingleElementTupleSubclass((1,))) # revealed: SingleElementTupleSubclass
# error: [missing-argument] "No argument provided for required parameter `iterable`"
reveal_type(SingleElementTupleSubclass()) # revealed: SingleElementTupleSubclass
reveal_type(VariadicTupleSubclass()) # revealed: VariadicTupleSubclass
reveal_type(VariadicTupleSubclass(())) # revealed: VariadicTupleSubclass
reveal_type(VariadicTupleSubclass([1, 2, 3])) # revealed: VariadicTupleSubclass
reveal_type(VariadicTupleSubclass((1, 2, 3, 4))) # revealed: VariadicTupleSubclass
reveal_type(MixedTupleSubclass((1,))) # revealed: MixedTupleSubclass
reveal_type(MixedTupleSubclass((1, "foo"))) # revealed: MixedTupleSubclass
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[int, *tuple[str, ...]]`, found `tuple[Literal[1], Literal[b"foo"]]`"
reveal_type(MixedTupleSubclass((1, b"foo"))) # revealed: MixedTupleSubclass
# error: [missing-argument] "No argument provided for required parameter `iterable`"
reveal_type(MixedTupleSubclass()) # revealed: MixedTupleSubclass
def _(empty: EmptyTupleSubclass, single_element: SingleElementTupleSubclass, mixed: MixedTupleSubclass, x: tuple[int, int]):
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[()]`, found `tuple[Literal[1], Literal[2]]`"
empty.__class__((1, 2))
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[int]`, found `tuple[Literal[1], Literal[2]]`"
single_element.__class__((1, 2))
# error: [missing-argument] "No argument provided for required parameter `iterable`"
mixed.__class__()
```
## Subtyping relationships
The type `tuple[S1, S2]` is a subtype of `tuple[T1, T2]` if and only if `S1` is a subtype of `T1`

View file

@ -916,6 +916,7 @@ c: Callable[[Any], str] = A().g
```py
from typing import Any, Callable
from ty_extensions import static_assert, is_assignable_to
c: Callable[[object], type] = type
c: Callable[[str], Any] = str
@ -936,6 +937,15 @@ class C:
def __init__(self, x: int) -> None: ...
c: Callable[[int], C] = C
def f(a: Callable[..., Any], b: Callable[[Any], Any]): ...
f(tuple, tuple)
def g(a: Callable[[Any, Any], Any]): ...
# error: [invalid-argument-type] "Argument to function `g` is incorrect: Expected `(Any, Any, /) -> Any`, found `<class 'tuple'>`"
g(tuple)
```
### Generic class literal types

View file

@ -4427,31 +4427,14 @@ impl<'db> Type<'db> {
.into()
}
Type::GenericAlias(alias) => {
let instantiated = Type::instance(db, ClassType::from(alias));
let parameters = if alias.origin(db).is_known(db, KnownClass::Tuple) {
// ```py
// class tuple:
// @overload
// def __new__(cls: type[tuple[()]], iterable: tuple[()] = ()) -> tuple[()]: ...
// @overload
// def __new__[T](cls: type[tuple[T, ...]], iterable: tuple[T, ...]) -> tuple[T, ...]: ...
// ```
let spec = alias.specialization(db).tuple(db);
let mut parameter =
Parameter::positional_only(Some(Name::new_static("iterable")))
.with_annotated_type(instantiated);
if matches!(spec.len().maximum(), Some(0)) {
parameter = parameter.with_default_type(TupleType::empty(db));
}
Parameters::new([parameter])
} else {
Parameters::gradual_form()
};
Type::GenericAlias(_) => {
// TODO annotated return type on `__new__` or metaclass `__call__`
// TODO check call vs signatures of `__new__` and/or `__init__`
Binding::single(self, Signature::new(parameters, Some(instantiated))).into()
Binding::single(
self,
Signature::new(Parameters::gradual_form(), self.to_instance(db)),
)
.into()
}
Type::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() {
@ -4636,7 +4619,7 @@ impl<'db> Type<'db> {
}
if let Type::GenericAlias(alias) = self {
if alias.origin(db).is_known(db, KnownClass::Tuple) {
if alias.origin(db).is_tuple(db) {
return Ok(todo_type!("*tuple[] annotations"));
}
}

View file

@ -575,7 +575,7 @@ impl<'db> ClassType<'db> {
pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> {
let (class_literal, specialization) = self.class_literal(db);
let synthesize_tuple_method = |return_type| {
let synthesize_simple_tuple_method = |return_type| {
let parameters =
Parameters::new([Parameter::positional_only(Some(Name::new_static("self")))
.with_annotated_type(Type::instance(db, self))]);
@ -587,22 +587,88 @@ impl<'db> ClassType<'db> {
};
match name {
"__len__" if class_literal.is_known(db, KnownClass::Tuple) => {
"__len__" if class_literal.is_tuple(db) => {
let return_type = specialization
.and_then(|spec| spec.tuple(db).len().into_fixed_length())
.and_then(|len| i64::try_from(len).ok())
.map(Type::IntLiteral)
.unwrap_or_else(|| KnownClass::Int.to_instance(db));
synthesize_tuple_method(return_type)
synthesize_simple_tuple_method(return_type)
}
"__bool__" if class_literal.is_known(db, KnownClass::Tuple) => {
"__bool__" if class_literal.is_tuple(db) => {
let return_type = specialization
.map(|spec| spec.tuple(db).truthiness().into_type(db))
.unwrap_or_else(|| KnownClass::Bool.to_instance(db));
synthesize_tuple_method(return_type)
synthesize_simple_tuple_method(return_type)
}
// ```py
// class tuple:
// @overload
// def __new__(cls: type[tuple[()]], iterable: tuple[()] = ()) -> tuple[()]: ...
// @overload
// def __new__[T](cls: type[tuple[T, ...]], iterable: tuple[T, ...]) -> tuple[T, ...]: ...
// ```
"__new__" if class_literal.is_tuple(db) => {
let mut iterable_parameter =
Parameter::positional_only(Some(Name::new_static("iterable")));
match specialization {
Some(spec) => {
let tuple = spec.tuple(db);
let tuple_len = tuple.len();
if tuple_len.minimum() == 0 && tuple_len.maximum().is_none() {
// If the tuple has no length restrictions,
// any iterable is allowed as long as the iterable has the correct element type.
let mut tuple_elements = tuple.all_elements();
iterable_parameter = iterable_parameter.with_annotated_type(
KnownClass::Iterable
.to_specialized_instance(db, [*tuple_elements.next().unwrap()]),
);
assert_eq!(
tuple_elements.next(),
None,
"Tuple specialization should not have more than one element when it has no length restriction"
);
} else {
// But if the tuple is of a fixed length, or has a minimum length, we require a tuple rather
// than an iterable, as a tuple is the only kind of iterable for which we can
// specify a fixed length, or that the iterable must be at least a certain length.
iterable_parameter =
iterable_parameter.with_annotated_type(Type::instance(db, self));
}
}
None => {
// If the tuple isn't specialized at all, we allow any argument as long as it is iterable.
iterable_parameter = iterable_parameter
.with_annotated_type(KnownClass::Iterable.to_instance(db));
}
}
// We allow the `iterable` parameter to be omitted for:
// - a zero-length tuple
// - an unspecialized tuple
// - a tuple with no minimum length
if specialization.is_none_or(|spec| spec.tuple(db).len().minimum() == 0) {
iterable_parameter = iterable_parameter.with_default_type(TupleType::empty(db));
}
let parameters = Parameters::new([
Parameter::positional_only(Some(Name::new_static("self")))
.with_annotated_type(SubclassOfType::from(db, self)),
iterable_parameter,
]);
let synthesized_dunder =
CallableType::function_like(db, Signature::new(parameters, None));
Place::bound(synthesized_dunder).into()
}
_ => class_literal
.own_class_member(db, specialization, name)
.map_type(|ty| ty.apply_optional_specialization(db, specialization)),
@ -659,18 +725,18 @@ impl<'db> ClassType<'db> {
)
.place;
let dunder_new_function =
if let Place::Type(Type::FunctionLiteral(dunder_new_function), _) =
dunder_new_function_symbol
{
let dunder_new_signature = dunder_new_function_symbol
.ignore_possibly_unbound()
.and_then(|ty| match ty {
Type::FunctionLiteral(function) => Some(function.signature(db)),
Type::Callable(callable) => Some(callable.signatures(db)),
_ => None,
});
let dunder_new_function = if let Some(dunder_new_signature) = dunder_new_signature {
// Step 3: If the return type of the `__new__` evaluates to a type that is not a subclass of this class,
// then we should ignore the `__init__` and just return the `__new__` method.
let returns_non_subclass =
dunder_new_function
.signature(db)
.overloads
.iter()
.any(|signature| {
let returns_non_subclass = dunder_new_signature.overloads.iter().any(|signature| {
signature.return_ty.is_some_and(|return_ty| {
!return_ty.is_assignable_to(
db,
@ -681,8 +747,11 @@ impl<'db> ClassType<'db> {
})
});
let dunder_new_bound_method =
dunder_new_function.into_bound_method_type(db, self_ty);
let dunder_new_bound_method = Type::Callable(CallableType::new(
db,
dunder_new_signature.bind_self(),
true,
));
if returns_non_subclass {
return dunder_new_bound_method;
@ -864,6 +933,10 @@ impl<'db> ClassLiteral<'db> {
self.known(db) == Some(known_class)
}
pub(crate) fn is_tuple(self, db: &'db dyn Db) -> bool {
self.is_known(db, KnownClass::Tuple)
}
pub(crate) fn generic_context(self, db: &'db dyn Db) -> Option<GenericContext<'db>> {
// Several typeshed definitions examine `sys.version_info`. To break cycles, we hard-code
// the knowledge that this class is not generic.

View file

@ -511,7 +511,7 @@ impl TupleSpecialization {
}
fn from_class(db: &dyn Db, class: ClassLiteral) -> Self {
if class.is_known(db, KnownClass::Tuple) {
if class.is_tuple(db) {
Self::Yes
} else {
Self::No

View file

@ -5637,10 +5637,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
| KnownClass::TypeVar
| KnownClass::NamedTuple
| KnownClass::TypeAliasType
| KnownClass::Tuple
| KnownClass::Deprecated
)
)
// Constructor calls to `tuple` and subclasses of `tuple` are handled in `Type::Bindings`,
// but constructor calls to `tuple[int]`, `tuple[int, ...]`, `tuple[int, *tuple[str, ...]]` (etc.)
// are handled by the default constructor-call logic (we synthesize a `__new__` method for them
// in `ClassType::own_class_member()`).
&& (callable_type.is_generic_alias() || !class.is_known(self.db(), KnownClass::Tuple))
// temporary special-casing for all subclasses of `enum.Enum`
// until we support the functional syntax for creating enum classes
&& KnownClass::Enum
@ -8003,7 +8009,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// updating all of the subscript logic below to use custom callables for all of the _other_
// special cases, too.
if let Type::ClassLiteral(class) = value_ty {
if class.is_known(self.db(), KnownClass::Tuple) {
if class.is_tuple(self.db()) {
return self
.infer_tuple_type_expression(slice)
.to_meta_type(self.db());