1

I try to call a classmethod on a generic class:

from typing import List, Union, TypeVar, Generic
from enum import IntEnum

class Gender(IntEnum):
    MALE = 1
    FEMALE = 2
    DIVERS = 3


T = TypeVar('T')

class EnumAggregate(Generic[T]):
    def __init__(self, value: Union[int, str, List[T]]) -> None:
        if value == '':
            raise ValueError(f'Parameter "value" cannot be empty!')

        if isinstance(value, list):
            self._value = ''.join([str(x.value) for x in value])
        else:
            self._value = str(value)

    def __contains__(self, item: T) -> bool:
        return item in self.to_list

    @property
    def to_list(self) -> List[T]:
        return [T(int(character)) for character in self._value]

    @property
    def value(self) -> str:
        return self._value

    @classmethod
    def all(cls) -> str:
        return ''.join([str(x.value) for x in T])

Genders = EnumAggregate[Gender]

But if I call

Genders.all()

I get the error TypeError: 'TypeVar' object is not iterable. So the TypeVar T isn't properly matched with the Enum Gender.

How can I fix this? The expected behavior would be

>>> Genders.all()
'123'

Any ideas? Or is this impossible?

DarkMath
  • 1,089
  • 2
  • 15
  • 29

2 Answers2

4

Python's type hinting system is there for a static type checker to validate your code and T is just a placeholder for the type system, like a slot in a template language. It can't be used as an indirect reference to a specific type.

You need to subclass your generic type if you want to produce a concrete implementation. And because Gender is a class and not an instance, you'd need to tell the type system how you plan to use a Type[T] somewhere, too.

Because you also want to be able to use T as an Enum() (calling it with EnumSubclass(int(character))), I'd also bind the typevar; that way the type checker will understand that all concrete forms of Type[T] are callable and will produce individual T instances, but also that those T instances will always have a .value attribute:

from typing import ClassVar, List, Union, Type, TypeVar, Generic
from enum import IntEnum

T = TypeVar('T', bound=IntEnum)  # only IntEnum subclasses

class EnumAggregate(Generic[T]):
    # Concrete implementations can reference `enum` *on the class itself*,
    # which will be an IntEnum subclass.
    enum: ClassVar[Type[T]]

    def __init__(self, value: Union[int, str, List[T]]) -> None:
        if not value:
            raise ValueError('Parameter "value" cannot be empty!')

        if isinstance(value, list):
            self._value = ''.join([str(x.value) for x in value])
        else:
            self._value = str(value)

    def __contains__(self, item: T) -> bool:
        return item in self.to_list

    @property
    def to_list(self) -> List[T]:
        # the concrete implementation needs to use self.enum here
        return [self.enum(int(character)) for character in self._value]

    @property
    def value(self) -> str:
        return self._value

    @classmethod
    def all(cls) -> str:
        # the concrete implementation needs to reference cls.enum here
        return ''.join([str(x.value) for x in cls.enum])

With the above generic class you can now create a concrete implementation, using your Gender IntEnum fitted into the T slot and as a class attribute:

class Gender(IntEnum):
    MALE = 1
    FEMALE = 2
    DIVERS = 3


class Genders(EnumAggregate[Gender]):
    enum = Gender

To be able to access the IntEnum subclass as a class attribute, we needed to use typing.ClassVar[]; otherwise the type checker has to assume the attribute is only available on instances.

And because the Gender IntEnum subclass is itself a class, we need to tell the type checker about that too, hence the use of typing.Type[].

Now the Gender concrete subclass works; the use of EnumAggregate[Gender] as a base class tells the type checker to substitute T for Gender everywhere, and because the implementation uses enum = Gender, the type checker sees that this is indeed correctly satisfied and the code passes all checks:

$ bin/mypy so65064844.py
Success: no issues found in 1 source file

and you can call Genders.all() to produce a string:

>>> Genders.all()
'123'

Note that I'd not store the enum values as strings, but rather as integers. There is little value in converting it back and forth here, and you are limiting yourself to enums with values between 0 and 9 (single digits).

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
  • You can no longer use type variables as a class variable type parameter: `error: ClassVar cannot contain type variables` (see the other answer here as well) – alkasm Jul 01 '22 at 05:50
1

The other answer does not work anymore, at least in Python 3.10. The type annotation ClassVar[Type[T]] results in a mypy error: ClassVar cannot contain type variables is thrown. This is because ClassVar should only be used in a Protocol and structural subtyping, which is not the best answer for the problem at hand.

The following modification of the other answer works:

class EnumAggregate(Generic[T]):
    enum: type[T]

[...]

class Genders(EnumAggregate[Gender]):
    enum = Gender

Abstract class variables

I would also recommend making enum abstract in some way, so instantiating EnumAggregate[Gender] instead of Genders will raise an error at the time of instantiation, not only at calls of to_list() or all().

This can be done in two ways: Either check the implementation in __init__:

class EnumAggregate(Generic[T]):
    enum: type[T]
    def __init__ 
    [...]
    if not hasattr(type(self), 'enum'):
        raise NotImplementedError("Implementations must define the class variable 'enum'")

Or use an abstract class property, see this discussion. This makes mypy happy in several situations, but not Pylance (see here):

class EnumAggregate(Generic[T]):
    @property
    @classmethod
    @abstractmethod
    def enum(cls) -> type[T]: ...

[...]

class Genders(EnumAggregate[Gender]):
    enum = Gender

However, there are unresolved problems with mypy and decorators, so right now there are spurious errors which might disappear in the future. For reference:

mypy issue 1

mypy issue 2

Discussion whether to deprecate chaining classmethod decorators

Jonathan
  • 111
  • 2