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.
Since you added some context in your comments, I'll address the "bigger picture".
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.
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
.
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__
.