ruff/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md

8.4 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: <class 'int'> | <class 'str'>

    if issubclass(t, int):
        reveal_type(t)  # revealed: <class 'int'>
    else:
        reveal_type(t)  # revealed: <class 'str'>

    if issubclass(t, str):
        reveal_type(t)  # revealed: <class '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: <class 'int'>
    else:
        reveal_type(t)  # revealed: <class 'str'> | <class 'bytes'>

    if issubclass(t, int):
        reveal_type(t)  # revealed: <class 'int'>
    elif issubclass(t, str):
        reveal_type(t)  # revealed: <class 'str'>
    else:
        reveal_type(t)  # revealed: <class '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: <class 'Derived1'> | <class 'Derived2'>

    if issubclass(t1, Derived1):
        reveal_type(t1)  # revealed: <class 'Derived1'>
    else:
        reveal_type(t1)  # revealed: <class 'Derived2'>

    t2 = Derived1 if flag2 else Base

    if issubclass(t2, Base):
        reveal_type(t2)  # revealed: <class 'Derived1'> | <class 'Base'>

    t3 = Derived1 if flag3 else Unrelated

    if issubclass(t3, Base):
        reveal_type(t3)  # revealed: <class 'Derived1'>
    else:
        reveal_type(t3)  # revealed: <class '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 & ~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: <class 'NoneType'>

    if issubclass(t, type(None)):
        reveal_type(t)  # revealed: <class '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: <class 'int'> | <class 'bytes'>
    else:
        reveal_type(t)  # revealed: <class '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()

# error: [invalid-argument-type]
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

# error: [invalid-argument-type]
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:
    return True

t = int if flag() else str
if issubclass(t, int):
    reveal_type(t)  # revealed: <class 'int'> | <class 'str'>

Do support narrowing if issubclass is aliased

issubclass_alias = issubclass

def flag() -> bool:
    return True

t = int if flag() else str
if issubclass_alias(t, int):
    reveal_type(t)  # revealed: <class 'int'>

Do support narrowing if issubclass is imported

from builtins import issubclass as imported_issubclass

def flag() -> bool:
    return True

t = int if flag() else str
if imported_issubclass(t, int):
    reveal_type(t)  # revealed: <class 'int'>

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

from typing import Any

def flag() -> bool:
    return True

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: <class 'int'> | <class 'str'>

# TODO: this should cause us to emit a diagnostic during
# type checking
if issubclass(t, (bytes, "str")):
    reveal_type(t)  # revealed: <class 'int'> | <class 'str'>

# TODO: this should cause us to emit a diagnostic during
# type checking
if issubclass(t, Any):
    reveal_type(t)  # revealed: <class 'int'> | <class 'str'>

Do not narrow if there are keyword arguments

def flag() -> bool:
    return True

t = int if flag() else str

# error: [unknown-argument]
if issubclass(t, int, foo="bar"):
    reveal_type(t)  # revealed: <class 'int'> | <class 'str'>

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

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

Disjoint type[] types are narrowed to Never

Here, type[UsesMeta1] and type[UsesMeta2] are disjoint because a common subclass of UsesMeta1 and UsesMeta2 could only exist if a common subclass of their metaclasses could exist. This is known to be impossible due to the fact that Meta1 is marked as @final.

from typing import final

@final
class Meta1(type): ...

class Meta2(type): ...
class UsesMeta1(metaclass=Meta1): ...
class UsesMeta2(metaclass=Meta2): ...

def _(x: type[UsesMeta1], y: type[UsesMeta2]):
    if issubclass(x, y):
        reveal_type(x)  # revealed: Never
    else:
        reveal_type(x)  # revealed: type[UsesMeta1]

    if issubclass(y, x):
        reveal_type(y)  # revealed: Never
    else:
        reveal_type(y)  # revealed: type[UsesMeta2]

Narrowing if an object with an intersection/union/TypeVar type is used as the second argument

If an intersection with only positive members is used as the second argument, and all positive members of the intersection are valid arguments for the second argument to isinstance(), we intersect with each positive member of the intersection:

[environment]
python-version = "3.12"
from typing import Any, ClassVar
from ty_extensions import Intersection

class Foo: ...

class Bar:
    attribute: ClassVar[int]

class Baz:
    attribute: ClassVar[str]

def f(x: type[Foo], y: Intersection[type[Bar], type[Baz]], z: type[Any]):
    if issubclass(x, y):
        reveal_type(x)  # revealed: type[Foo] & type[Bar] & type[Baz]

    if issubclass(x, z):
        reveal_type(x)  # revealed: type[Foo] & Any

The same if a union type is used:

def g(x: type[Foo], y: type[Bar | Baz]):
    if issubclass(x, y):
        reveal_type(x)  # revealed: (type[Foo] & type[Bar]) | (type[Foo] & type[Baz])

And even if a TypeVar is used, providing it has valid upper bounds/constraints:

from typing import TypeVar

T = TypeVar("T", bound=type[Bar])

def h_old_syntax(x: type[Foo], y: T) -> T:
    if issubclass(x, y):
        reveal_type(x)  # revealed: type[Foo] & type[Bar]
        reveal_type(x.attribute)  # revealed: int

    return y

def h[U: type[Bar | Baz]](x: type[Foo], y: U) -> U:
    if issubclass(x, y):
        reveal_type(x)  # revealed: (type[Foo] & type[Bar]) | (type[Foo] & type[Baz])
        reveal_type(x.attribute)  # revealed: int | str

    return y

Or even a tuple of tuple of typevars that have intersection bounds...

from ty_extensions import Intersection

class Spam: ...
class Eggs: ...
class Ham: ...
class Mushrooms: ...

def i[T: Intersection[type[Bar], type[Baz | Spam]], U: (type[Eggs], type[Ham])](x: type[Foo], y: T, z: U) -> tuple[T, U]:
    if issubclass(x, (y, (z, Mushrooms))):
        # revealed: (type[Foo] & type[Bar] & type[Baz]) | (type[Foo] & type[Bar] & type[Spam]) | (type[Foo] & type[Eggs]) | (type[Foo] & type[Ham]) | (type[Foo] & type[Mushrooms])
        reveal_type(x)

    return (y, z)