From c2380fa0e2903d6db645601d69a8b11bf3b43b73 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 21 Jul 2025 13:50:46 +0100 Subject: [PATCH] [ty] Extend tuple `__len__` and `__bool__` special casing to also cover tuple subclasses (#19289) Co-authored-by: Brent Westbrook --- .../resources/mdtest/expression/boolean.md | 56 +++++++++++++++++++ .../resources/mdtest/expression/len.md | 45 +++++++++++++++ .../mdtest/type_properties/is_subtype_of.md | 29 ++++++++++ crates/ty_python_semantic/src/types.rs | 21 +++---- crates/ty_python_semantic/src/types/class.rs | 36 +++++++++++- crates/ty_python_semantic/src/types/tuple.rs | 23 ++++++++ 6 files changed, 194 insertions(+), 16 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/expression/boolean.md b/crates/ty_python_semantic/resources/mdtest/expression/boolean.md index 7a9f25f637..fb1d0a2602 100644 --- a/crates/ty_python_semantic/resources/mdtest/expression/boolean.md +++ b/crates/ty_python_semantic/resources/mdtest/expression/boolean.md @@ -72,7 +72,14 @@ reveal_type(my_bool(0)) # revealed: bool ## Truthy values +```toml +[environment] +python-version = "3.11" +``` + ```py +from typing import Literal + reveal_type(bool(1)) # revealed: Literal[True] reveal_type(bool((0,))) # revealed: Literal[True] reveal_type(bool("NON EMPTY")) # revealed: Literal[True] @@ -81,6 +88,42 @@ reveal_type(bool(True)) # revealed: Literal[True] def foo(): ... reveal_type(bool(foo)) # revealed: Literal[True] + +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] + +# 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] + +# Unknown length with an overridden `__bool__`: +class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]): + def __bool__(self) -> Literal[True]: + return True + +reveal_type(bool(VariadicTupleSubclassWithDunderBoolOverride((1,)))) # revealed: Literal[True] +reveal_type(VariadicTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True] + +# revealed: bound method VariadicTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True] +reveal_type(VariadicTupleSubclassWithDunderBoolOverride().__bool__) + +# Same again but for a subclass of a fixed-length tuple: +class EmptyTupleSubclassWithDunderBoolOverride(tuple[()]): + # TODO: we should reject this override as a Liskov violation: + def __bool__(self) -> Literal[True]: + return True + +reveal_type(bool(EmptyTupleSubclassWithDunderBoolOverride(()))) # revealed: Literal[True] +reveal_type(EmptyTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True] + +# revealed: bound method EmptyTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True] +reveal_type(EmptyTupleSubclassWithDunderBoolOverride().__bool__) ``` ## Falsy values @@ -92,6 +135,12 @@ reveal_type(bool(None)) # revealed: Literal[False] reveal_type(bool("")) # revealed: Literal[False] reveal_type(bool(False)) # revealed: Literal[False] reveal_type(bool()) # revealed: Literal[False] + +class EmptyTupleSubclass(tuple[()]): ... + +reveal_type(bool(EmptyTupleSubclass())) # revealed: Literal[False] +reveal_type(EmptyTupleSubclass.__bool__) # revealed: (self: tuple[()], /) -> Literal[False] +reveal_type(EmptyTupleSubclass().__bool__) # revealed: () -> Literal[False] ``` ## Ambiguous values @@ -100,6 +149,13 @@ reveal_type(bool()) # revealed: Literal[False] reveal_type(bool([])) # revealed: bool reveal_type(bool({})) # revealed: bool reveal_type(bool(set())) # revealed: bool + +class VariadicTupleSubclass(tuple[int, ...]): ... + +def f(x: tuple[int, ...], y: VariadicTupleSubclass): + reveal_type(bool(x)) # revealed: bool + reveal_type(x.__bool__) # revealed: () -> bool + reveal_type(y.__bool__) # revealed: () -> bool ``` ## `__bool__` returning `NoReturn` diff --git a/crates/ty_python_semantic/resources/mdtest/expression/len.md b/crates/ty_python_semantic/resources/mdtest/expression/len.md index 6dd80b10db..573005d522 100644 --- a/crates/ty_python_semantic/resources/mdtest/expression/len.md +++ b/crates/ty_python_semantic/resources/mdtest/expression/len.md @@ -65,6 +65,51 @@ reveal_type(len((*[], 1, 2))) # revealed: Literal[3] reveal_type(len((*[], *{}))) # revealed: Literal[2] ``` +Tuple subclasses: + +```py +class EmptyTupleSubclass(tuple[()]): ... +class Length1TupleSubclass(tuple[int]): ... +class Length2TupleSubclass(tuple[int, str]): ... +class UnknownLengthTupleSubclass(tuple[int, ...]): ... + +reveal_type(len(EmptyTupleSubclass())) # revealed: Literal[0] +reveal_type(len(Length1TupleSubclass((1,)))) # revealed: Literal[1] +reveal_type(len(Length2TupleSubclass((1, "foo")))) # revealed: Literal[2] +reveal_type(len(UnknownLengthTupleSubclass((1, 2, 3)))) # revealed: int + +reveal_type(tuple[int, int].__len__) # revealed: (self: tuple[int, int], /) -> Literal[2] +reveal_type(tuple[int, ...].__len__) # revealed: (self: tuple[int, ...], /) -> int + +def f(x: tuple[int, int], y: tuple[int, ...]): + reveal_type(x.__len__) # revealed: () -> Literal[2] + reveal_type(y.__len__) # revealed: () -> int + +reveal_type(EmptyTupleSubclass.__len__) # revealed: (self: tuple[()], /) -> Literal[0] +reveal_type(EmptyTupleSubclass().__len__) # revealed: () -> Literal[0] +reveal_type(UnknownLengthTupleSubclass.__len__) # revealed: (self: tuple[int, ...], /) -> int +reveal_type(UnknownLengthTupleSubclass().__len__) # revealed: () -> int +``` + +If `__len__` is overridden, we use the overridden return type: + +```py +from typing import Literal + +class UnknownLengthSubclassWithDunderLenOverridden(tuple[int, ...]): + def __len__(self) -> Literal[42]: + return 42 + +reveal_type(len(UnknownLengthSubclassWithDunderLenOverridden())) # revealed: Literal[42] + +class FixedLengthSubclassWithDunderLenOverridden(tuple[int]): + # TODO: we should complain about this as a Liskov violation (incompatible override) + def __len__(self) -> Literal[42]: + return 42 + +reveal_type(len(FixedLengthSubclassWithDunderLenOverridden((1,)))) # revealed: Literal[42] +``` + ### Lists, sets and dictionaries ```py diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md index fa9dfc1eee..3d46d11a47 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md @@ -551,6 +551,11 @@ static_assert(is_subtype_of(Never, AlwaysFalsy)) ### `AlwaysTruthy` and `AlwaysFalsy` +```toml +[environment] +python-version = "3.11" +``` + ```py from ty_extensions import AlwaysTruthy, AlwaysFalsy, Intersection, Not, is_subtype_of, static_assert from typing_extensions import Literal, LiteralString @@ -588,6 +593,30 @@ static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal[""]]], Not[AlwaysFalsy])) # error: [static-assert-error] static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], Not[AlwaysFalsy])) + +class Length2TupleSubclass(tuple[int, str]): ... + +static_assert(is_subtype_of(Length2TupleSubclass, AlwaysTruthy)) + +class EmptyTupleSubclass(tuple[()]): ... + +static_assert(is_subtype_of(EmptyTupleSubclass, AlwaysFalsy)) + +class TupleSubclassWithAtLeastLength2(tuple[int, *tuple[str, ...], bytes]): ... + +static_assert(is_subtype_of(TupleSubclassWithAtLeastLength2, AlwaysTruthy)) + +class UnknownLength(tuple[int, ...]): ... + +static_assert(not is_subtype_of(UnknownLength, AlwaysTruthy)) +static_assert(not is_subtype_of(UnknownLength, AlwaysFalsy)) + +class Invalid(tuple[int, str]): + # TODO: we should emit an error here (Liskov violation) + def __bool__(self) -> Literal[False]: + return False + +static_assert(is_subtype_of(Invalid, AlwaysFalsy)) ``` ### `TypeGuard` and `TypeIs` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index f12bb63ab2..badb8d7b40 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -56,7 +56,7 @@ use crate::types::infer::infer_unpack_types; use crate::types::mro::{Mro, MroError, MroIterator}; pub(crate) use crate::types::narrow::infer_narrowing_constraint; use crate::types::signatures::{Parameter, ParameterForm, Parameters, walk_signature}; -use crate::types::tuple::{TupleSpec, TupleType}; +use crate::types::tuple::TupleType; pub use crate::util::diagnostics::add_inferred_python_version_hint_to_diagnostic; use crate::{Db, FxOrderSet, Module, Program}; pub(crate) use class::{ClassLiteral, ClassType, GenericAlias, KnownClass}; @@ -3508,14 +3508,7 @@ impl<'db> Type<'db> { Type::BooleanLiteral(bool) => Truthiness::from(*bool), Type::StringLiteral(str) => Truthiness::from(!str.value(db).is_empty()), Type::BytesLiteral(bytes) => Truthiness::from(!bytes.value(db).is_empty()), - Type::Tuple(tuple) => match tuple.tuple(db).len().size_hint() { - // The tuple type is AlwaysFalse if it contains only the empty tuple - (_, Some(0)) => Truthiness::AlwaysFalse, - // The tuple type is AlwaysTrue if its inhabitants must always have length >=1 - (minimum, _) if minimum > 0 => Truthiness::AlwaysTrue, - // The tuple type is Ambiguous if its inhabitants could be of any length - _ => Truthiness::Ambiguous, - }, + Type::Tuple(tuple) => tuple.truthiness(db), }; Ok(truthiness) @@ -3542,10 +3535,12 @@ impl<'db> Type<'db> { let usize_len = match self { Type::BytesLiteral(bytes) => Some(bytes.python_len(db)), Type::StringLiteral(string) => Some(string.python_len(db)), - Type::Tuple(tuple) => match tuple.tuple(db) { - TupleSpec::Fixed(tuple) => Some(tuple.len()), - TupleSpec::Variable(_) => None, - }, + + // N.B. This is strictly-speaking redundant, since the `__len__` method on tuples + // is special-cased in `ClassType::own_class_member`. However, it's probably more + // efficient to short-circuit here and check against the tuple spec directly, + // rather than going through the `__len__` method. + Type::Tuple(tuple) => tuple.tuple(db).len().into_fixed_length(), _ => None, }; diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 15aa9bc7c5..6472e14579 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -574,9 +574,39 @@ impl<'db> ClassType<'db> { /// traverse through the MRO until it finds the member. pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { let (class_literal, specialization) = self.class_literal(db); - class_literal - .own_class_member(db, specialization, name) - .map_type(|ty| ty.apply_optional_specialization(db, specialization)) + + let synthesize_tuple_method = |return_type| { + let parameters = + Parameters::new([Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(Type::instance(db, self))]); + + let synthesized_dunder_method = + CallableType::function_like(db, Signature::new(parameters, Some(return_type))); + + Place::bound(synthesized_dunder_method).into() + }; + + match name { + "__len__" if class_literal.is_known(db, KnownClass::Tuple) => { + 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) + } + "__bool__" if class_literal.is_known(db, KnownClass::Tuple) => { + 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) + } + _ => class_literal + .own_class_member(db, specialization, name) + .map_type(|ty| ty.apply_optional_specialization(db, specialization)), + } } /// Look up an instance attribute (available in `__dict__`) of the given name. diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index 58dcb0debb..fe1ceb5aed 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -22,6 +22,7 @@ use std::hash::Hash; use itertools::{Either, EitherOrBoth, Itertools}; +use crate::types::Truthiness; use crate::types::class::{ClassType, KnownClass}; use crate::types::{ Type, TypeMapping, TypeRelation, TypeTransformer, TypeVarInstance, TypeVarVariance, @@ -76,6 +77,13 @@ impl TupleLength { None => "unlimited".to_string(), } } + + pub(crate) fn into_fixed_length(self) -> Option { + match self { + TupleLength::Fixed(len) => Some(len), + TupleLength::Variable(_, _) => None, + } + } } /// # Ordering @@ -240,6 +248,10 @@ impl<'db> TupleType<'db> { pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool { self.tuple(db).is_single_valued(db) } + + pub(crate) fn truthiness(self, db: &'db dyn Db) -> Truthiness { + self.tuple(db).truthiness() + } } /// A tuple spec describes the contents of a tuple type, which might be fixed- or variable-length. @@ -967,6 +979,17 @@ impl Tuple { } } + pub(crate) fn truthiness(&self) -> Truthiness { + match self.len().size_hint() { + // The tuple type is AlwaysFalse if it contains only the empty tuple + (_, Some(0)) => Truthiness::AlwaysFalse, + // The tuple type is AlwaysTrue if its inhabitants must always have length >=1 + (minimum, _) if minimum > 0 => Truthiness::AlwaysTrue, + // The tuple type is Ambiguous if its inhabitants could be of any length + _ => Truthiness::Ambiguous, + } + } + pub(crate) fn is_empty(&self) -> bool { match self { Tuple::Fixed(tuple) => tuple.is_empty(),