Skip to content

Commit cb5a9ff

Browse files
authored
[ty] Make tuple subclass constructors sound (#19469)
1 parent fcdffe4 commit cb5a9ff

File tree

7 files changed

+201
-66
lines changed

7 files changed

+201
-66
lines changed

crates/ty_python_semantic/resources/mdtest/expression/boolean.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ class SingleElementTupleSubclass(tuple[int]): ...
9393

9494
reveal_type(bool(SingleElementTupleSubclass((0,)))) # revealed: Literal[True]
9595
reveal_type(SingleElementTupleSubclass.__bool__) # revealed: (self: tuple[int], /) -> Literal[True]
96-
reveal_type(SingleElementTupleSubclass().__bool__) # revealed: () -> Literal[True]
96+
reveal_type(SingleElementTupleSubclass((1,)).__bool__) # revealed: () -> Literal[True]
9797

9898
# Unknown length, but we know the length is guaranteed to be >=2
9999
class MixedTupleSubclass(tuple[int, *tuple[str, ...], bytes]): ...
100100

101101
reveal_type(bool(MixedTupleSubclass((1, b"foo")))) # revealed: Literal[True]
102102
reveal_type(MixedTupleSubclass.__bool__) # revealed: (self: tuple[int, *tuple[str, ...], bytes], /) -> Literal[True]
103-
reveal_type(MixedTupleSubclass().__bool__) # revealed: () -> Literal[True]
103+
reveal_type(MixedTupleSubclass((1, b"foo")).__bool__) # revealed: () -> Literal[True]
104104

105105
# Unknown length with an overridden `__bool__`:
106106
class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]):

crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@ def _(p: P, q: Q):
1919
## Instantiating tuples
2020

2121
Like all classes, tuples can be instantiated by invoking the `tuple` class. When instantiating a
22-
specialization of `tuple` we (TODO: should) check that the values passed in match the element types
23-
defined in the specialization.
22+
specialization of `tuple` we check that the values passed in match the element types defined in the
23+
specialization.
24+
25+
```toml
26+
[environment]
27+
python-version = "3.11"
28+
```
2429

2530
```py
2631
from typing_extensions import Iterable, Never
2732

2833
reveal_type(tuple()) # revealed: tuple[()]
2934
reveal_type(tuple[int]((1,))) # revealed: tuple[int]
35+
reveal_type(tuple[int, *tuple[str, ...]]((1,))) # revealed: tuple[int, *tuple[str, ...]]
3036
reveal_type(().__class__()) # revealed: tuple[()]
3137
reveal_type((1, 2).__class__((1, 2))) # revealed: tuple[Literal[1], Literal[2]]
3238

@@ -56,6 +62,63 @@ reveal_type((1,).__class__()) # revealed: tuple[Literal[1]]
5662
reveal_type((1, 2).__class__()) # revealed: tuple[Literal[1], Literal[2]]
5763
```
5864

65+
## Instantiating tuple subclasses
66+
67+
Tuple subclasses inherit the special-cased constructors from their tuple superclasses:
68+
69+
```toml
70+
[environment]
71+
python-version = "3.11"
72+
```
73+
74+
```py
75+
from typing_extensions import Iterable, Never
76+
77+
class UnspecializedTupleSubclass(tuple): ...
78+
class EmptyTupleSubclass(tuple[()]): ...
79+
class SingleElementTupleSubclass(tuple[int]): ...
80+
class VariadicTupleSubclass(tuple[int, ...]): ...
81+
class MixedTupleSubclass(tuple[int, *tuple[str, ...]]): ...
82+
83+
reveal_type(UnspecializedTupleSubclass()) # revealed: UnspecializedTupleSubclass
84+
reveal_type(UnspecializedTupleSubclass(())) # revealed: UnspecializedTupleSubclass
85+
reveal_type(UnspecializedTupleSubclass((1, 2, "foo"))) # revealed: UnspecializedTupleSubclass
86+
reveal_type(UnspecializedTupleSubclass([1, 2, "foo", b"bar"])) # revealed: UnspecializedTupleSubclass
87+
88+
reveal_type(EmptyTupleSubclass()) # revealed: EmptyTupleSubclass
89+
reveal_type(EmptyTupleSubclass(())) # revealed: EmptyTupleSubclass
90+
91+
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[()]`, found `tuple[Literal[1], Literal[2]]`"
92+
reveal_type(EmptyTupleSubclass((1, 2))) # revealed: EmptyTupleSubclass
93+
94+
reveal_type(SingleElementTupleSubclass((1,))) # revealed: SingleElementTupleSubclass
95+
96+
# error: [missing-argument] "No argument provided for required parameter `iterable`"
97+
reveal_type(SingleElementTupleSubclass()) # revealed: SingleElementTupleSubclass
98+
99+
reveal_type(VariadicTupleSubclass()) # revealed: VariadicTupleSubclass
100+
reveal_type(VariadicTupleSubclass(())) # revealed: VariadicTupleSubclass
101+
reveal_type(VariadicTupleSubclass([1, 2, 3])) # revealed: VariadicTupleSubclass
102+
reveal_type(VariadicTupleSubclass((1, 2, 3, 4))) # revealed: VariadicTupleSubclass
103+
104+
reveal_type(MixedTupleSubclass((1,))) # revealed: MixedTupleSubclass
105+
reveal_type(MixedTupleSubclass((1, "foo"))) # revealed: MixedTupleSubclass
106+
107+
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[int, *tuple[str, ...]]`, found `tuple[Literal[1], Literal[b"foo"]]`"
108+
reveal_type(MixedTupleSubclass((1, b"foo"))) # revealed: MixedTupleSubclass
109+
110+
# error: [missing-argument] "No argument provided for required parameter `iterable`"
111+
reveal_type(MixedTupleSubclass()) # revealed: MixedTupleSubclass
112+
113+
def _(empty: EmptyTupleSubclass, single_element: SingleElementTupleSubclass, mixed: MixedTupleSubclass, x: tuple[int, int]):
114+
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[()]`, found `tuple[Literal[1], Literal[2]]`"
115+
empty.__class__((1, 2))
116+
# error: [invalid-argument-type] "Argument is incorrect: Expected `tuple[int]`, found `tuple[Literal[1], Literal[2]]`"
117+
single_element.__class__((1, 2))
118+
# error: [missing-argument] "No argument provided for required parameter `iterable`"
119+
mixed.__class__()
120+
```
121+
59122
## Subtyping relationships
60123

61124
The type `tuple[S1, S2]` is a subtype of `tuple[T1, T2]` if and only if `S1` is a subtype of `T1`

crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,7 @@ c: Callable[[Any], str] = A().g
916916

917917
```py
918918
from typing import Any, Callable
919+
from ty_extensions import static_assert, is_assignable_to
919920

920921
c: Callable[[object], type] = type
921922
c: Callable[[str], Any] = str
@@ -936,6 +937,15 @@ class C:
936937
def __init__(self, x: int) -> None: ...
937938

938939
c: Callable[[int], C] = C
940+
941+
def f(a: Callable[..., Any], b: Callable[[Any], Any]): ...
942+
943+
f(tuple, tuple)
944+
945+
def g(a: Callable[[Any, Any], Any]): ...
946+
947+
# error: [invalid-argument-type] "Argument to function `g` is incorrect: Expected `(Any, Any, /) -> Any`, found `<class 'tuple'>`"
948+
g(tuple)
939949
```
940950

941951
### Generic class literal types

crates/ty_python_semantic/src/types.rs

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4427,31 +4427,14 @@ impl<'db> Type<'db> {
44274427
.into()
44284428
}
44294429

4430-
Type::GenericAlias(alias) => {
4431-
let instantiated = Type::instance(db, ClassType::from(alias));
4432-
4433-
let parameters = if alias.origin(db).is_known(db, KnownClass::Tuple) {
4434-
// ```py
4435-
// class tuple:
4436-
// @overload
4437-
// def __new__(cls: type[tuple[()]], iterable: tuple[()] = ()) -> tuple[()]: ...
4438-
// @overload
4439-
// def __new__[T](cls: type[tuple[T, ...]], iterable: tuple[T, ...]) -> tuple[T, ...]: ...
4440-
// ```
4441-
let spec = alias.specialization(db).tuple(db);
4442-
let mut parameter =
4443-
Parameter::positional_only(Some(Name::new_static("iterable")))
4444-
.with_annotated_type(instantiated);
4445-
if matches!(spec.len().maximum(), Some(0)) {
4446-
parameter = parameter.with_default_type(TupleType::empty(db));
4447-
}
4448-
Parameters::new([parameter])
4449-
} else {
4450-
Parameters::gradual_form()
4451-
};
4430+
Type::GenericAlias(_) => {
44524431
// TODO annotated return type on `__new__` or metaclass `__call__`
44534432
// TODO check call vs signatures of `__new__` and/or `__init__`
4454-
Binding::single(self, Signature::new(parameters, Some(instantiated))).into()
4433+
Binding::single(
4434+
self,
4435+
Signature::new(Parameters::gradual_form(), self.to_instance(db)),
4436+
)
4437+
.into()
44554438
}
44564439

44574440
Type::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() {
@@ -4636,7 +4619,7 @@ impl<'db> Type<'db> {
46364619
}
46374620

46384621
if let Type::GenericAlias(alias) = self {
4639-
if alias.origin(db).is_known(db, KnownClass::Tuple) {
4622+
if alias.origin(db).is_tuple(db) {
46404623
return Ok(todo_type!("*tuple[] annotations"));
46414624
}
46424625
}

crates/ty_python_semantic/src/types/class.rs

Lines changed: 108 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ impl<'db> ClassType<'db> {
575575
pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> {
576576
let (class_literal, specialization) = self.class_literal(db);
577577

578-
let synthesize_tuple_method = |return_type| {
578+
let synthesize_simple_tuple_method = |return_type| {
579579
let parameters =
580580
Parameters::new([Parameter::positional_only(Some(Name::new_static("self")))
581581
.with_annotated_type(Type::instance(db, self))]);
@@ -587,22 +587,88 @@ impl<'db> ClassType<'db> {
587587
};
588588

589589
match name {
590-
"__len__" if class_literal.is_known(db, KnownClass::Tuple) => {
590+
"__len__" if class_literal.is_tuple(db) => {
591591
let return_type = specialization
592592
.and_then(|spec| spec.tuple(db).len().into_fixed_length())
593593
.and_then(|len| i64::try_from(len).ok())
594594
.map(Type::IntLiteral)
595595
.unwrap_or_else(|| KnownClass::Int.to_instance(db));
596596

597-
synthesize_tuple_method(return_type)
597+
synthesize_simple_tuple_method(return_type)
598598
}
599-
"__bool__" if class_literal.is_known(db, KnownClass::Tuple) => {
599+
600+
"__bool__" if class_literal.is_tuple(db) => {
600601
let return_type = specialization
601602
.map(|spec| spec.tuple(db).truthiness().into_type(db))
602603
.unwrap_or_else(|| KnownClass::Bool.to_instance(db));
603604

604-
synthesize_tuple_method(return_type)
605+
synthesize_simple_tuple_method(return_type)
605606
}
607+
608+
// ```py
609+
// class tuple:
610+
// @overload
611+
// def __new__(cls: type[tuple[()]], iterable: tuple[()] = ()) -> tuple[()]: ...
612+
// @overload
613+
// def __new__[T](cls: type[tuple[T, ...]], iterable: tuple[T, ...]) -> tuple[T, ...]: ...
614+
// ```
615+
"__new__" if class_literal.is_tuple(db) => {
616+
let mut iterable_parameter =
617+
Parameter::positional_only(Some(Name::new_static("iterable")));
618+
619+
match specialization {
620+
Some(spec) => {
621+
let tuple = spec.tuple(db);
622+
let tuple_len = tuple.len();
623+
624+
if tuple_len.minimum() == 0 && tuple_len.maximum().is_none() {
625+
// If the tuple has no length restrictions,
626+
// any iterable is allowed as long as the iterable has the correct element type.
627+
let mut tuple_elements = tuple.all_elements();
628+
iterable_parameter = iterable_parameter.with_annotated_type(
629+
KnownClass::Iterable
630+
.to_specialized_instance(db, [*tuple_elements.next().unwrap()]),
631+
);
632+
assert_eq!(
633+
tuple_elements.next(),
634+
None,
635+
"Tuple specialization should not have more than one element when it has no length restriction"
636+
);
637+
} else {
638+
// But if the tuple is of a fixed length, or has a minimum length, we require a tuple rather
639+
// than an iterable, as a tuple is the only kind of iterable for which we can
640+
// specify a fixed length, or that the iterable must be at least a certain length.
641+
iterable_parameter =
642+
iterable_parameter.with_annotated_type(Type::instance(db, self));
643+
}
644+
}
645+
None => {
646+
// If the tuple isn't specialized at all, we allow any argument as long as it is iterable.
647+
iterable_parameter = iterable_parameter
648+
.with_annotated_type(KnownClass::Iterable.to_instance(db));
649+
}
650+
}
651+
652+
// We allow the `iterable` parameter to be omitted for:
653+
// - a zero-length tuple
654+
// - an unspecialized tuple
655+
// - a tuple with no minimum length
656+
if specialization.is_none_or(|spec| spec.tuple(db).len().minimum() == 0) {
657+
iterable_parameter = iterable_parameter.with_default_type(TupleType::empty(db));
658+
}
659+
660+
let parameters = Parameters::new([
661+
Parameter::positional_only(Some(Name::new_static("self")))
662+
.with_annotated_type(SubclassOfType::from(db, self)),
663+
iterable_parameter,
664+
]);
665+
666+
let synthesized_dunder =
667+
CallableType::function_like(db, Signature::new(parameters, None));
668+
669+
Place::bound(synthesized_dunder).into()
670+
}
671+
606672
_ => class_literal
607673
.own_class_member(db, specialization, name)
608674
.map_type(|ty| ty.apply_optional_specialization(db, specialization)),
@@ -659,38 +725,41 @@ impl<'db> ClassType<'db> {
659725
)
660726
.place;
661727

662-
let dunder_new_function =
663-
if let Place::Type(Type::FunctionLiteral(dunder_new_function), _) =
664-
dunder_new_function_symbol
665-
{
666-
// Step 3: If the return type of the `__new__` evaluates to a type that is not a subclass of this class,
667-
// then we should ignore the `__init__` and just return the `__new__` method.
668-
let returns_non_subclass =
669-
dunder_new_function
670-
.signature(db)
671-
.overloads
672-
.iter()
673-
.any(|signature| {
674-
signature.return_ty.is_some_and(|return_ty| {
675-
!return_ty.is_assignable_to(
676-
db,
677-
self_ty
678-
.to_instance(db)
679-
.expect("ClassType should be instantiable"),
680-
)
681-
})
682-
});
728+
let dunder_new_signature = dunder_new_function_symbol
729+
.ignore_possibly_unbound()
730+
.and_then(|ty| match ty {
731+
Type::FunctionLiteral(function) => Some(function.signature(db)),
732+
Type::Callable(callable) => Some(callable.signatures(db)),
733+
_ => None,
734+
});
683735

684-
let dunder_new_bound_method =
685-
dunder_new_function.into_bound_method_type(db, self_ty);
736+
let dunder_new_function = if let Some(dunder_new_signature) = dunder_new_signature {
737+
// Step 3: If the return type of the `__new__` evaluates to a type that is not a subclass of this class,
738+
// then we should ignore the `__init__` and just return the `__new__` method.
739+
let returns_non_subclass = dunder_new_signature.overloads.iter().any(|signature| {
740+
signature.return_ty.is_some_and(|return_ty| {
741+
!return_ty.is_assignable_to(
742+
db,
743+
self_ty
744+
.to_instance(db)
745+
.expect("ClassType should be instantiable"),
746+
)
747+
})
748+
});
686749

687-
if returns_non_subclass {
688-
return dunder_new_bound_method;
689-
}
690-
Some(dunder_new_bound_method)
691-
} else {
692-
None
693-
};
750+
let dunder_new_bound_method = Type::Callable(CallableType::new(
751+
db,
752+
dunder_new_signature.bind_self(),
753+
true,
754+
));
755+
756+
if returns_non_subclass {
757+
return dunder_new_bound_method;
758+
}
759+
Some(dunder_new_bound_method)
760+
} else {
761+
None
762+
};
694763

695764
let dunder_init_function_symbol = self_ty
696765
.member_lookup_with_policy(
@@ -864,6 +933,10 @@ impl<'db> ClassLiteral<'db> {
864933
self.known(db) == Some(known_class)
865934
}
866935

936+
pub(crate) fn is_tuple(self, db: &'db dyn Db) -> bool {
937+
self.is_known(db, KnownClass::Tuple)
938+
}
939+
867940
pub(crate) fn generic_context(self, db: &'db dyn Db) -> Option<GenericContext<'db>> {
868941
// Several typeshed definitions examine `sys.version_info`. To break cycles, we hard-code
869942
// the knowledge that this class is not generic.

crates/ty_python_semantic/src/types/display.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ impl TupleSpecialization {
511511
}
512512

513513
fn from_class(db: &dyn Db, class: ClassLiteral) -> Self {
514-
if class.is_known(db, KnownClass::Tuple) {
514+
if class.is_tuple(db) {
515515
Self::Yes
516516
} else {
517517
Self::No

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5637,10 +5637,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
56375637
| KnownClass::TypeVar
56385638
| KnownClass::NamedTuple
56395639
| KnownClass::TypeAliasType
5640-
| KnownClass::Tuple
56415640
| KnownClass::Deprecated
56425641
)
56435642
)
5643+
5644+
// Constructor calls to `tuple` and subclasses of `tuple` are handled in `Type::Bindings`,
5645+
// but constructor calls to `tuple[int]`, `tuple[int, ...]`, `tuple[int, *tuple[str, ...]]` (etc.)
5646+
// are handled by the default constructor-call logic (we synthesize a `__new__` method for them
5647+
// in `ClassType::own_class_member()`).
5648+
&& (callable_type.is_generic_alias() || !class.is_known(self.db(), KnownClass::Tuple))
5649+
56445650
// temporary special-casing for all subclasses of `enum.Enum`
56455651
// until we support the functional syntax for creating enum classes
56465652
&& KnownClass::Enum
@@ -8003,7 +8009,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
80038009
// updating all of the subscript logic below to use custom callables for all of the _other_
80048010
// special cases, too.
80058011
if let Type::ClassLiteral(class) = value_ty {
8006-
if class.is_known(self.db(), KnownClass::Tuple) {
8012+
if class.is_tuple(self.db()) {
80078013
return self
80088014
.infer_tuple_type_expression(slice)
80098015
.to_meta_type(self.db());

0 commit comments

Comments
 (0)