python / mypy

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

Type-checking generic of enum.Meta with a custom metaclass fails with code that runs, and passes with code that doesn't run. #11970

Open mvaled opened 2 years ago

mvaled commented 2 years ago

Bug Report

Trying to type-check generic types of a bound enum.Enum (with a custom metaclass) only catches type errors when the actual code fails to run; but doesn't detect the issues when the code runs.

To Reproduce

The code in this gist properly detects the type error while calling Carrier(Foo, Bar.item1):

import enum
from dataclasses import dataclass
from typing import TypeVar, Generic, Type

M = TypeVar('M', bound="MyEnum")

class Meta(enum.EnumMeta):
    pass

class MyEnum(metaclass=Meta):
    ...

class Foo(enum.Enum, MyEnum):
    item1 = "1"
    item2 = "2"

class Bar(enum.Enum, MyEnum):
    item1 = "1"
    item2 = "2"

@dataclass
class Carrier(Generic[M]):
    meta: Type[M]
    instance: M

Carrier(Foo, Foo.item1)
Carrier(Foo, Bar.item1)  # should fail

However that code fails to run with

Traceback (most recent call last):
  File "/home/manu/src/tmp/enums.py", line 14, in <module>
    class Foo(enum.Enum, MyEnum):
  File "/usr/lib/python3.10/enum.py", line 173, in __prepare__
    member_type, first_enum = metacls._get_mixins_(cls, bases)
  File "/usr/lib/python3.10/enum.py", line 619, in _get_mixins_
    raise TypeError("new enumerations should be created as "
TypeError: new enumerations should be created as `EnumName([mixin_type, ...] [data_type,] enum_type)`

In order for the code to run we have to swap the positions of enum.Enum and MyEnum in both Foo and Bar (see the second gist); but then mypy fails to catch the type-error.

Your Environment

Previous discussion in gitter: https://gitter.im/python/typing?at=61dab43cf5a3947800f73b71

erictraut commented 2 years ago

I don't think the sample above (or the one in your gist) should generate a type error. There's a valid solution for TypeVar M for both Carrier calls at the bottom of the sample. In the first of the two calls, the M is Foo. In the second M is Foo | Bar or object.

If you want the second call to Carrier to produce an error, you would need to define M as a constrained TypeVar rather than a bound TypeVar.

M = TypeVar("M", "Foo", "Bar")
mvaled commented 2 years ago

That's probably correct. My first intuition was to look for something like Instance[T] (for some T bounded to a meta-class). This is the (pseudo) code I was asking about in the gitter thread:

import enum
from dataclasses import dataclass
from typing import TypeVar, Generic, Instance

M = TypeVar('M', bound='Meta')

class Meta(enum.EnumMeta):
    pass

class Foo(enum.Enum, metaclass=Meta):
    item1 = "1"
    item2 = "2"

class Bar(enum.Enum, metaclass=Meta):
    item1 = "1"
    item2 = "2"

@dataclass
class Carrier(Generic[M]):
    meta: M
    instance: Instance[M] 

Carrier(Foo, Foo.item1)
Carrier(Foo, Bar.item1)  # should fail

The difference here is that M is bound to the metaclass Meta but then I would like to refer to some instance of M.

erictraut commented 2 years ago

Yeah, there's no such thing as Instance in the type system today. (It's generally not needed because you can specify Type[M] to specify that you're talking about the instantiable class rather than an instance of that class.) And as you probably know, bound works only parent/child class relationships, not with metaclasses.

Does my suggestion to use a constrained TypeVar meet your needs?

mvaled commented 2 years ago

The actual code is a little bit more complicated; there are methods in the common MyEnum base with a signature similar to:

@classmethod
def convert(cls: Type[M], ...) -> M:
    ...

And have M being constrained to the subclasses doesn't work. I will have to look at it a little more closely.