ruff/crates/red_knot_python_semantic/resources/mdtest/narrow/truthiness.md
InSync f764f59971
[red-knot] Treat classes as instances of their respective metaclasses in boolean tests (#15105)
## Summary

Follow-up to #15089.

## Test Plan

Markdown tests.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2024-12-23 01:30:51 +00:00

8.7 KiB

Narrowing For Truthiness Checks (if x or if not x)

Value Literals

def foo() -> Literal[0, -1, True, False, "", "foo", b"", b"bar", None] | tuple[()]:
    return 0

x = foo()

if x:
    reveal_type(x)  # revealed: Literal[-1] | Literal[True] | Literal["foo"] | Literal[b"bar"]
else:
    reveal_type(x)  # revealed: Literal[0] | Literal[False] | Literal[""] | Literal[b""] | None | tuple[()]

if not x:
    reveal_type(x)  # revealed: Literal[0] | Literal[False] | Literal[""] | Literal[b""] | None | tuple[()]
else:
    reveal_type(x)  # revealed: Literal[-1] | Literal[True] | Literal["foo"] | Literal[b"bar"]

if x and not x:
    reveal_type(x)  # revealed: Never
else:
    reveal_type(x)  # revealed: Literal[-1, 0] | bool | Literal["", "foo"] | Literal[b"", b"bar"] | None | tuple[()]

if not (x and not x):
    reveal_type(x)  # revealed: Literal[-1, 0] | bool | Literal["", "foo"] | Literal[b"", b"bar"] | None | tuple[()]
else:
    reveal_type(x)  # revealed: Never

if x or not x:
    reveal_type(x)  # revealed: Literal[-1, 0] | bool | Literal["foo", ""] | Literal[b"bar", b""] | None | tuple[()]
else:
    reveal_type(x)  # revealed: Never

if not (x or not x):
    reveal_type(x)  # revealed: Never
else:
    reveal_type(x)  # revealed: Literal[-1, 0] | bool | Literal["foo", ""] | Literal[b"bar", b""] | None | tuple[()]

if (isinstance(x, int) or isinstance(x, str)) and x:
    reveal_type(x)  # revealed: Literal[-1] | Literal[True] | Literal["foo"]
else:
    reveal_type(x)  # revealed: Literal[b"", b"bar"] | None | tuple[()] | Literal[0] | Literal[False] | Literal[""]

Function Literals

Basically functions are always truthy.

def flag() -> bool:
    return True

def foo(hello: int) -> bytes:
    return b""

def bar(world: str, *args, **kwargs) -> float:
    return 0.0

x = foo if flag() else bar

if x:
    reveal_type(x)  # revealed: Literal[foo, bar]
else:
    reveal_type(x)  # revealed: Never

Mutable Truthiness

Truthiness of Instances

The boolean value of an instance is not always consistent. For example, __bool__ can be customized to return random values, or in the case of a list(), the result depends on the number of elements in the list. Therefore, these types should not be narrowed by if x or if not x.

class A: ...
class B: ...

def f(x: A | B):
    if x:
        reveal_type(x)  # revealed: A & ~AlwaysFalsy | B & ~AlwaysFalsy
    else:
        reveal_type(x)  # revealed: A & ~AlwaysTruthy | B & ~AlwaysTruthy

    if x and not x:
        reveal_type(x)  # revealed: A & ~AlwaysFalsy & ~AlwaysTruthy | B & ~AlwaysFalsy & ~AlwaysTruthy
    else:
        reveal_type(x)  # revealed: A & ~AlwaysTruthy | B & ~AlwaysTruthy | A & ~AlwaysFalsy | B & ~AlwaysFalsy

    if x or not x:
        reveal_type(x)  # revealed: A & ~AlwaysFalsy | B & ~AlwaysFalsy | A & ~AlwaysTruthy | B & ~AlwaysTruthy
    else:
        reveal_type(x)  # revealed: A & ~AlwaysTruthy & ~AlwaysFalsy | B & ~AlwaysTruthy & ~AlwaysFalsy

Truthiness of Types

Also, types may not be Truthy. This is because __bool__ can be customized via a metaclass. Although this is a very rare case, we may consider metaclass checks in the future to handle this more accurately.

def flag() -> bool:
    return True

x = int if flag() else str
reveal_type(x)  # revealed: Literal[int, str]

if x:
    reveal_type(x)  # revealed: Literal[int] & ~AlwaysFalsy | Literal[str] & ~AlwaysFalsy
else:
    reveal_type(x)  # revealed: Literal[int] & ~AlwaysTruthy | Literal[str] & ~AlwaysTruthy

Determined Truthiness

Some custom classes can have a boolean value that is consistently determined as either True or False, regardless of the instance's state. This is achieved by defining a __bool__ method that always returns a fixed value.

These types can always be fully narrowed in boolean contexts, as shown below:

class T:
    def __bool__(self) -> Literal[True]:
        return True

class F:
    def __bool__(self) -> Literal[False]:
        return False

t = T()

if t:
    reveal_type(t)  # revealed: T
else:
    reveal_type(t)  # revealed: Never

f = F()

if f:
    reveal_type(f)  # revealed: Never
else:
    reveal_type(f)  # revealed: F

Narrowing Complex Intersection and Union

class A: ...
class B: ...

def flag() -> bool:
    return True

def instance() -> A | B:
    return A()

def literals() -> Literal[0, 42, "", "hello"]:
    return 42

x = instance()
y = literals()

if isinstance(x, str) and not isinstance(x, B):
    reveal_type(x)  # revealed: A & str & ~B
    reveal_type(y)  # revealed: Literal[0, 42] | Literal["", "hello"]

    z = x if flag() else y

    reveal_type(z)  # revealed: A & str & ~B | Literal[0, 42] | Literal["", "hello"]

    if z:
        reveal_type(z)  # revealed: A & str & ~B & ~AlwaysFalsy | Literal[42] | Literal["hello"]
    else:
        reveal_type(z)  # revealed: A & str & ~B & ~AlwaysTruthy | Literal[0] | Literal[""]

Narrowing Multiple Variables

def f(x: Literal[0, 1], y: Literal["", "hello"]):
    if x and y and not x and not y:
        reveal_type(x)  # revealed: Never
        reveal_type(y)  # revealed: Never
    else:
        # ~(x or not x) and ~(y or not y)
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Literal["", "hello"]

    if (x or not x) and (y and not y):
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Never
    else:
        # ~(x or not x) or ~(y and not y)
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Literal["", "hello"]

ControlFlow Merging

After merging control flows, when we take the union of all constraints applied in each branch, we should return to the original state.

class A: ...

x = A()

if x and not x:
    y = x
    reveal_type(y)  # revealed: A & ~AlwaysFalsy & ~AlwaysTruthy
else:
    y = x
    reveal_type(y)  # revealed: A & ~AlwaysTruthy | A & ~AlwaysFalsy

# TODO: It should be A. We should improve UnionBuilder or IntersectionBuilder. (issue #15023)
reveal_type(y)  # revealed: A & ~AlwaysTruthy | A & ~AlwaysFalsy

Truthiness of classes

class MetaAmbiguous(type):
    def __bool__(self) -> bool: ...

class MetaFalsy(type):
    def __bool__(self) -> Literal[False]: ...

class MetaTruthy(type):
    def __bool__(self) -> Literal[True]: ...

class MetaDeferred(type):
    def __bool__(self) -> MetaAmbiguous: ...

class AmbiguousClass(metaclass=MetaAmbiguous): ...
class FalsyClass(metaclass=MetaFalsy): ...
class TruthyClass(metaclass=MetaTruthy): ...
class DeferredClass(metaclass=MetaDeferred): ...

def _(
    a: type[AmbiguousClass],
    t: type[TruthyClass],
    f: type[FalsyClass],
    d: type[DeferredClass],
    ta: type[TruthyClass | AmbiguousClass],
    af: type[AmbiguousClass] | type[FalsyClass],
    flag: bool,
):
    reveal_type(ta)  # revealed: type[TruthyClass] | type[AmbiguousClass]
    if ta:
        reveal_type(ta)  # revealed: type[TruthyClass] | type[AmbiguousClass] & ~AlwaysFalsy

    reveal_type(af)  # revealed: type[AmbiguousClass] | type[FalsyClass]
    if af:
        reveal_type(af)  # revealed: type[AmbiguousClass] & ~AlwaysFalsy

    # TODO: Emit a diagnostic (`d` is not valid in boolean context)
    if d:
        # TODO: Should be `Unknown`
        reveal_type(d)  # revealed: type[DeferredClass] & ~AlwaysFalsy

    tf = TruthyClass if flag else FalsyClass
    reveal_type(tf)  # revealed: Literal[TruthyClass, FalsyClass]

    if tf:
        reveal_type(tf)  # revealed: Literal[TruthyClass]
    else:
        reveal_type(tf)  # revealed: Literal[FalsyClass]

Narrowing in chained boolean expressions

from typing import Literal

class A: ...

def _(x: Literal[0, 1]):
    reveal_type(x or A())  # revealed: Literal[1] | A
    reveal_type(x and A())  # revealed: Literal[0] | A

def _(x: str):
    reveal_type(x or A())  # revealed: str & ~AlwaysFalsy | A
    reveal_type(x and A())  # revealed: str & ~AlwaysTruthy | A

def _(x: bool | str):
    reveal_type(x or A())  # revealed: Literal[True] | str & ~AlwaysFalsy | A
    reveal_type(x and A())  # revealed: Literal[False] | str & ~AlwaysTruthy | A

class Falsy:
    def __bool__(self) -> Literal[False]: ...

class Truthy:
    def __bool__(self) -> Literal[True]: ...

def _(x: Falsy | Truthy):
    reveal_type(x or A())  # revealed: Truthy | A
    reveal_type(x and A())  # revealed: Falsy | A

class MetaFalsy(type):
    def __bool__(self) -> Literal[False]: ...

class MetaTruthy(type):
    def __bool__(self) -> Literal[True]: ...

class FalsyClass(metaclass=MetaFalsy): ...
class TruthyClass(metaclass=MetaTruthy): ...

def _(x: type[FalsyClass] | type[TruthyClass]):
    reveal_type(x or A())  # revealed: type[TruthyClass] | A
    reveal_type(x and A())  # revealed: type[FalsyClass] | A