gbi1977
gbi1977

Reputation: 233

`mypy` doesn't recognize inherited dataclass members

I'm trying to design my code as follows - i.e., I'd like that each subclass which implements my functionlity will have as member a collection of fields, which can also inherit from a base dataclass.

from dataclasses import dataclass
from abc import ABC, abstractmethod

@dataclass
class BaseFields:
    pass


@dataclass
class MoreFields(baseFields):
    name: str = "john"


class A(ABC):
    def __init__(self) -> None:
        super().__init__()
        self.fields: BaseFields = BaseFields()
        
    @abstractmethod
    def say_hi(self) -> None:
        pass
    

class B(A):
    def __init__(self) -> None:
        super().__init__()
        self.fields = MoreFields()

    def say_hi(self) -> None:
        print(f"Hi {self.fields.name}!")
        

if __name__ == "__main__":
    b = B()
    b.say_hi()

When I run it, I get Hi john! as output, as expected.
But mypy doesn't seem to recognize it:

❯ mypy dataclass_inheritence.py
dataclass_inheritence.py:25: error: "baseFields" has no attribute "name"
Found 1 error in 1 file (checked 1 source file)

I looked and found this github issue, and it links to another one, but doesn't seem like it offers a solution.

I should also note that if I remove the @dataclass decorators and implement the Fields classes as plain ol' classes, with __init__ - I still get the same mypy error.

My motivation (as you may tell) is to reference composite members within the implemented methods of the functional subclasses. Those members are constants, as in the example, so I might use some form of Enum inheritance, but looking at this question it's not a popular design choice (will have to use some 3rd party module which I'm not keen on doing).

Has anyone encountered something similar? Do you have suggestions for a design that could achieve my goal?

Upvotes: 0

Views: 1264

Answers (2)

flakes
flakes

Reputation: 23644

You can use a generic base class to define the class. I would also have the fields attribute be passed to the base class constructor. There are some subtle tricks to get the signature on the init method working, but this should work.

Some imports you'll want:

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Generic, TypeVar, overload

Rename the classes with more pythonic names, and define a generic TypeVar to represent which fields we are using.

@dataclass
class BaseFields:
    pass


@dataclass
class MoreFields(BaseFields):
    name: str = "john"


Fields = TypeVar('Fields', bound=BaseFields)

For defining the base class, we want to allow the fields param to be anything satisfying the TypeVar. We also need to add some overloads to handle the case where a default is used or not.

class A(Generic[Fields], ABC):
    fields: Fields

    @overload
    def __init__(self: A[BaseFields]) -> None:
        ...

    @overload
    def __init__(self: A[Fields], fields: Fields) -> None:
        ...

    def __init__(self, fields=None):
        self.fields = fields or BaseFields()

    @abstractmethod
    def say_hi(self) -> None:
        pass

Now we can run our test:

class B(A[MoreFields]):
    def __init__(self) -> None:
        super().__init__(MoreFields())

    def say_hi(self) -> None:
        print(f"Hi {self.fields.name}!")


if __name__ == "__main__":
    b = B()
    b.say_hi()
$ mypy test.py
Success: no issues found in 1 source file

Upvotes: 1

Samwise
Samwise

Reputation: 71507

The type of self.fields is declared as baseFields in A.__init__, and is not narrowed implicitly by assigning a moreFields to it in B.__init__ -- after all, you might want to be able to re-assign it to another baseFields instance, and it is therefore never assumed to be anything more specific than baseFields.

If you explicitly annotate it as moreFields in B.__init__, the error goes away:

class B(A):
    def __init__(self) -> None:
        super().__init__()
        self.fields: moreFields = moreFields()

    def say_hi(self) -> None:
        print(f"Hi {self.fields.name}!")  # ok!

although this actually feels like a bug in mypy, because now you can do this, violating the LSP:

if __name__ == "__main__":
    b: A = B()
    b.fields = baseFields()  # no mypy error, because b is an A, right?
    b.say_hi()  # runtime AttributeError because b is actually a B!

If I want a subclass to be able to narrow the type of an attribute, I make it a property backed by private attributes:

class A(ABC):
    def __init__(self) -> None:
        super().__init__()
        self.__baseFields = baseFields()
        
    @property
    def fields(self) -> baseFields:
        return self.__baseFields

    @abstractmethod
    def say_hi(self) -> None:
        pass
    

class B(A):
    def __init__(self) -> None:
        super().__init__()
        self.__moreFields = moreFields()

    @property
    def fields(self) -> moreFields:
        return self.__moreFields

    def say_hi(self) -> None:
        print(f"Hi {self.fields.name}!")  # ok!

Upvotes: 1

Related Questions