Skip to content

Commit c2380fa

Browse files
authored
[ty] Extend tuple __len__ and __bool__ special casing to also cover tuple subclasses (astral-sh#19289)
Co-authored-by: Brent Westbrook
1 parent 4dec44a commit c2380fa

File tree

6 files changed

+194
-16
lines changed

6 files changed

+194
-16
lines changed

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,14 @@ reveal_type(my_bool(0)) # revealed: bool
7272

7373
## Truthy values
7474

75+
```toml
76+
[environment]
77+
python-version = "3.11"
78+
```
79+
7580
```py
81+
from typing import Literal
82+
7683
reveal_type(bool(1)) # revealed: Literal[True]
7784
reveal_type(bool((0,))) # revealed: Literal[True]
7885
reveal_type(bool("NON EMPTY")) # revealed: Literal[True]
@@ -81,6 +88,42 @@ reveal_type(bool(True)) # revealed: Literal[True]
8188
def foo(): ...
8289

8390
reveal_type(bool(foo)) # revealed: Literal[True]
91+
92+
class SingleElementTupleSubclass(tuple[int]): ...
93+
94+
reveal_type(bool(SingleElementTupleSubclass((0,)))) # revealed: Literal[True]
95+
reveal_type(SingleElementTupleSubclass.__bool__) # revealed: (self: tuple[int], /) -> Literal[True]
96+
reveal_type(SingleElementTupleSubclass().__bool__) # revealed: () -> Literal[True]
97+
98+
# Unknown length, but we know the length is guaranteed to be >=2
99+
class MixedTupleSubclass(tuple[int, *tuple[str, ...], bytes]): ...
100+
101+
reveal_type(bool(MixedTupleSubclass((1, b"foo")))) # revealed: Literal[True]
102+
reveal_type(MixedTupleSubclass.__bool__) # revealed: (self: tuple[int, *tuple[str, ...], bytes], /) -> Literal[True]
103+
reveal_type(MixedTupleSubclass().__bool__) # revealed: () -> Literal[True]
104+
105+
# Unknown length with an overridden `__bool__`:
106+
class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]):
107+
def __bool__(self) -> Literal[True]:
108+
return True
109+
110+
reveal_type(bool(VariadicTupleSubclassWithDunderBoolOverride((1,)))) # revealed: Literal[True]
111+
reveal_type(VariadicTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True]
112+
113+
# revealed: bound method VariadicTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True]
114+
reveal_type(VariadicTupleSubclassWithDunderBoolOverride().__bool__)
115+
116+
# Same again but for a subclass of a fixed-length tuple:
117+
class EmptyTupleSubclassWithDunderBoolOverride(tuple[()]):
118+
# TODO: we should reject this override as a Liskov violation:
119+
def __bool__(self) -> Literal[True]:
120+
return True
121+
122+
reveal_type(bool(EmptyTupleSubclassWithDunderBoolOverride(()))) # revealed: Literal[True]
123+
reveal_type(EmptyTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True]
124+
125+
# revealed: bound method EmptyTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True]
126+
reveal_type(EmptyTupleSubclassWithDunderBoolOverride().__bool__)
84127
```
85128

86129
## Falsy values
@@ -92,6 +135,12 @@ reveal_type(bool(None)) # revealed: Literal[False]
92135
reveal_type(bool("")) # revealed: Literal[False]
93136
reveal_type(bool(False)) # revealed: Literal[False]
94137
reveal_type(bool()) # revealed: Literal[False]
138+
139+
class EmptyTupleSubclass(tuple[()]): ...
140+
141+
reveal_type(bool(EmptyTupleSubclass())) # revealed: Literal[False]
142+
reveal_type(EmptyTupleSubclass.__bool__) # revealed: (self: tuple[()], /) -> Literal[False]
143+
reveal_type(EmptyTupleSubclass().__bool__) # revealed: () -> Literal[False]
95144
```
96145

97146
## Ambiguous values
@@ -100,6 +149,13 @@ reveal_type(bool()) # revealed: Literal[False]
100149
reveal_type(bool([])) # revealed: bool
101150
reveal_type(bool({})) # revealed: bool
102151
reveal_type(bool(set())) # revealed: bool
152+
153+
class VariadicTupleSubclass(tuple[int, ...]): ...
154+
155+
def f(x: tuple[int, ...], y: VariadicTupleSubclass):
156+
reveal_type(bool(x)) # revealed: bool
157+
reveal_type(x.__bool__) # revealed: () -> bool
158+
reveal_type(y.__bool__) # revealed: () -> bool
103159
```
104160

105161
## `__bool__` returning `NoReturn`

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,51 @@ reveal_type(len((*[], 1, 2))) # revealed: Literal[3]
6565
reveal_type(len((*[], *{}))) # revealed: Literal[2]
6666
```
6767

68+
Tuple subclasses:
69+
70+
```py
71+
class EmptyTupleSubclass(tuple[()]): ...
72+
class Length1TupleSubclass(tuple[int]): ...
73+
class Length2TupleSubclass(tuple[int, str]): ...
74+
class UnknownLengthTupleSubclass(tuple[int, ...]): ...
75+
76+
reveal_type(len(EmptyTupleSubclass())) # revealed: Literal[0]
77+
reveal_type(len(Length1TupleSubclass((1,)))) # revealed: Literal[1]
78+
reveal_type(len(Length2TupleSubclass((1, "foo")))) # revealed: Literal[2]
79+
reveal_type(len(UnknownLengthTupleSubclass((1, 2, 3)))) # revealed: int
80+
81+
reveal_type(tuple[int, int].__len__) # revealed: (self: tuple[int, int], /) -> Literal[2]
82+
reveal_type(tuple[int, ...].__len__) # revealed: (self: tuple[int, ...], /) -> int
83+
84+
def f(x: tuple[int, int], y: tuple[int, ...]):
85+
reveal_type(x.__len__) # revealed: () -> Literal[2]
86+
reveal_type(y.__len__) # revealed: () -> int
87+
88+
reveal_type(EmptyTupleSubclass.__len__) # revealed: (self: tuple[()], /) -> Literal[0]
89+
reveal_type(EmptyTupleSubclass().__len__) # revealed: () -> Literal[0]
90+
reveal_type(UnknownLengthTupleSubclass.__len__) # revealed: (self: tuple[int, ...], /) -> int
91+
reveal_type(UnknownLengthTupleSubclass().__len__) # revealed: () -> int
92+
```
93+
94+
If `__len__` is overridden, we use the overridden return type:
95+
96+
```py
97+
from typing import Literal
98+
99+
class UnknownLengthSubclassWithDunderLenOverridden(tuple[int, ...]):
100+
def __len__(self) -> Literal[42]:
101+
return 42
102+
103+
reveal_type(len(UnknownLengthSubclassWithDunderLenOverridden())) # revealed: Literal[42]
104+
105+
class FixedLengthSubclassWithDunderLenOverridden(tuple[int]):
106+
# TODO: we should complain about this as a Liskov violation (incompatible override)
107+
def __len__(self) -> Literal[42]:
108+
return 42
109+
110+
reveal_type(len(FixedLengthSubclassWithDunderLenOverridden((1,)))) # revealed: Literal[42]
111+
```
112+
68113
### Lists, sets and dictionaries
69114

70115
```py

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,11 @@ static_assert(is_subtype_of(Never, AlwaysFalsy))
551551

552552
### `AlwaysTruthy` and `AlwaysFalsy`
553553

554+
```toml
555+
[environment]
556+
python-version = "3.11"
557+
```
558+
554559
```py
555560
from ty_extensions import AlwaysTruthy, AlwaysFalsy, Intersection, Not, is_subtype_of, static_assert
556561
from typing_extensions import Literal, LiteralString
@@ -588,6 +593,30 @@ static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]],
588593
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal[""]]], Not[AlwaysFalsy]))
589594
# error: [static-assert-error]
590595
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], Not[AlwaysFalsy]))
596+
597+
class Length2TupleSubclass(tuple[int, str]): ...
598+
599+
static_assert(is_subtype_of(Length2TupleSubclass, AlwaysTruthy))
600+
601+
class EmptyTupleSubclass(tuple[()]): ...
602+
603+
static_assert(is_subtype_of(EmptyTupleSubclass, AlwaysFalsy))
604+
605+
class TupleSubclassWithAtLeastLength2(tuple[int, *tuple[str, ...], bytes]): ...
606+
607+
static_assert(is_subtype_of(TupleSubclassWithAtLeastLength2, AlwaysTruthy))
608+
609+
class UnknownLength(tuple[int, ...]): ...
610+
611+
static_assert(not is_subtype_of(UnknownLength, AlwaysTruthy))
612+
static_assert(not is_subtype_of(UnknownLength, AlwaysFalsy))
613+
614+
class Invalid(tuple[int, str]):
615+
# TODO: we should emit an error here (Liskov violation)
616+
def __bool__(self) -> Literal[False]:
617+
return False
618+
619+
static_assert(is_subtype_of(Invalid, AlwaysFalsy))
591620
```
592621

593622
### `TypeGuard` and `TypeIs`

crates/ty_python_semantic/src/types.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ use crate::types::infer::infer_unpack_types;
5656
use crate::types::mro::{Mro, MroError, MroIterator};
5757
pub(crate) use crate::types::narrow::infer_narrowing_constraint;
5858
use crate::types::signatures::{Parameter, ParameterForm, Parameters, walk_signature};
59-
use crate::types::tuple::{TupleSpec, TupleType};
59+
use crate::types::tuple::TupleType;
6060
pub use crate::util::diagnostics::add_inferred_python_version_hint_to_diagnostic;
6161
use crate::{Db, FxOrderSet, Module, Program};
6262
pub(crate) use class::{ClassLiteral, ClassType, GenericAlias, KnownClass};
@@ -3508,14 +3508,7 @@ impl<'db> Type<'db> {
35083508
Type::BooleanLiteral(bool) => Truthiness::from(*bool),
35093509
Type::StringLiteral(str) => Truthiness::from(!str.value(db).is_empty()),
35103510
Type::BytesLiteral(bytes) => Truthiness::from(!bytes.value(db).is_empty()),
3511-
Type::Tuple(tuple) => match tuple.tuple(db).len().size_hint() {
3512-
// The tuple type is AlwaysFalse if it contains only the empty tuple
3513-
(_, Some(0)) => Truthiness::AlwaysFalse,
3514-
// The tuple type is AlwaysTrue if its inhabitants must always have length >=1
3515-
(minimum, _) if minimum > 0 => Truthiness::AlwaysTrue,
3516-
// The tuple type is Ambiguous if its inhabitants could be of any length
3517-
_ => Truthiness::Ambiguous,
3518-
},
3511+
Type::Tuple(tuple) => tuple.truthiness(db),
35193512
};
35203513

35213514
Ok(truthiness)
@@ -3542,10 +3535,12 @@ impl<'db> Type<'db> {
35423535
let usize_len = match self {
35433536
Type::BytesLiteral(bytes) => Some(bytes.python_len(db)),
35443537
Type::StringLiteral(string) => Some(string.python_len(db)),
3545-
Type::Tuple(tuple) => match tuple.tuple(db) {
3546-
TupleSpec::Fixed(tuple) => Some(tuple.len()),
3547-
TupleSpec::Variable(_) => None,
3548-
},
3538+
3539+
// N.B. This is strictly-speaking redundant, since the `__len__` method on tuples
3540+
// is special-cased in `ClassType::own_class_member`. However, it's probably more
3541+
// efficient to short-circuit here and check against the tuple spec directly,
3542+
// rather than going through the `__len__` method.
3543+
Type::Tuple(tuple) => tuple.tuple(db).len().into_fixed_length(),
35493544

35503545
_ => None,
35513546
};

crates/ty_python_semantic/src/types/class.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -574,9 +574,39 @@ impl<'db> ClassType<'db> {
574574
/// traverse through the MRO until it finds the member.
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);
577-
class_literal
578-
.own_class_member(db, specialization, name)
579-
.map_type(|ty| ty.apply_optional_specialization(db, specialization))
577+
578+
let synthesize_tuple_method = |return_type| {
579+
let parameters =
580+
Parameters::new([Parameter::positional_only(Some(Name::new_static("self")))
581+
.with_annotated_type(Type::instance(db, self))]);
582+
583+
let synthesized_dunder_method =
584+
CallableType::function_like(db, Signature::new(parameters, Some(return_type)));
585+
586+
Place::bound(synthesized_dunder_method).into()
587+
};
588+
589+
match name {
590+
"__len__" if class_literal.is_known(db, KnownClass::Tuple) => {
591+
let return_type = specialization
592+
.and_then(|spec| spec.tuple(db).len().into_fixed_length())
593+
.and_then(|len| i64::try_from(len).ok())
594+
.map(Type::IntLiteral)
595+
.unwrap_or_else(|| KnownClass::Int.to_instance(db));
596+
597+
synthesize_tuple_method(return_type)
598+
}
599+
"__bool__" if class_literal.is_known(db, KnownClass::Tuple) => {
600+
let return_type = specialization
601+
.map(|spec| spec.tuple(db).truthiness().into_type(db))
602+
.unwrap_or_else(|| KnownClass::Bool.to_instance(db));
603+
604+
synthesize_tuple_method(return_type)
605+
}
606+
_ => class_literal
607+
.own_class_member(db, specialization, name)
608+
.map_type(|ty| ty.apply_optional_specialization(db, specialization)),
609+
}
580610
}
581611

582612
/// Look up an instance attribute (available in `__dict__`) of the given name.

crates/ty_python_semantic/src/types/tuple.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use std::hash::Hash;
2222

2323
use itertools::{Either, EitherOrBoth, Itertools};
2424

25+
use crate::types::Truthiness;
2526
use crate::types::class::{ClassType, KnownClass};
2627
use crate::types::{
2728
Type, TypeMapping, TypeRelation, TypeTransformer, TypeVarInstance, TypeVarVariance,
@@ -76,6 +77,13 @@ impl TupleLength {
7677
None => "unlimited".to_string(),
7778
}
7879
}
80+
81+
pub(crate) fn into_fixed_length(self) -> Option<usize> {
82+
match self {
83+
TupleLength::Fixed(len) => Some(len),
84+
TupleLength::Variable(_, _) => None,
85+
}
86+
}
7987
}
8088

8189
/// # Ordering
@@ -240,6 +248,10 @@ impl<'db> TupleType<'db> {
240248
pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool {
241249
self.tuple(db).is_single_valued(db)
242250
}
251+
252+
pub(crate) fn truthiness(self, db: &'db dyn Db) -> Truthiness {
253+
self.tuple(db).truthiness()
254+
}
243255
}
244256

245257
/// A tuple spec describes the contents of a tuple type, which might be fixed- or variable-length.
@@ -967,6 +979,17 @@ impl<T> Tuple<T> {
967979
}
968980
}
969981

982+
pub(crate) fn truthiness(&self) -> Truthiness {
983+
match self.len().size_hint() {
984+
// The tuple type is AlwaysFalse if it contains only the empty tuple
985+
(_, Some(0)) => Truthiness::AlwaysFalse,
986+
// The tuple type is AlwaysTrue if its inhabitants must always have length >=1
987+
(minimum, _) if minimum > 0 => Truthiness::AlwaysTrue,
988+
// The tuple type is Ambiguous if its inhabitants could be of any length
989+
_ => Truthiness::Ambiguous,
990+
}
991+
}
992+
970993
pub(crate) fn is_empty(&self) -> bool {
971994
match self {
972995
Tuple::Fixed(tuple) => tuple.is_empty(),

0 commit comments

Comments
 (0)