From e196c2ab37907be298a528ae03ae9ba2912129f9 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 24 Oct 2025 10:29:55 +0100 Subject: [PATCH] [ty] Consider `__len__` when determining the truthiness of an instance of a tuple class or a `@final` class (#21049) --- crates/ty_ide/src/goto_definition.rs | 229 ++++++++++++++++-- .../resources/mdtest/expression/boolean.md | 52 +++- .../resources/mdtest/narrow/boolean.md | 11 + crates/ty_python_semantic/src/types.rs | 70 +++++- crates/ty_python_semantic/src/types/class.rs | 25 +- .../src/types/ide_support.rs | 27 ++- 6 files changed, 349 insertions(+), 65 deletions(-) diff --git a/crates/ty_ide/src/goto_definition.rs b/crates/ty_ide/src/goto_definition.rs index 6cc6d0c23d..6dc9e203b6 100644 --- a/crates/ty_ide/src/goto_definition.rs +++ b/crates/ty_ide/src/goto_definition.rs @@ -1293,6 +1293,156 @@ class Test: .source( "main.py", " +class Test: + def __invert__(self) -> 'Test': ... + +a = Test() + +~a +", + ) + .build(); + + assert_snapshot!(test.goto_definition(), @r" + info[goto-definition]: Definition + --> main.py:3:9 + | + 2 | class Test: + 3 | def __invert__(self) -> 'Test': ... + | ^^^^^^^^^^ + 4 | + 5 | a = Test() + | + info: Source + --> main.py:7:1 + | + 5 | a = Test() + 6 | + 7 | ~a + | ^ + | + "); + } + + /// We jump to the `__invert__` definition here even though its signature is incorrect. + #[test] + fn goto_definition_unary_operator_with_bad_dunder_definition() { + let test = CursorTest::builder() + .source( + "main.py", + " +class Test: + def __invert__(self, extra_arg) -> 'Test': ... + +a = Test() + +~a +", + ) + .build(); + + assert_snapshot!(test.goto_definition(), @r" + info[goto-definition]: Definition + --> main.py:3:9 + | + 2 | class Test: + 3 | def __invert__(self, extra_arg) -> 'Test': ... + | ^^^^^^^^^^ + 4 | + 5 | a = Test() + | + info: Source + --> main.py:7:1 + | + 5 | a = Test() + 6 | + 7 | ~a + | ^ + | + "); + } + + #[test] + fn goto_definition_unary_after_operator() { + let test = CursorTest::builder() + .source( + "main.py", + " +class Test: + def __invert__(self) -> 'Test': ... + +a = Test() + +~ a +", + ) + .build(); + + assert_snapshot!(test.goto_definition(), @r" + info[goto-definition]: Definition + --> main.py:3:9 + | + 2 | class Test: + 3 | def __invert__(self) -> 'Test': ... + | ^^^^^^^^^^ + 4 | + 5 | a = Test() + | + info: Source + --> main.py:7:1 + | + 5 | a = Test() + 6 | + 7 | ~ a + | ^ + | + "); + } + + #[test] + fn goto_definition_unary_between_operator_and_operand() { + let test = CursorTest::builder() + .source( + "main.py", + " +class Test: + def __invert__(self) -> 'Test': ... + +a = Test() + +-a +", + ) + .build(); + + assert_snapshot!(test.goto_definition(), @r" + info[goto-definition]: Definition + --> main.py:5:1 + | + 3 | def __invert__(self) -> 'Test': ... + 4 | + 5 | a = Test() + | ^ + 6 | + 7 | -a + | + info: Source + --> main.py:7:2 + | + 5 | a = Test() + 6 | + 7 | -a + | ^ + | + "); + } + + #[test] + fn goto_definition_unary_not_with_dunder_bool() { + let test = CursorTest::builder() + .source( + "main.py", + " class Test: def __bool__(self) -> bool: ... @@ -1325,17 +1475,17 @@ a = Test() } #[test] - fn goto_definition_unary_after_operator() { + fn goto_definition_unary_not_with_dunder_len() { let test = CursorTest::builder() .source( "main.py", " class Test: - def __bool__(self) -> bool: ... + def __len__(self) -> 42: ... a = Test() -not a +not a ", ) .build(); @@ -1345,8 +1495,8 @@ not a --> main.py:3:9 | 2 | class Test: - 3 | def __bool__(self) -> bool: ... - | ^^^^^^^^ + 3 | def __len__(self) -> 42: ... + | ^^^^^^^ 4 | 5 | a = Test() | @@ -1361,40 +1511,83 @@ not a "); } + /// If `__bool__` is defined incorrectly, `not` does not fallback to `__len__`. + /// Instead, we jump to the `__bool__` definition as usual. + /// The fallback only occurs if `__bool__` is not defined at all. #[test] - fn goto_definition_unary_between_operator_and_operand() { + fn goto_definition_unary_not_with_bad_dunder_bool_and_dunder_len() { let test = CursorTest::builder() .source( "main.py", " class Test: - def __bool__(self) -> bool: ... + def __bool__(self, extra_arg) -> bool: ... + def __len__(self) -> 42: ... a = Test() --a +not a ", ) .build(); assert_snapshot!(test.goto_definition(), @r" info[goto-definition]: Definition - --> main.py:5:1 + --> main.py:3:9 | - 3 | def __bool__(self) -> bool: ... - 4 | - 5 | a = Test() - | ^ - 6 | - 7 | -a + 2 | class Test: + 3 | def __bool__(self, extra_arg) -> bool: ... + | ^^^^^^^^ + 4 | def __len__(self) -> 42: ... | info: Source - --> main.py:7:2 + --> main.py:8:1 + | + 6 | a = Test() + 7 | + 8 | not a + | ^^^ + | + "); + } + + /// Same as for unary operators that only use a single dunder, + /// we still jump to `__len__` for `not` goto-definition even if + /// the `__len__` signature is incorrect (but only if there is no + /// `__bool__` definition). + #[test] + fn goto_definition_unary_not_with_no_dunder_bool_and_bad_dunder_len() { + let test = CursorTest::builder() + .source( + "main.py", + " +class Test: + def __len__(self, extra_arg) -> 42: ... + +a = Test() + +not a +", + ) + .build(); + + assert_snapshot!(test.goto_definition(), @r" + info[goto-definition]: Definition + --> main.py:3:9 + | + 2 | class Test: + 3 | def __len__(self, extra_arg) -> 42: ... + | ^^^^^^^ + 4 | + 5 | a = Test() + | + info: Source + --> main.py:7:1 | 5 | a = Test() 6 | - 7 | -a - | ^ + 7 | not a + | ^^^ | "); } diff --git a/crates/ty_python_semantic/resources/mdtest/expression/boolean.md b/crates/ty_python_semantic/resources/mdtest/expression/boolean.md index 9af250a0a5..ec78b20fb7 100644 --- a/crates/ty_python_semantic/resources/mdtest/expression/boolean.md +++ b/crates/ty_python_semantic/resources/mdtest/expression/boolean.md @@ -78,7 +78,7 @@ python-version = "3.11" ``` ```py -from typing import Literal +from typing import Literal, final reveal_type(bool(1)) # revealed: Literal[True] reveal_type(bool((0,))) # revealed: Literal[True] @@ -92,15 +92,11 @@ 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((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((1, b"foo")).__bool__) # revealed: () -> Literal[True] # Unknown length with an overridden `__bool__`: class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]): @@ -108,10 +104,6 @@ class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]): 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[()]): @@ -124,11 +116,28 @@ reveal_type(EmptyTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def # revealed: bound method EmptyTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True] reveal_type(EmptyTupleSubclassWithDunderBoolOverride().__bool__) + +@final +class FinalClassOverridingLenAndNotBool: + def __len__(self) -> Literal[42]: + return 42 + +reveal_type(bool(FinalClassOverridingLenAndNotBool())) # revealed: Literal[True] + +@final +class FinalClassWithNoLenOrBool: ... + +reveal_type(bool(FinalClassWithNoLenOrBool())) # revealed: Literal[True] + +def f(x: SingleElementTupleSubclass | FinalClassOverridingLenAndNotBool | FinalClassWithNoLenOrBool): + reveal_type(bool(x)) # revealed: Literal[True] ``` ## Falsy values ```py +from typing import final, Literal + reveal_type(bool(0)) # revealed: Literal[False] reveal_type(bool(())) # revealed: Literal[False] reveal_type(bool(None)) # revealed: Literal[False] @@ -139,13 +148,23 @@ 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] + +@final +class FinalClassOverridingLenAndNotBool: + def __len__(self) -> Literal[0]: + return 0 + +reveal_type(bool(FinalClassOverridingLenAndNotBool())) # revealed: Literal[False] + +def f(x: EmptyTupleSubclass | FinalClassOverridingLenAndNotBool): + reveal_type(bool(x)) # revealed: Literal[False] ``` ## Ambiguous values ```py +from typing import Literal + reveal_type(bool([])) # revealed: bool reveal_type(bool({})) # revealed: bool reveal_type(bool(set())) # revealed: bool @@ -154,8 +173,15 @@ 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 + +class NonFinalOverridingLenAndNotBool: + def __len__(self) -> Literal[42]: + return 42 + +# We cannot consider `__len__` for a non-`@final` type, +# because a subclass might override `__bool__`, +# and `__bool__` takes precedence over `__len__` +reveal_type(bool(NonFinalOverridingLenAndNotBool())) # revealed: bool ``` ## `__bool__` returning `NoReturn` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/boolean.md b/crates/ty_python_semantic/resources/mdtest/narrow/boolean.md index 17f4454ff2..dd86762ee5 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/boolean.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/boolean.md @@ -21,6 +21,8 @@ def _(flag: bool): ## Narrowing in `and` ```py +from typing import final + def _(flag: bool): class A: ... x: A | None = A() if flag else None @@ -28,6 +30,15 @@ def _(flag: bool): isinstance(x, A) and reveal_type(x) # revealed: A x is None and reveal_type(x) # revealed: None reveal_type(x) # revealed: A | None + +@final +class FinalClass: ... + +# We know that no subclass of `FinalClass` can exist, +# therefore no subtype of `FinalClass` can define `__bool__` +# or `__len__`, therefore `FinalClass` can safely be considered +# always-truthy, therefore this always resolves to `None` +reveal_type(FinalClass() and None) # revealed: None ``` ## Multiple `and` arms diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 6cc7f20739..0ccb90b0cb 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4463,18 +4463,15 @@ impl<'db> Type<'db> { visitor: &TryBoolVisitor<'db>, ) -> Result> { let type_to_truthiness = |ty| { - if let Type::BooleanLiteral(bool_val) = ty { - Truthiness::from(bool_val) - } else { - Truthiness::Ambiguous + match ty { + Type::BooleanLiteral(bool_val) => Truthiness::from(bool_val), + Type::IntLiteral(int_val) => Truthiness::from(int_val != 0), + // anything else is handled lower down + _ => Truthiness::Ambiguous, } }; - let try_dunder_bool = || { - // We only check the `__bool__` method for truth testing, even though at - // runtime there is a fallback to `__len__`, since `__bool__` takes precedence - // and a subclass could add a `__bool__` method. - + let try_dunders = || { match self.try_call_dunder( db, "__bool__", @@ -4509,18 +4506,67 @@ impl<'db> Type<'db> { Ok(Truthiness::Ambiguous) } - Err(CallDunderError::MethodNotAvailable) => Ok(Truthiness::Ambiguous), + Err(CallDunderError::MethodNotAvailable) => { + // We only consider `__len__` for tuples and `@final` types, + // since `__bool__` takes precedence + // and a subclass could add a `__bool__` method. + // + // TODO: with regards to tuple types, we intend to emit a diagnostic + // if a tuple subclass defines a `__bool__` method with a return type + // that is inconsistent with the tuple's length. Otherwise, the special + // handling for tuples here isn't sound. + if let Some(instance) = self.into_nominal_instance() { + if let Some(tuple_spec) = instance.tuple_spec(db) { + Ok(tuple_spec.truthiness()) + } else if instance.class(db).is_final(db) { + match self.try_call_dunder( + db, + "__len__", + CallArguments::none(), + TypeContext::default(), + ) { + Ok(outcome) => { + let return_type = outcome.return_type(db); + if return_type.is_assignable_to( + db, + KnownClass::SupportsIndex.to_instance(db), + ) { + Ok(type_to_truthiness(return_type)) + } else { + // TODO: should report a diagnostic similar to if return type of `__bool__` + // is not assignable to `bool` + Ok(Truthiness::Ambiguous) + } + } + // if a `@final` type does not define `__bool__` or `__len__`, it is always truthy + Err(CallDunderError::MethodNotAvailable) => { + Ok(Truthiness::AlwaysTrue) + } + // TODO: errors during a `__len__` call (if `__len__` exists) should be reported + // as diagnostics similar to errors during a `__bool__` call (when `__bool__` exists) + Err(_) => Ok(Truthiness::Ambiguous), + } + } else { + Ok(Truthiness::Ambiguous) + } + } else { + Ok(Truthiness::Ambiguous) + } + } + Err(CallDunderError::CallError(CallErrorKind::BindingError, bindings)) => { Err(BoolError::IncorrectArguments { truthiness: type_to_truthiness(bindings.return_type(db)), not_boolable_type: *self, }) } + Err(CallDunderError::CallError(CallErrorKind::NotCallable, _)) => { Err(BoolError::NotCallable { not_boolable_type: *self, }) } + Err(CallDunderError::CallError(CallErrorKind::PossiblyNotCallable, _)) => { Err(BoolError::Other { not_boolable_type: *self, @@ -4635,9 +4681,9 @@ impl<'db> Type<'db> { .known_class(db) .and_then(KnownClass::bool) .map(Ok) - .unwrap_or_else(try_dunder_bool)?, + .unwrap_or_else(try_dunders)?, - Type::ProtocolInstance(_) => try_dunder_bool()?, + Type::ProtocolInstance(_) => try_dunders()?, Type::Union(union) => try_union(*union)?, diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index ae577549e2..940679ed17 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -745,17 +745,6 @@ impl<'db> ClassType<'db> { }) }; - 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))]); - - let synthesized_dunder_method = - CallableType::function_like(db, Signature::new(parameters, Some(return_type))); - - Member::definitely_declared(synthesized_dunder_method) - }; - match name { "__len__" if class_literal.is_tuple(db) => { let return_type = specialization @@ -765,16 +754,14 @@ impl<'db> ClassType<'db> { .map(Type::IntLiteral) .unwrap_or_else(|| KnownClass::Int.to_instance(db)); - 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))]); - "__bool__" if class_literal.is_tuple(db) => { - let return_type = specialization - .and_then(|spec| spec.tuple(db)) - .map(|tuple| tuple.truthiness().into_type(db)) - .unwrap_or_else(|| KnownClass::Bool.to_instance(db)); + let synthesized_dunder_method = + CallableType::function_like(db, Signature::new(parameters, Some(return_type))); - synthesize_simple_tuple_method(return_type) + Member::definitely_declared(synthesized_dunder_method) } "__getitem__" if class_literal.is_tuple(db) => { diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 331d89a412..9dad158369 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -10,6 +10,7 @@ use crate::semantic_index::scope::ScopeId; use crate::semantic_index::{ attribute_scopes, global_scope, place_table, semantic_index, use_def_map, }; +use crate::types::CallDunderError; use crate::types::call::{CallArguments, MatchedArgument}; use crate::types::signatures::Signature; use crate::types::{ @@ -973,13 +974,33 @@ pub fn definitions_for_unary_op<'db>( ast::UnaryOp::Not => "__bool__", }; - let Ok(bindings) = operand_ty.try_call_dunder( + let bindings = match operand_ty.try_call_dunder( db, unary_dunder_method, CallArguments::none(), TypeContext::default(), - ) else { - return None; + ) { + Ok(bindings) => bindings, + Err(CallDunderError::MethodNotAvailable) if unary_op.op == ast::UnaryOp::Not => { + // The runtime falls back to `__len__` for `not` if `__bool__` is not defined. + match operand_ty.try_call_dunder( + db, + "__len__", + CallArguments::none(), + TypeContext::default(), + ) { + Ok(bindings) => bindings, + Err(CallDunderError::MethodNotAvailable) => return None, + Err( + CallDunderError::PossiblyUnbound(bindings) + | CallDunderError::CallError(_, bindings), + ) => *bindings, + } + } + Err(CallDunderError::MethodNotAvailable) => return None, + Err( + CallDunderError::PossiblyUnbound(bindings) | CallDunderError::CallError(_, bindings), + ) => *bindings, }; let callable_type = promote_literals_for_self(db, bindings.callable_type());