ruff/crates/red_knot_python_semantic/resources/mdtest/narrow/issubclass.md
David Peter 000948ad3b
[red-knot] Statically known branches (#15019)
## Summary

This changeset adds support for precise type-inference and
boundness-handling of definitions inside control-flow branches with
statically-known conditions, i.e. test-expressions whose truthiness we
can unambiguously infer as *always false* or *always true*.

This branch also includes:
- `sys.platform` support
- statically-known branches handling for Boolean expressions and while
  loops
- new `target-version` requirements in some Markdown tests which were
  now required due to the understanding of `sys.version_info` branches.

closes #12700 
closes #15034 

## Performance

### `tomllib`, -7%, needs to resolve one additional module (sys)

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./red_knot_main --project /home/shark/tomllib` | 22.2 ± 1.3 | 19.1 |
25.6 | 1.00 |
| `./red_knot_feature --project /home/shark/tomllib` | 23.8 ± 1.6 | 20.8
| 28.6 | 1.07 ± 0.09 |

### `black`, -6%

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./red_knot_main --project /home/shark/black` | 129.3 ± 5.1 | 119.0 |
137.8 | 1.00 |
| `./red_knot_feature --project /home/shark/black` | 136.5 ± 6.8 | 123.8
| 147.5 | 1.06 ± 0.07 |

## Test Plan

- New Markdown tests for the main feature in
  `statically-known-branches.md`
- New Markdown tests for `sys.platform`
- Adapted tests for `EllipsisType`, `Never`, etc
2024-12-21 11:33:10 +01:00

5.3 KiB

Narrowing for issubclass checks

Narrowing for issubclass(class, classinfo) expressions.

classinfo is a single type

Basic example

def _(flag: bool):
    t = int if flag else str

    if issubclass(t, bytes):
        reveal_type(t)  # revealed: Never

    if issubclass(t, object):
        reveal_type(t)  # revealed: Literal[int, str]

    if issubclass(t, int):
        reveal_type(t)  # revealed: Literal[int]
    else:
        reveal_type(t)  # revealed: Literal[str]

    if issubclass(t, str):
        reveal_type(t)  # revealed: Literal[str]
        if issubclass(t, int):
            reveal_type(t)  # revealed: Never

Proper narrowing in elif and else branches

def _(flag1: bool, flag2: bool):
    t = int if flag1 else str if flag2 else bytes

    if issubclass(t, int):
        reveal_type(t)  # revealed: Literal[int]
    else:
        reveal_type(t)  # revealed: Literal[str, bytes]

    if issubclass(t, int):
        reveal_type(t)  # revealed: Literal[int]
    elif issubclass(t, str):
        reveal_type(t)  # revealed: Literal[str]
    else:
        reveal_type(t)  # revealed: Literal[bytes]

Multiple derived classes

class Base: ...
class Derived1(Base): ...
class Derived2(Base): ...
class Unrelated: ...

def _(flag1: bool, flag2: bool, flag3: bool):
    t1 = Derived1 if flag1 else Derived2

    if issubclass(t1, Base):
        reveal_type(t1)  # revealed: Literal[Derived1, Derived2]

    if issubclass(t1, Derived1):
        reveal_type(t1)  # revealed: Literal[Derived1]
    else:
        reveal_type(t1)  # revealed: Literal[Derived2]

    t2 = Derived1 if flag2 else Base

    if issubclass(t2, Base):
        reveal_type(t2)  # revealed: Literal[Derived1, Base]

    t3 = Derived1 if flag3 else Unrelated

    if issubclass(t3, Base):
        reveal_type(t3)  # revealed: Literal[Derived1]
    else:
        reveal_type(t3)  # revealed: Literal[Unrelated]

Narrowing for non-literals

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

def _(t: type[object]):
    if issubclass(t, A):
        reveal_type(t)  # revealed: type[A]
        if issubclass(t, B):
            reveal_type(t)  # revealed: type[A] & type[B]
    else:
        reveal_type(t)  # revealed: type[object] & ~type[A]

Handling of None

types.NoneType is only available in Python 3.10 and later:

[environment]
python-version = "3.10"
from types import NoneType

def _(flag: bool):
    t = int if flag else NoneType

    if issubclass(t, NoneType):
        reveal_type(t)  # revealed: Literal[NoneType]

    if issubclass(t, type(None)):
        # TODO: this should be just `Literal[NoneType]`
        reveal_type(t)  # revealed: Literal[int, NoneType]

classinfo contains multiple types

(Nested) tuples of types

class Unrelated: ...

def _(flag1: bool, flag2: bool):
    t = int if flag1 else str if flag2 else bytes

    if issubclass(t, (int, (Unrelated, (bytes,)))):
        reveal_type(t)  # revealed: Literal[int, bytes]
    else:
        reveal_type(t)  # revealed: Literal[str]

Special cases

Emit a diagnostic if the first argument is of wrong type

Too wide

type[object] is a subtype of object, but not every object can be passed as the first argument to issubclass:

class A: ...

t = object()

# TODO: we should emit a diagnostic here
if issubclass(t, A):
    reveal_type(t)  # revealed: type[A]

Wrong

Literal[1] and type are entirely disjoint, so the inferred type of Literal[1] & type[int] is eagerly simplified to Never as a result of the type narrowing in the if issubclass(t, int) branch:

t = 1

# TODO: we should emit a diagnostic here
if issubclass(t, int):
    reveal_type(t)  # revealed: Never

Do not use custom issubclass for narrowing

def issubclass(c, ci):
    return True

def flag() -> bool: ...

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

Do support narrowing if issubclass is aliased

issubclass_alias = issubclass

def flag() -> bool: ...

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

Do support narrowing if issubclass is imported

from builtins import issubclass as imported_issubclass

def flag() -> bool: ...

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

Do not narrow if second argument is not a proper classinfo argument

from typing import Any

def flag() -> bool: ...

t = int if flag() else str

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

# TODO: this should cause us to emit a diagnostic during
# type checking
if issubclass(t, (bytes, "str")):
    reveal_type(t)  # revealed: Literal[int, str]

# TODO: this should cause us to emit a diagnostic during
# type checking
if issubclass(t, Any):
    reveal_type(t)  # revealed: Literal[int, str]

Do not narrow if there are keyword arguments

def flag() -> bool: ...

t = int if flag() else str

# TODO: this should cause us to emit a diagnostic
# (`issubclass` has no `foo` parameter)
if issubclass(t, int, foo="bar"):
    reveal_type(t)  # revealed: Literal[int, str]