lidatong / dataclasses-json

Easily serialize Data Classes to and from JSON
MIT License
1.36k stars 154 forks source link

Different behaviour when calling class wtih nested dataclasses #39

Open VasLem opened 5 years ago

VasLem commented 5 years ago

When I define a dataclass A with a list of nested dataclasses B, by calling the class using a json dictionary, which has also lists inside, the recursive mechanism does not work, leaving the list of dataclasses unrendered, although they are converted to python objects if from_json is called.

Example

The json:

data = {'attachments': [],
 'vesselIds': [9731444],
 'globalServiceOptions': [{'globalServiceOptionId': {'id': 2,
    'agentServiceId': {'id': 2,
     'agentName': 'Manual Import Agent',
     'serviceName': 'Bunker Analysis',
     'fileParserEnabled': True,
     'fileSplitterEnabled': False,
     'messageToFileEnabled': False,
     'scheduleAllowed': True,
     'vesselDemanded': True},
    'serviceOptionName': 'Time',
    'value': 'T_last',
    'required': False,
    'defaultValue': 'T_last',
    'description': 'UTC Timestamp',
    'serviceOptionType': 'TIME'},
   'name': 'Time',
   'value': None}]}

Result of Converting it with data unrendered:

In [10]: FileParserRequest(**data)
Out[10]: FileParserRequest(vesselIds=[9731444], attachments=[], globalServiceOptions=[{'globalServiceOptionId': {'id': 2, 'agentServiceId': {'id': 2, 'agentName': 'Manual Import Agent', 'serviceName': 'Bunker Analysis', 'fileParserEnabled': True, 'fileSplitterEnabled': False, 'messageToFileEnabled': False, 'scheduleAllowed': True, 'vesselDemanded': True}, 'serviceOptionName': 'Time', 'value': 'T_last', 'required': False, 'defaultValue': 'T_last', 'description': 'UTC Timestamp', 'serviceOptionType': 'TIME'}, 'name': 'Time', 'value': None}])

Result of converting it with from_json():

In [9]: FileParserRequest.from_json(json.dumps(data))
Out[9]: FileParserRequest(vesselIds=[9731444], attachments=[], globalServiceOptions=[ServiceOption(globalServiceOptionId=GlobalServiceOptionId(id=2, agentServiceId=AgentServiceId(id=2, agentName='Manual Import Agent', serviceName='Bunker Analysis', fileParserEnabled=True, fileSplitterEnabled=False, messageToFileEnabled=False, scheduleAllowed=True, vesselDemanded=True), serviceOptionName='Time', value='T_last', required=False, defaultValue='T_last', description='UTC Timestamp', serviceOptionType='TIME'), name='Time', value=None)])
HappyTreeBeard commented 5 years ago

The issue stems from the use of the double asterisks to pass keyword arguments to the dataclass constructor, e.g. FileParserRequest(**data). As mentioned in the original description, the problem does not occur if a JSON string is loaded.


import json
from typing import Set

from dataclasses import dataclass
from dataclasses_json import dataclass_json

@dataclass_json
@dataclass
class Student:
    id: int = 0
    name: str = ""

@dataclass_json
@dataclass
class Professor:
    id: int
    name: str

@dataclass_json
@dataclass
class Course:
    id: int
    name: str
    professor: Professor
    students: Set[Student]

c_dict = {
    "id": 1, "name": "course",
    "professor": {"id": 1, "name": "professor"},
    "students": [{"id": 1, "name": "student"}]
}

# Using **kwargs to unpack arguments in the constructor
c_unpacked = Course(**c_dict) 
# Non-keyword arguments that are already unpacked. 
c_construct = Course(1, 'course', Professor(1, 'professor'), {Student(1, 'student')})
# Problem does not occur when loading from JSON
c_json = Course.from_json(json.dumps(c_dict))

assert c_unpacked.to_json() == c_construct.to_json()  # Pass

assert isinstance(c_unpacked.professor, Professor)  # Fail, type(c1.professor) is dict
assert isinstance(c_construct.professor, Professor)  # Pass
assert isinstance(c_json.professor, Professor)  # Pass

assert c_json == c_construct  # Pass
assert c_unpacked == c_construct  # Fail

The unpacking would work if the dictionary contained the dataclass objects. Instead the unpacked dictionary contains another dictionary which overrides the type hint of the dataclass.

c_dict = {
    "id": 1, "name": "course",
    "professor": Professor(1, 'professor'),  # Pass an object, not a dictionary 
    "students": {Student(1, 'student'), }
}

Using this dictionary in my previous example would work. I would say this is intended behavior.