diff --git a/benchmarks/complex.py b/benchmarks/complex.py index bbc7519b..e733b7d0 100644 --- a/benchmarks/complex.py +++ b/benchmarks/complex.py @@ -10,6 +10,10 @@ import pytest from dataclasses_json import DataClassJsonMixin, config from jsons import JsonSerializable +from dacite import from_dict as dacite_from_dict +from pydantic import BaseModel +import attr +import mashumaro from dataclass_wizard import JSONWizard from dataclass_wizard.class_helper import create_new_class @@ -19,10 +23,8 @@ log = logging.getLogger(__name__) - @dataclass class MyClass: - my_ledger: Dict[str, Any] the_answer_to_life: Optional[int] people: List['Person'] @@ -37,24 +39,33 @@ class MyClassDJ(DataClassJsonMixin): is_enabled: bool = True +# New Mashumaro Model +@dataclass +class MyClassMashumaro(mashumaro.DataClassDictMixin): + my_ledger: Dict[str, Any] + the_answer_to_life: Optional[int] + people: List['Person'] + is_enabled: bool = True + + @dataclass class Person: name: 'Name' age: int birthdate: datetime - # dataclass-factory doesn't support Literals - # gender: Literal['M', 'F', 'N/A'] gender: str occupation: Union[str, List[str]] - # dataclass-factory doesn't support DefaultDict - # hobbies: DefaultDict[str, List[str]] = field( - # default_factory=lambda: defaultdict(list)) hobbies: Dict[str, List[str]] = field( default_factory=lambda: defaultdict(list)) class Name(NamedTuple): - """A person's name""" + first: str + last: str + salutation: Optional[str] = 'Mr.' + +@dataclass +class NameDataclass: first: str last: str salutation: Optional[str] = 'Mr.' @@ -62,25 +73,15 @@ class Name(NamedTuple): @dataclass class PersonDJ: - # spent a long time debugging the issue: `dataclasses-json` doesn't - # seem to support Named Tuples like `Name` (unsure how to fix) - # - # I also can't annotate it as just a `Tuple` either... I have no idea - # why. So unsure what to do, now I'm just declaring it as `Any` type - - # which of course is a bit unfair on other test cases that handle - # Named Tuples properly, but it's the only I could get this to work. - name: Any + name: NameDataclass age: int birthdate: datetime = field(metadata=config( - encoder=datetime.isoformat, - decoder=as_datetime, - mm_field=marshmallow.fields.DateTime(format='iso') + encoder=datetime.isoformat, + decoder=as_datetime, + mm_field=marshmallow.fields.DateTime(format='iso') )) gender: str occupation: Union[str, List[str]] - # dataclass-factory doesn't support DefaultDict - # hobbies: DefaultDict[str, List[str]] = field( - # default_factory=lambda: defaultdict(list)) hobbies: Dict[str, List[str]] = field( default_factory=lambda: defaultdict(list)) @@ -91,6 +92,8 @@ class PersonDJ: JsonsType = TypeVar('JsonsType', MyClass, JsonSerializable) # Model for `dataclasses-json` DJType = TypeVar('DJType', MyClass, DataClassJsonMixin) +# Model for `mashumaro` +MashumaroType = TypeVar('MashumaroType', MyClass, mashumaro.DataClassDictMixin) # Factory for `dataclass-factory` factory = dataclass_factory.Factory() @@ -103,6 +106,14 @@ class PersonDJ: MyClassJsons: JsonsType = create_new_class( MyClass, (MyClass, JsonSerializable), 'Jsons', attr_dict=vars(MyClass).copy()) +MyClassMashumaro: MashumaroType = create_new_class( + MyClass, (MyClass, mashumaro.DataClassDictMixin), 'Mashumaro', + attr_dict=vars(MyClass).copy()) + + + +def custom_name_decoder(value): + return Name(**value) @pytest.fixture(scope='session') @@ -115,9 +126,7 @@ def data(): 'the_answer_to_life': '42', 'people': [ { - # I want to make this into a Tuple - ('Roberto', 'Fuirron') - - # but `dataclass-factory` doesn't seem to like that. - 'name': {'first': 'Roberto', 'last': 'Fuirron'}, + 'name': ('Roberto', 'Fuirron'), 'age': 21, 'birthdate': '1950-02-28T17:35:20Z', 'gender': 'M', @@ -125,10 +134,8 @@ def data(): 'hobbies': {'M-F': ('chess', '123', 'reading'), 'Sat-Sun': ['parasailing']} }, { - 'name': {'first': 'Janice', 'last': 'Darr', 'salutation': 'Dr.'}, + 'name': ('Janice', 'Darr', 'Dr.'), 'age': 45, - # `jsons` doesn't support this format (not sure how to fix?) - # 'birthdate': '1971-11-05 05:10:59', 'birthdate': '1971-11-05T05:10:59Z', 'gender': 'F', 'occupation': 'Dentist' @@ -137,10 +144,25 @@ def data(): } +@pytest.fixture(scope='session') +def data_2(data): + """data for `dataclasses-factory`, which has issue with tuple -> NamedTuple""" + + d = data.copy() + d['people'] = [p.copy() for p in data['people']] + + # I want to make this into a Tuple - ('Roberto', 'Fuirron') - + # but `dataclass-factory` doesn't seem to like that. + + d['people'][0]['name'] = {'first': 'Roberto', 'last': 'Fuirron'} + d['people'][1]['name'] = {'first': 'Janice', 'last': 'Darr', 'salutation': 'Dr.'} + + return d + + def parse_iso_format(data): return as_datetime(data) - iso_format_schema = dataclass_factory.Schema( parser=parse_iso_format, serializer=datetime.isoformat @@ -150,101 +172,76 @@ def parse_iso_format(data): datetime: iso_format_schema } - -def test_load(request, data, n): +def test_load(request, data, data_2, n): g = globals().copy() g.update(locals()) - # Result: 0.790 log.info('dataclass-wizard %f', timeit('MyClassWizard.from_dict(data)', globals=g, number=n)) - # Result: 0.774 log.info('dataclass-factory %f', - timeit('factory.load(data, MyClass)', globals=g, number=n)) + timeit('factory.load(data_2, MyClass)', globals=g, number=n)) - # Result: 23.40 - # NOTE: This likely is not an entirely fair comparison, since the - # rest load `Person.name` as a `Name` (which is a NamedTuple sub-class), - # but in this case we just load it as an `Any` type. log.info('dataclasses-json %f', - timeit('MyClassDJ.from_dict(data)', globals=g, number=n)) + timeit('MyClassDJ.from_dict(data_2)', globals=g, number=n)) + + log.info('mashumaro %f', + timeit('MyClassMashumaro.from_dict(data)', globals=g, number=n)) if not request.config.getoption("--all"): pytest.skip("Skipping benchmarks for the rest by default, unless --all is specified.") - # these ones took a long time xD - # Result: 70.752 log.info('jsons %f', timeit('MyClassJsons.load(data)', globals=g, number=n)) - # Result: 118.775 log.info('jsons (strict) %f', timeit('MyClassJsons.load(data, strict=True)', globals=g, number=n)) # Assert the dataclass instances have the same values for all fields. - c1 = MyClassWizard.from_dict(data) - c2 = factory.load(data, MyClass) + c2 = factory.load(data_2, MyClass) c3 = MyClassDJ.from_dict(data) c4 = MyClassJsons.load(data) + c5 = MyClassMashumaro.from_dict(data) - # Really can't do a direct equality check because it's all over the place. - # For example, `dataclass-factory` de-serializes NamedTuple sub-classes as - # tuples. That's a bit odd, because our annotated type is clearly a NamedTuple - # subclass (Name). - # assert c1.__dict__ == c2.__dict__ == c3.__dict__ == c4.__dict__ - - -def test_dump(request, data, n): + # Since these models might differ slightly, we can skip exact equality checks + # assert c1.__dict__ == c2.__dict__ == c3.__dict__ == c4.__dict__ == c5.__dict__ +def test_dump(request, data, data_2, n): c1 = MyClassWizard.from_dict(data) - c2 = factory.load(data, MyClass) - c3 = MyClassDJ.from_dict(data) + c2 = factory.load(data_2, MyClass) + c3 = MyClassDJ.from_dict(data_2) c4 = MyClassJsons.load(data) + c5 = MyClassMashumaro.from_dict(data) g = globals().copy() g.update(locals()) - # Result: 1.394 log.info('dataclass-wizard %f', timeit('c1.to_dict()', globals=g, number=n)) - # Result: 1.804 log.info('asdict (dataclasses) %f', timeit('asdict(c1)', globals=g, number=n)) - # Result: 0.862 log.info('dataclass-factory %f', timeit('factory.dump(c2, MyClass)', globals=g, number=n)) - # Result: 9.872 log.info('dataclasses-json %f', timeit('c3.to_dict()', globals=g, number=n)) + log.info('mashumaro %f', + timeit('c5.to_dict()', globals=g, number=n)) + if not request.config.getoption("--all"): pytest.skip("Skipping benchmarks for the rest by default, unless --all is specified.") - # Result: 53.686 log.info('jsons %f', timeit('c4.dump()', globals=g, number=n)) - # Result: 48.100 log.info('jsons (strict) %f', timeit('c4.dump(strict=True)', globals=g, number=n)) # Assert the dict objects which are the result of `to_dict` are all equal. - - # Need this step because our lib converts field names to camel-case - # by default. c1_dict = {to_snake_case(f): fval for f, fval in c1.to_dict().items()} - # I tried to make the formats equal, but then I gave up midway. Probably not - # worth the effort tbh. The other important difference is how NamedTuple's - # are converted. `dataclass-factory` already loads them as tuples, so it - # also dumps them as tuples. But in our case we dump as NamedTuple, because - # technically NamedTuple is still JSON-serializable (its a tuple in the end) - # - # for person in c1_dict['people']: - # person['birthdate'] = person['birthdate'].replace('Z', '+00:00', 1) - - # assert c1_dict == factory.dump(c2, MyClass) == c3.to_dict() == c4.dump() + + # assert c1_dict == factory.dump(c2, MyClass) == c3.to_dict() == c4.dump() == c5.to_dict() diff --git a/benchmarks/simple.py b/benchmarks/simple.py index e0320a5e..c2b76cac 100644 --- a/benchmarks/simple.py +++ b/benchmarks/simple.py @@ -7,6 +7,11 @@ import pytest from dataclasses_json import DataClassJsonMixin from jsons import JsonSerializable +from dacite import from_dict as dacite_from_dict +from pydantic import BaseModel +import marshmallow +import attr +import mashumaro from dataclass_wizard import JSONWizard from dataclass_wizard.class_helper import create_new_class @@ -14,111 +19,156 @@ log = logging.getLogger(__name__) - +# Dataclass for the test @dataclass class MyClass: my_str: str my_int: int my_bool: Optional[bool] +# Add Pydantic Model +class MyClassPydantic(BaseModel): + my_str: str + my_int: int + my_bool: Optional[bool] + +# Marshmallow Schema +class MyClassSchema(marshmallow.Schema): + my_str = marshmallow.fields.Str() + my_int = marshmallow.fields.Int() + my_bool = marshmallow.fields.Bool() + +# attrs Class +@attr.s +class MyClassAttrs: + my_str = attr.ib(type=str) + my_int = attr.ib(type=int) + my_bool = attr.ib(type=Optional[bool]) + +# Mashumaro Model +@dataclass +class MyClassMashumaro(mashumaro.DataClassDictMixin): + my_str: str + my_int: int + my_bool: Optional[bool] # Model for `dataclass-wizard` -WizType = TypeVar('WizType', MyClass, JSONWizard) +WizType = TypeVar("WizType", MyClass, JSONWizard) # Model for `jsons` -JsonsType = TypeVar('JsonsType', MyClass, JsonSerializable) +JsonsType = TypeVar("JsonsType", MyClass, JsonSerializable) # Model for `dataclasses-json` -DJType = TypeVar('DJType', MyClass, DataClassJsonMixin) +DJType = TypeVar("DJType", MyClass, DataClassJsonMixin) # Factory for `dataclass-factory` factory = dataclass_factory.Factory() -MyClassWizard: WizType = create_new_class( - MyClass, (MyClass, JSONWizard), 'Wizard') -MyClassDJ: DJType = create_new_class( - MyClass, (MyClass, DataClassJsonMixin), 'DJ') -MyClassJsons: JsonsType = create_new_class( - MyClass, (MyClass, JsonSerializable), 'Jsons') +MyClassWizard: WizType = create_new_class(MyClass, (MyClass, JSONWizard), "Wizard") +MyClassDJ: DJType = create_new_class(MyClass, (MyClass, DataClassJsonMixin), "DJ") +MyClassJsons: JsonsType = create_new_class(MyClass, (MyClass, JsonSerializable), "Jsons") - -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def data(): return { - 'my_str': 'hello world!', - 'my_int': 21, - 'my_bool': True + "my_str": "hello world!", + "my_int": 21, + "my_bool": True, } - def test_load(data, n): g = globals().copy() g.update(locals()) - # Result: 0.076 - log.info('dataclass-wizard %f', - timeit('MyClassWizard.from_dict(data)', globals=g, number=n)) - - # Result: 0.104 - log.info('dataclass-factory %f', - timeit('factory.load(data, MyClass)', globals=g, number=n)) - - # Result: 3.614 - log.info('dataclasses-json %f', - timeit('MyClassDJ.from_dict(data)', globals=g, number=n)) - - # Result: 4.702 - log.info('jsons %f', - timeit('MyClassJsons.load(data)', globals=g, number=n)) - - # Result: 5.708 - log.info('jsons (strict) %f', - timeit('MyClassJsons.load(data, strict=True)', globals=g, number=n)) + # [ RESULTS ] + # benchmarks.simple.simple - [INFO] dataclass-wizard 0.075491 + # benchmarks.simple.simple - [INFO] dataclass-factory 0.105838 + # benchmarks.simple.simple - [INFO] dataclasses-json 3.684969 + # benchmarks.simple.simple - [INFO] jsons 4.713889 + # benchmarks.simple.simple - [INFO] dacite 0.480481 + # benchmarks.simple.simple - [INFO] pydantic 0.073991 + # benchmarks.simple.simple - [INFO] marshmallow 2.219145 + # benchmarks.simple.simple - [INFO] attrs 0.020691 + # benchmarks.simple.simple - [INFO] mashumaro 0.042289 + + # Add dacite and pydantic benchmarks + log.info("dataclass-wizard %f", + timeit("MyClassWizard.from_dict(data)", globals=g, number=n)) + log.info("dataclass-factory %f", + timeit("factory.load(data, MyClass)", globals=g, number=n)) + log.info("dataclasses-json %f", + timeit("MyClassDJ.from_dict(data)", globals=g, number=n)) + log.info("jsons %f", + timeit("MyClassJsons.load(data)", globals=g, number=n)) + log.info("dacite %f", + timeit("dacite_from_dict(MyClass, data)", globals=g, number=n)) + log.info("pydantic %f", + timeit("MyClassPydantic(**data)", globals=g, number=n)) + log.info("marshmallow %f", + timeit("MyClassSchema().load(data)", globals=g, number=n)) + log.info("attrs %f", + timeit("MyClassAttrs(**data)", globals=g, number=n)) + log.info("mashumaro %f", + timeit("MyClassMashumaro.from_dict(data)", globals=g, number=n)) # Assert the dataclass instances have the same values for all fields. - c1 = MyClassWizard.from_dict(data) c2 = factory.load(data, MyClass) c3 = MyClassDJ.from_dict(data) c4 = MyClassJsons.load(data) + c5 = dacite_from_dict(MyClass, data) + c6 = MyClassPydantic(**data) + c7 = MyClassSchema().load(data) + c8 = MyClassAttrs(**data) + c9 = MyClassMashumaro.from_dict(data) - assert c1.__dict__ == c2.__dict__ == c3.__dict__ == c4.__dict__ - + assert c1.__dict__ == c2.__dict__ == c3.__dict__ == c4.__dict__ == c5.__dict__ == c6.model_dump() == c7 == c8.__dict__ == c9.to_dict() def test_dump(data, n): + + # [ RESULTS ] + # benchmarks.simple.simple - [INFO] dataclass-wizard 0.065604 + # benchmarks.simple.simple - [INFO] asdict (dataclasses) 0.087785 + # benchmarks.simple.simple - [INFO] dataclass-factory 0.084215 + # benchmarks.simple.simple - [INFO] dataclasses-json 1.278573 + # benchmarks.simple.simple - [INFO] jsons 6.192119 + # benchmarks.simple.simple - [INFO] dacite (not applicable) -- skipped + # benchmarks.simple.simple - [INFO] pydantic 0.066679 + # benchmarks.simple.simple - [INFO] marshmallow 0.000481 + # benchmarks.simple.simple - [INFO] attrs 0.122282 + # benchmarks.simple.simple - [INFO] mashumaro 0.009025 + c1 = MyClassWizard.from_dict(data) c2 = factory.load(data, MyClass) c3 = MyClassDJ.from_dict(data) c4 = MyClassJsons.load(data) + c5 = dacite_from_dict(MyClass, data) + c6 = MyClassPydantic(**data) + c7 = MyClassSchema().load(data) + c8 = MyClassAttrs(**data) + c9 = MyClassMashumaro.from_dict(data) g = globals().copy() g.update(locals()) - # Result: 0.067 - log.info('dataclass-wizard %f', - timeit('c1.to_dict()', globals=g, number=n)) - - # Result: 0.090 - log.info('asdict (dataclasses) %f', - timeit('asdict(c1)', globals=g, number=n)) - - # Result: 0.075 - log.info('dataclass-factory %f', - timeit('factory.dump(c2, MyClass)', globals=g, number=n)) - - # Result: 1.318 - log.info('dataclasses-json %f', - timeit('c3.to_dict()', globals=g, number=n)) - - # Result: 6.207 - log.info('jsons %f', - timeit('c4.dump()', globals=g, number=n)) - - # Result: 6.280 - log.info('jsons (strict) %f', - timeit('c4.dump(strict=True)', globals=g, number=n)) + log.info("dataclass-wizard %f", + timeit("c1.to_dict()", globals=g, number=n)) + log.info("asdict (dataclasses) %f", + timeit("asdict(c1)", globals=g, number=n)) + log.info("dataclass-factory %f", + timeit("factory.dump(c2, MyClass)", globals=g, number=n)) + log.info("dataclasses-json %f", + timeit("c3.to_dict()", globals=g, number=n)) + log.info("jsons %f", + timeit("c4.dump()", globals=g, number=n)) + log.info("dacite (not applicable) -- skipped") + log.info("pydantic %f", + timeit("c6.model_dump()", globals=g, number=n)) + log.info("marshmallow %f", + timeit("c7", globals=g, number=n)) + log.info("attrs %f", + timeit("attr.asdict(c8)", globals=g, number=n)) + log.info("mashumaro %f", + timeit("c9.to_dict()", globals=g, number=n)) # Assert the dict objects which are the result of `to_dict` are all equal. - - # Need this step because our lib converts field names to camel-case - # by default. c1_dict = {to_snake_case(f): fval for f, fval in c1.to_dict().items()} - assert c1_dict == factory.dump(c2, MyClass) == c3.to_dict() == c4.dump() + assert c1_dict == factory.dump(c2, MyClass) == c3.to_dict() == c4.dump() == c6.model_dump() == attr.asdict(c8) == c9.to_dict() diff --git a/requirements-test.txt b/requirements-test.txt index 41867f6b..463d9f33 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -6,3 +6,6 @@ pytest-cov==6.0.0 dataclasses-json==0.6.7 jsons==1.6.3 dataclass-factory==2.16 # pyup: ignore +dacite==1.8.1 +mashumaro==3.15 +pydantic==2.10.2