python / mypy

Optional static typing for Python
https://www.mypy-lang.org/
Other
18.2k stars 2.78k forks source link

Mypy inappropriately infers class type as _typeshed.DataclassInstance #14941

Open binishkaspar opened 1 year ago

binishkaspar commented 1 year ago

Bug Report In the following code snippet, mypy incorrectly infers the type of typ as _typeshed.DataclassInstance. This issue was not present in previous versions of mypy

To Reproduce

from dataclasses import dataclass, is_dataclass
from typing import TypeVar, Any, Type, cast

@dataclass
class A:
  a: int

T = TypeVar('T')

def parse(typ: Type[T], raw: dict[str, Any]) -> T:
  if not is_dataclass(typ):
    raise Exception('Unsupported type')

  parsed = typ(**raw)  # type: ignore[call-arg]
  return parsed

parse(A, {'a': 2})

Expected Behavior

There should be no type error

Actual Behavior

Incompatible return value type (got "DataclassInstance", expected "T") \[return-value\]

Your Environment

erictraut commented 1 year ago

Mypy is narrowing the type of typ to type[DataclassInstance] because the is_dataclass function which was recently changed to use a TypeGuard. This is why the behavior changed with recent versions of mypy. Here's the relevant overload from dataclasses.pyi:

def is_dataclass(obj: type) -> TypeGuard[type[DataclassInstance]]: ...
gschaffner commented 1 year ago

i think that narrowing from type[T] to type[DataclassInstance] is problematic becuase DataclassInstance is a Protocol. type[DataclassInstance] is not always a subtype of type[T][^1].

would this be solved if type narrowing used intersections (https://github.com/python/typing/issues/213)?

[^1]: depending on the bound on T. if T is unbound then type[DataclassInstance] is a subtype of type[T], but if we had a bound T = TypeVar("T", bound=A) and A wasn't a subtype of DataclassInstance, then type[DataclassInstance] would legitimately not be a subtype of type[T].

gschaffner commented 1 year ago

here's a small reproducer that isn't tied to the recent typeshed changes:

@runtime_checkable
class _HasFoo(Protocol):
    # some structural type that is third-party private and not available for users to use in annotations.
    @classmethod
    def foo(cls) -> None:
        ...

def class_has_foo(typ: type, /) -> TypeGuard[type[_HasFoo]]:
    # some TypeGuard for type[it] that is third-party public.
    return issubclass(typ, _HasFoo)

def construct(typ: type[T], /) -> T:
    if not class_has_foo(typ):
        raise TypeError()

    return typ()

(https://mypy-play.net/?mypy=1.1.1&python=3.11&gist=e1d8c7379c14222d2ec5f2e91eca4f62)

pyright and pyre error with this too.

erictraut commented 1 year ago

would this be solved if type narrowing used intersections

Yes, intersections would solve this.

pyright and pyre error with this too.

I just checked in a fix for pyright that addresses this issue. Pyright has the concept of conditional types, which is effectively an internal intersection between a TypeVar and another type.

binishkaspar commented 1 year ago

I think for now I will introduce a new function guardsafe_is_dataclass (may be need a better name) which returns bool without using TypeGuard

from dataclasses import dataclass, is_dataclass
from typing import TypeVar, Any, Type, cast

@dataclass
class A:
  a: int

T = TypeVar('T')

def guardsafe_is_dataclass(typ: Type[T]) -> bool:
    return is_dataclass(typ)

def parse(typ: Type[T], raw: dict[str, Any]) -> T:
  if not guardsafe_is_dataclass(typ):
    raise Exception('Unsupported type')

  parsed = typ(**raw)  # type: ignore[call-arg]
  return parsed

parse(A, {'a': 2})
dszady-rtb commented 9 months ago

Can we expect this to be fixed?