Search code examples
pythontype-hinting

Type hint for class with __dict__ method


I have a class that can be instantiated from a dataclass.

class Asd:
    @classmethod
    def from_dataclass(cls, dataclass):
        return cls(**dataclass.__dict__)
    def __init__(self, **kwargs):
        ...

In principle I could pass to from_dataclass any class with a __dict__ method, and I would like the type hint to reflect this.

What I have done is to create my protocol.

from typing import Protocol

class HasDict(Protocol):
    def __dict__(self):
        ...

class Asd:
    @classmethod
    def from_dataclass(cls, dataclass: HasDict):
        return cls(**dataclass.__dict__)
    def __init__(self, **kwargs):
        ...

I wonder if I'm reinventing the wheel or if there is a better way to do this.


Solution

  • Since you added some context in your comments, I'll address the "bigger picture".

    Problem

    We have a class with a specific constructor and want to write a special method from_obj to instantiate it from any object that has attributes that match the non-optional parameters of our constructor.

    Solution

    Define a protocol that has attributes with the same names and types as the non-optional parameters of our constructor. Then explicitly pass those attributes as the corresponding arguments to the constructor inside from_obj.

    Example

    To make this a bit more interesting, let's include the possibility of positional-only as well as optional parameters in our constructor. Say we have a class Foo with the following __init__ signature:

    class Foo:
        def __init__(self, x: int, /, y: str, z: bool = True) -> None: ...
    

    Both x and y are required, x is positional only, and z has a default, thus is not required for instantiation.

    The protocol should reflect the bare minimum needed to instantiate our Foo, so it will look like this:

    class FooCompatible(Protocol):
        x: int
        y: str
    

    In our from_obj method we can now annotate the parameter with that protocol and do some additional steps to accommodate the different parameter categories. Here is a simple example for a working Foo class and the setup I would suggest:

    from typing import Protocol
    from typing_extensions import Self
    
    
    class FooCompatible(Protocol):
        x: int
        y: str
    
    
    class Foo:
        def __init__(self, x: int, /, y: str, z: bool = True) -> None:
            self.spam = x * y.upper()
            self.not_z = not z
    
        @classmethod
        def from_obj(cls, obj: FooCompatible) -> Self:
            try:
                optional_kwargs = {"z": getattr(obj, "z")}
            except AttributeError:
                optional_kwargs = {}
            return cls(obj.x, y=obj.y, **optional_kwargs)
    

    Usage:

    from dataclasses import dataclass
    # ... import Foo
    
    
    @dataclass
    class Bar:
        x: int
        y: str
        z: bool
    
    
    @dataclass
    class Baz:
        x: int
        y: str
    
    
    foo1 = Foo.from_obj(Bar(3, "a", False))
    print(foo1.spam, foo1.not_z)  # AAA True
    
    
    foo2 = Foo.from_obj(Baz(2, "b"))
    print(foo2.spam, foo2.not_z)  # BB False
    

    This passes mypy --strict without errors. If we for example changed Baz.y to be a float, then Mypy would immediately complain that the argument to from_obj has the wrong type.

    Note that I only used dataclasses here for convenience. The from_obj method works with any class that is FooCompatible.

    Of course, if the number of optional parameters the constructor has increases, the dynamic attribute access can become unwieldy, if you want to check for each of those individually. But one of the core tenets of Python has always been "explicit is better than implicit", so I would still suggest going the named route and not just grab the entire __dict__.