Files
ruff/crates/red_knot_python_semantic/resources/mdtest/narrow/isinstance.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

4.2 KiB

Narrowing for isinstance checks

Narrowing for isinstance(object, classinfo) expressions.

classinfo is a single type

def _(flag: bool):
    x = 1 if flag else "a"

    if isinstance(x, int):
        reveal_type(x)  # revealed: Literal[1]

    if isinstance(x, str):
        reveal_type(x)  # revealed: Literal["a"]
        if isinstance(x, int):
            reveal_type(x)  # revealed: Never

    if isinstance(x, (int, object)):
        reveal_type(x)  # revealed: Literal[1, "a"]

classinfo is a tuple of types

Note: isinstance(x, (int, str)) should not be confused with isinstance(x, tuple[(int, str)]). The former is equivalent to isinstance(x, int | str):

def _(flag: bool, flag1: bool, flag2: bool):
    x = 1 if flag else "a"

    if isinstance(x, (int, str)):
        reveal_type(x)  # revealed: Literal[1, "a"]
    else:
        reveal_type(x)  # revealed: Never

    if isinstance(x, (int, bytes)):
        reveal_type(x)  # revealed: Literal[1]

    if isinstance(x, (bytes, str)):
        reveal_type(x)  # revealed: Literal["a"]

    # No narrowing should occur if a larger type is also
    # one of the possibilities:
    if isinstance(x, (int, object)):
        reveal_type(x)  # revealed: Literal[1, "a"]
    else:
        reveal_type(x)  # revealed: Never

    y = 1 if flag1 else "a" if flag2 else b"b"
    if isinstance(y, (int, str)):
        reveal_type(y)  # revealed: Literal[1, "a"]

    if isinstance(y, (int, bytes)):
        reveal_type(y)  # revealed: Literal[1, b"b"]

    if isinstance(y, (str, bytes)):
        reveal_type(y)  # revealed: Literal["a", b"b"]

classinfo is a nested tuple of types

def _(flag: bool):
    x = 1 if flag else "a"

    if isinstance(x, (bool, (bytes, int))):
        reveal_type(x)  # revealed: Literal[1]
    else:
        reveal_type(x)  # revealed: Literal["a"]

Class types

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

x = object()

if isinstance(x, A):
    reveal_type(x)  # revealed: A
    if isinstance(x, B):
        reveal_type(x)  # revealed: A & B
    else:
        reveal_type(x)  # revealed: A & ~B

if isinstance(x, (A, B)):
    reveal_type(x)  # revealed: A | B
elif isinstance(x, (A, C)):
    reveal_type(x)  # revealed: C & ~A & ~B
else:
    # TODO: Should be simplified to ~A & ~B & ~C
    reveal_type(x)  # revealed: object & ~A & ~B & ~C

No narrowing for instances of builtins.type

def _(flag: bool):
    t = type("t", (), {})

    # This isn't testing what we want it to test if we infer anything more precise here:
    reveal_type(t)  # revealed: type

    x = 1 if flag else "foo"

    if isinstance(x, t):
        reveal_type(x)  # revealed: Literal[1, "foo"]

Do not use custom isinstance for narrowing

def _(flag: bool):
    def isinstance(x, t):
        return True
    x = 1 if flag else "a"

    if isinstance(x, int):
        reveal_type(x)  # revealed: Literal[1, "a"]

Do support narrowing if isinstance is aliased

def _(flag: bool):
    isinstance_alias = isinstance

    x = 1 if flag else "a"

    if isinstance_alias(x, int):
        reveal_type(x)  # revealed: Literal[1]

Do support narrowing if isinstance is imported

from builtins import isinstance as imported_isinstance

def _(flag: bool):
    x = 1 if flag else "a"

    if imported_isinstance(x, int):
        reveal_type(x)  # revealed: Literal[1]

Do not narrow if second argument is not a type

def _(flag: bool):
    x = 1 if flag else "a"

    # TODO: this should cause us to emit a diagnostic during
    # type checking
    if isinstance(x, "a"):
        reveal_type(x)  # revealed: Literal[1, "a"]

    # TODO: this should cause us to emit a diagnostic during
    # type checking
    if isinstance(x, "int"):
        reveal_type(x)  # revealed: Literal[1, "a"]

Do not narrow if there are keyword arguments

def _(flag: bool):
    x = 1 if flag else "a"

    # error: [unknown-argument]
    if isinstance(x, int, foo="bar"):
        reveal_type(x)  # revealed: Literal[1, "a"]

type[] types are narrowed as well as class-literal types

def _(x: object, y: type[int]):
    if isinstance(x, y):
        reveal_type(x)  # revealed: int