Files
ruff/crates/red_knot_python_semantic/resources/mdtest/narrow/truthiness.md
Shaygan Hooshyari 03ff883626 Display Union of Literals as a Literal (#14993)
## Summary

Resolves #14988

Display union of Literals like other type checkers do.

With this change we lose the sorting behavior. And we show the types as
they appeared. So it's deterministic and tests should not be flaky.
This is similar to how Mypy [reveals the
type](https://mypy-play.net/?mypy=latest&python=3.12&gist=51ad03b153bfca3b940d5084345e230f).

In some cases this makes it harder to know what is the order in revealed
type when writing tests but since it's consistent after the test fails
we know the order.

## Test Plan

I adjusted mdtests for this change. Basically merged the int and string
types of the unions.

In cases where we have types other than numbers and strings like this
[one](https://github.com/astral-sh/ruff/pull/14993/files#diff-ac50bce02b9f0ad4dc7d6b8e1046d60dad919ac52d0aeb253e5884f89ea42bfeL51).
We only group the strings and numbers as the issue suggsted.

```
def _(flag: bool, flag2: bool):
    if flag:
        f = 1
    elif flag2:
        f = "foo"
    else:
        def f() -> int:
            return 1
    # error: "Object of type `Literal[1, "foo", f]` is not callable (due to union elements Literal[1], Literal["foo"])"
    # revealed: Unknown | int
    reveal_type(f())
```

[pyright
example](https://pyright-play.net/?code=GYJw9gtgBALgngBwJYDsDmUkQWEMoAySMApiAIYA2AUNQCYnBQD6AFMJeWgFxQBGYMJQA0UDlwBMvAUICU3alCWYm4nouWamAXigBGDUpKUkqzmimHNYqLoBEwQXavGAziQXXlDVa1lQAWgA%2BTBQYTy9rEBIYAFcQFH0rAGIoMnAQXjsAeT4AKxIAY3wwJngEEigAAyJSCkoAbT1RBydRYABdKsxXKBQwfEKqTj5KStY6WMqYMChYlCQwROMSCBIw3tqyKiaO0S36htawOw7ZZ01U6IA3EioSOl4AVRQAa36Ad0SAH1CYKxud0ozHKJHYflk1CAA)

[mypy
example](https://mypy-play.net/?mypy=latest&python=3.12&gist=31c8bdaa5521860cfeca4b92841cb3b7)

---------

Co-authored-by: Carl Meyer <carl@oddbird.net>
2025-01-08 00:58:38 +00:00

8.4 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, True, "foo", b"bar"]
else:
    reveal_type(x)  # revealed: Literal[0, False, "", b""] | None | tuple[()]

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

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

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

if x or not x:
    reveal_type(x)  # revealed: Literal[-1, "foo", b"bar", 0, "", b""] | bool | 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, "foo", b"bar", 0, "", b""] | bool | None | tuple[()]

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

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, "", "hello"]

    z = x if flag() else y

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

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

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