konradhalas / dacite

Simple creation of data classes from dictionaries.
MIT License
1.72k stars 107 forks source link

Any way to use an existing dataclass and just augment it with dacite? #96

Closed alexferl closed 4 years ago

alexferl commented 4 years ago

Hello,

Thank you for the lib, I was about to make something similar to handle nested data and dataclasses but found yours instead.

That being said, I haven't found a way to do exactly what I want even after searching. Perhaps you can help me?

I have the following base dataclass that has some fields and methods that I want in all my other dataclasses:

@dataclass
class BaseModel:
    id: str = ""
    created_at: str = field(default_factory=get_isoformat)
    deleted_at: Optional[str] = None
    updated_at: Optional[str] = None

    def _set_attrs(self, d: dict) -> BaseModel:
        for k, v in d.items():
            setattr(self, k, v)
        return self

    def delete(self):
        self.deleted_at = get_isoformat()

    def update(self, d: dict) -> BaseModel:
        self.updated_at = get_isoformat()
        return self._set_attrs(d)

    def from_dict(self, d: dict) -> BaseModel:
        return self._set_attrs(d)

    def from_json(self, d: str) -> BaseModel:
        return self._set_attrs(json.loads(d))

    def to_dict(self) -> dict:
        return asdict(self)

    def to_json(self) -> bytes:
        return json.dumps(self.to_dict())

Obviously the way I set the attributes in _set_attrs is very naive and won't support nested data so I was hopping to use dacite within that method to properly set them for me.

The use case for this is the following: Consider the following dataclasses:

@dataclass
class Profile:
    picture_url: str = ""

@dataclass
class UserModel(BaseModel):
    email: str = ""
    first_name: str = ""
    last_name: str = ""
    profile: Profile = Profile()

If I were to create a user I would do something like:

data = {"email": "test@example.com", "first_name": "Test", "last_name": "User"}
user = UserModel(**data)
db.save(user)

No problem there as there's no nested fields.

Now if I wanted to update a nested field:

data = {"profile": {"profile_url": "some_url}
user = db.load(user_id)
user.update(data)
db.save(user)

As you know that's not gonna work, profile will be set to: {"profile_url": "some_url"}

Enter dacite. Ideally, I'd want to modify my BaseModel's update() method to something like this:

    def update(self, d: dict) -> BaseModel:
        self.updated_at = get_isoformat()
        dacite.from_dict(self, d)
        return self

but again, as you know, this won't work since from_dict() excepts Type[T] not an instance.

A very hacky workaround that seems like a bad idea is:

    def update(self, d: dict) -> BaseModel:
        self.updated_at = get_isoformat()
        self.__dict__.update(d)
        c = dacite.from_dict(self.__class__, asdict(self))
        new_obj = c
        self.__dict__.update(new_obj.__dict__)
        return self

My question is, would you consider having dacite augment an existing instance instead of creating one? Or is there another way to do what I want?

Cheers

ahobsonsayers commented 4 years ago

Id also be interested in this! Id like to be able to implement a from_dict method() that takes a dictionary and uses the values in a existing class instance. Basically id like to be able to call from_dict on an existing instance instead of creating a whole new one. Is this possible?

konradhalas commented 4 years ago

Hi @admiralobvious - thank you for this issue. You are right, it's not possible to call from_dict with a data class instance.

As I understand you want to build some general solution, so data can contain a set of any fields from a given dataclass. It sounds like a possible extension for dacite (e.g. dacite.update_from_dict) but TBH I want to keep dacite API as small as possible.

It see two possible solutions for your problem.

  1. Dump your dataclass to dict, update it according to data and return new isntance of your model.
    def update(self, d: dict) -> BaseModel:
        current = self.to_dict()
        result = {**current, **d}
        return dacite.from_dict(self.__class__, result)
  1. Accept only "transformed" data in your update method

So instead of:

data = {"profile": {"profile_url": "some_url}

... transform your update data into:

data = {"profile": Profile(profile_url="some_url)}

... before you pass it to update method. It looks like the best solution, because you are not mixing "raw" data with your "transfomed" models. After you load your model from database you should stay in "dataclass world" only.