Search code examples
pythonmypytyping

MyPy doesn't seem to pick up on a class's attributes when altering said attributes w/ a metaclass


So I've got something like this:

from dataclasses import dataclass, field
from typing import ClassVar

import itertools


class MetaComponent(type):
    iterator: ClassVar[itertools.count] = itertools.count(1)

    def __new__(cls, clsname, bases, attrs):
        attrs["type_of_part_index"] = next(MetaComponent.iterator)
        return super().__new__(cls, clsname, bases, attrs)


class Component(metaclass=MetaComponent):
    type_: str = field(init=False)
    singleton: bool = True

    def __post_init__(self):
        self.type_ = type(self).__name__.lower()


@dataclass
class Tire(Component):
    radius: float
    singleton: bool = False


@dataclass
class Body(Component):
    color: str


body = Body(color="red")
print(body.type_of_part_index)

MyPy gives me "Body" has no attribute "type_of_part_index". The code works as intended, so I'm wondering if there's a bug w/ MyPy or am I doing something incorrect w.r.t. adding an attribute like this. Is there a fix for this, or some more canonical way of writing the code such that MyPy sees the attribute correctly?


Solution

  • According to docs,

    Mypy supports the lookup of attributes in the metaclass

    but

    Mypy does not and cannot understand arbitrary metaclass code.

    The most common solution is to declare attribute type (either on metaclass or on derived class). Declaring on metaclass is better for DRY (no need to repeat this with each derived class), but worse for typechecking: trying to access MetaComponent.type_of_part_index will be an error not visible to mypy. Declaring in derived class is opposite.

    So you can do this:

    class MetaComponent(type):
        iterator: ClassVar[itertools.count] = itertools.count(1)
        type_of_part_index: int  # Will be cls attribute
        # or ClassVar[int] or whatever you need
    
        def __new__(cls, clsname, bases, attrs):
            attrs["type_of_part_index"] = next(MetaComponent.iterator)
            return super().__new__(cls, clsname, bases, attrs)
    

    or this:

    class Component(metaclass=MetaComponent):
        type_of_part_index: int  # Added by metaclass
    
        type_: str = field(init=False)
        singleton: bool = True
    
        def __post_init__(self):
            self.type_ = type(self).__name__.lower()
    

    In this situation I would prefer the latter (and comment), because metaclass isn't reused.