I have a dataclass which inherits an abstract class that implements some boilerplate, and also uses the @validate_arguments
decorator to immediately cast strings back into numbers on object creation. The dataclass is a series of figures, some of which are calculated in the __post_init__
.
report.py
:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pydantic import validate_arguments
@dataclass
class Report(ABC):
def __post_init__(self):
self.process_attributes()
@abstractmethod
def process_attributes(self):
pass
@validate_arguments
@dataclass
class SpecificReport(Report):
some_number: int
some_other_number: float
calculated_field: float = field(init=False)
def process_attributes(self):
self.calculated_field = self.some_number * self.some_other_number
I then have another class which is initialized with a class of type Report
, gathers some metadata on creation about that class, and then has methods which perform operations with these objects, including taking some content and then constructing new objects of this type from a dictionary. We determine which fields are set explicitly with inspect.signature
and explode out our dictionary and call the constructor.
report_editor.py
from inspect import signature
from report import Report, SpecificReport
class ReportEditor:
def __init__(self, report_type: type[Report], content=None):
self.content = content
self.report_type = report_type
self.explicit_fields = list(signature(report_type).parameters.keys())
def process(self):
initializable_dict = {key: val for key, val in self.content.items() if key in self.explicit_fields}
report = self.report_type(**initializable_dict)
print(report)
However, this produces an error when hitting process_attributes
, because the validate_arguments
step is not performed. Aside from that, the object is initialized as I'd expect, but since the values are strings, they remain as such and only throw an exception once trying to do an operation.
This works just fine and produces the desired behavior:
def process(self):
initializable_dict = {key: val for key, val in self.content.items() if key in self.explicit_fields}
report = SpecificReport(**initializable_dict)
print(report)
but, of course, the intent is to abstract that away and allow this ReportEditor
class to be able to do these operations without knowing what kind of Report
it is.
here is main.py
to run the reproducible example:
from report import SpecificReport
from report_editor import ReportEditor
def example():
new_report = SpecificReport(1, 1.0)
report_editor = ReportEditor(type(new_report), {
"some_number": "1",
"some_other_number": "1.0",
"calculated_field": "1.0"
})
report_editor.process()
if __name__ == '__main__':
example()
I tried putting @validate_arguments on both the parent and child classes, as well as only on the parent Report
class. These both resulted in a TypeError: cannot create 'cython_function_or_method' instances
. I'm not finding any other way there is to call the constructor from outside just using the type
object.
Why is the constructor called properly, but not the decorator function in this instance? Is it possible to maybe cast a type
object to a Callable
in order to get the full constructor somehow? What am I missing? Or is this just not possible (maybe with generics)?
CodePudding user response:
Here is the fundamental problem:
In [1]: import report
In [2]: new_report = report.SpecificReport(1, 1.0)
In [3]: type(new_report) is report.SpecificReport
Out[3]: False
This is happening because the pydantic.validate_arguments
decorator returns a cythonized function:
In [4]: report.SpecificReport
Out[4]: <cyfunction SpecificReport at 0x1103bb370>
The function does the validation. The class constructor doesn't. It looks like this decorator is experimental, and at least for now, is not designed to work on classes (it just happens to work since a class is just a callable with .__annotations__
after all).
EDIT:
However, if you do want validation, you can use pydantic.dataclasses
, which is a "drop-in" (not quite but drop-in by very close and they made a real effort at compatibility) replacement for the standard library dataclasses
. You can use change the report.py
to the following:
from abc import ABC, abstractmethod
import dataclasses
import pydantic
@pydantic.dataclasses.dataclass
class Report(ABC):
def __post_init_post_parse__(self, *args, **kwargs):
self.process_attributes()
@abstractmethod
def process_attributes(self, *args, **kwargs):
pass
@pydantic.dataclasses.dataclass
class SpecificReport(Report):
some_number: int
some_other_number: float
calculated_field: dataclasses.InitVar[float] = dataclasses.field(init=False)
def process_attributes(self, *args, **kwargs):
self.calculated_field = self.some_number * self.some_other_number
Some subtleties:
- in
__post_init__
, the arguments haven't been parsed and validated, but you can use__post_init_post_parse__
if you want them validated/parsed. We do, or elseself.some_number * self.some_other_number
will raise theTypeError
- Have to use
dataclasses.InitVar
along withdataclasses.field(init=False)
because withoutInitVar
, the validation fails if__post_init__
didn't setcalculated_field
(so we can't use the parsed fields in__post_init_post_parse__
because the missing attribute is checked earlier). There might be a way to prevent it from enforcing that, but this is what I found for now. I'm not very comfortable with it. Hopefully someone can find a better way. - had to use
*args, **kwargs
in__post_init_post_parse__
and inprocess
becauseInitVar
will pass an argument, so extenders of this class might want to do the same, so make it generic.
I had to add **args, **kwargs
to the