diff --git a/crates/red_knot_python_semantic/resources/mdtest/unary/instance.md b/crates/red_knot_python_semantic/resources/mdtest/unary/invert_add_usub.md similarity index 95% rename from crates/red_knot_python_semantic/resources/mdtest/unary/instance.md rename to crates/red_knot_python_semantic/resources/mdtest/unary/invert_add_usub.md index 85f80c3b8c..98d9c9b1c0 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/unary/instance.md +++ b/crates/red_knot_python_semantic/resources/mdtest/unary/invert_add_usub.md @@ -1,4 +1,6 @@ -# Unary Operations +# Invert, UAdd, USub + +## Instance ```py from typing import Literal diff --git a/crates/red_knot_python_semantic/resources/mdtest/unary/not.md b/crates/red_knot_python_semantic/resources/mdtest/unary/not.md index f43b486c24..a193437328 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/unary/not.md +++ b/crates/red_knot_python_semantic/resources/mdtest/unary/not.md @@ -113,3 +113,101 @@ reveal_type(not ()) # revealed: Literal[True] reveal_type(not ("hello",)) # revealed: Literal[False] reveal_type(not (1, "hello")) # revealed: Literal[False] ``` + +## Instance + +Not operator is inferred based on +. An instance is True or False +if the `__bool__` method says so. + +At runtime, the `__len__` method is a fallback for `__bool__`, but we can't make use of that. If we +have a class that defines `__len__` but not `__bool__`, it is possible that any subclass could add a +`__bool__` method that would invalidate whatever conclusion we drew from `__len__`. So instances of +classes without a `__bool__` method, with or without `__len__`, must be inferred as unknown +truthiness. + +```py +class AlwaysTrue: + def __bool__(self) -> Literal[True]: + return True + +# revealed: Literal[False] +reveal_type(not AlwaysTrue()) + +class AlwaysFalse: + def __bool__(self) -> Literal[False]: + return False + +# revealed: Literal[True] +reveal_type(not AlwaysFalse()) + +# We don't get into a cycle if someone sets their `__bool__` method to the `bool` builtin: +class BoolIsBool: + __bool__ = bool + +# revealed: bool +reveal_type(not BoolIsBool()) + +# At runtime, no `__bool__` and no `__len__` means truthy, but we can't rely on that, because +# a subclass could add a `__bool__` method. +class NoBoolMethod: ... + +# revealed: bool +reveal_type(not NoBoolMethod()) + +# And we can't rely on `__len__` for the same reason: a subclass could add `__bool__`. +class LenZero: + def __len__(self) -> Literal[0]: + return 0 + +# revealed: bool +reveal_type(not LenZero()) + +class LenNonZero: + def __len__(self) -> Literal[1]: + return 1 + +# revealed: bool +reveal_type(not LenNonZero()) + +class WithBothLenAndBool1: + def __bool__(self) -> Literal[False]: + return False + + def __len__(self) -> Literal[2]: + return 2 + +# revealed: Literal[True] +reveal_type(not WithBothLenAndBool1()) + +class WithBothLenAndBool2: + def __bool__(self) -> Literal[True]: + return True + + def __len__(self) -> Literal[0]: + return 0 + +# revealed: Literal[False] +reveal_type(not WithBothLenAndBool2()) + +# TODO: raise diagnostic when __bool__ method is not valid: [unsupported-operator] "Method __bool__ for type `MethodBoolInvalid` should return `bool`, returned type `int`" +# https://docs.python.org/3/reference/datamodel.html#object.__bool__ +class MethodBoolInvalid: + def __bool__(self) -> int: + return 0 + +# revealed: bool +reveal_type(not MethodBoolInvalid()) + +# Don't trust a possibly-unbound `__bool__` method: +def get_flag() -> bool: + return True + +class PossiblyUnboundBool: + if get_flag(): + def __bool__(self) -> Literal[False]: + return False + +# revealed: bool +reveal_type(not PossiblyUnboundBool()) +``` diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index b61f848daa..2b23469408 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -1197,14 +1197,40 @@ impl<'db> Type<'db> { // TODO: see above Truthiness::Ambiguous } - Type::Instance(InstanceType { class }) => { - // TODO: lookup `__bool__` and `__len__` methods on the instance's class - // More info in https://docs.python.org/3/library/stdtypes.html#truth-value-testing - // For now, we only special-case some builtin classes + instance_ty @ Type::Instance(InstanceType { class }) => { if class.is_known(db, KnownClass::NoneType) { Truthiness::AlwaysFalse } else { - Truthiness::Ambiguous + // 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. We don't use + // `Type::call_dunder` here because of the need to check for `__bool__ = bool`. + + // Don't trust a maybe-unbound `__bool__` method. + let Symbol::Type(bool_method, Boundness::Bound) = + instance_ty.to_meta_type(db).member(db, "__bool__") + else { + return Truthiness::Ambiguous; + }; + + // Check if the class has `__bool__ = bool` and avoid infinite recursion, since + // `Type::call` on `bool` will call `Type::bool` on the argument. + if bool_method + .into_class_literal() + .is_some_and(|ClassLiteralType { class }| { + class.is_known(db, KnownClass::Bool) + }) + { + return Truthiness::Ambiguous; + } + + if let Some(Type::BooleanLiteral(bool_val)) = + bool_method.call(db, &[*instance_ty]).return_ty(db) + { + bool_val.into() + } else { + Truthiness::Ambiguous + } } } Type::KnownInstance(known_instance) => known_instance.bool(),