10

I am using dataclass to parse (HTTP request/response) JSON objects and today I came across a problem that requires transformation/alias attribute names within my classes.

from dataclasses import dataclass, asdict
from typing import List
import json


@dataclass
class Foo:
    foo_name: str # foo_name -> FOO NAME


@dataclass
class Bar:
    bar_name: str # bar_name -> barName


@dataclass
class Baz:
    baz_name: str # baz_name -> B A Z
    baz_foo: List[Foo] # baz_foo -> BAZ FOO
    baz_bar: List[Bar] # baz_bar -> BAZ BAR

currently:

# encode
baz_e = Baz("name", [{"foo_name": "one"}, {"foo_name": "two"}], [{"bar_name": "first"}])
json_baz_e = json.dumps(asdict(baz_e))
print(json_baz_e)
# {"baz_name": "name", "baz_foo": [{"foo_name": "one"}, {"foo_name": "two"}], "baz_bar": [{"bar_name": "first"}]}


# decode
json_baz_d = {
    "baz_name": "name", 
    "baz_foo": [{"foo_name": "one"}, {"foo_name": "two"}], 
    "baz_bar":[{"bar_name": "first"}]
}
baz_d = Baz(**json_baz_d) # back to class instance
print(baz_d)
# Baz(baz_name='name', baz_foo=[{'foo_name': 'one'}, {'foo_name': 'two'}], baz_bar=[{'bar_name': 'first'}])

expected:

# encode
baz_e = Baz("name", [{"FOO NAME": "one"}, {"FOO NAME": "two"}], [{"barName": "first"}])
json_baz_e = json.dumps(asdict(baz_e))


# decode
json_baz_d = {
    "B A Z": "name", 
    "BAZ FOO": [{"FOO NAME": "one"}, {"FOO NAME": "two"}], 
    "BAZ BAR":[{"barName": "first"}]
}
baz_d = Baz(**json_baz_d) # back to class instance

Is the only solution dataclasses-json, or is there still a possibility without additional libraries?

3 Answers 3

7

You could certainly use dataclasses-json for this, however if you don't need the advantage of marshmallow schemas, you can probably get by with an alternate solution like the dataclass-wizard, which is similarly a JSON serialization library built on top of dataclasses. It supports alias field mappings as needed here; another bonus is that it doesn't have any dependencies outside of Python stdlib, other than the typing-extensions module for Python < 3.10.

There's a few choices available to specify alias field mappings, but in the below example I chose two options to illustrate:

  • json_field, which can be considered an alias to dataclasses.field
  • a json_key_to_field mapping that can be specified in the Meta config for a dataclass
from dataclasses import dataclass
from typing import List

from dataclass_wizard import JSONWizard, json_field


@dataclass
class Foo:
    # pass all=True, so reverse mapping (field -> JSON) is also added
    foo_name: str = json_field('FOO NAME', all=True)


@dataclass
class Bar:
    # default key transform is `camelCase`, so alias is not needed here
    bar_name: str


@dataclass
class Baz(JSONWizard):

    class _(JSONWizard.Meta):
        json_key_to_field = {
            # Pass '__all__', so reverse mapping (field -> JSON) is also added
            '__all__': True,
            'B A Z': 'baz_name',
            'BAZ FOO': 'baz_foo',
            'BAZ BAR': 'baz_bar'
        }

    baz_name: str
    baz_foo: List[Foo]
    baz_bar: List[Bar]


# encode
baz_e = Baz("name", [Foo('one'), Foo('two')], [Bar('first')])
json_baz_d = baz_e.to_dict()

print(json_baz_d)
# {'B A Z': 'name', 'BAZ FOO': [{'FOO NAME': 'one'}, {'FOO NAME': 'two'}], 'BAZ BAR': [{'barName': 'first'}]}

# decode
baz_d = Baz.from_dict(json_baz_d)  # back to class instance

print(repr(baz_d))
# > Baz(baz_name='name', baz_foo=[Foo(foo_name='one'), Foo(foo_name='two')], baz_bar=[Bar(bar_name='first')])

# True
assert baz_e == baz_d

NB: I noticed one obvious thing that I wanted to point out, as it seemed to not result in expected behavior. In the question above, you appear to be instantiating a Baz instance as follows:

baz_e = Baz("name", [{"foo_name": "one"}, {"foo_name": "two"}], [{"bar_name": "first"}])

However, note that the value for the baz_foo field, in this case, is a list of Python dict objects rather than a list of Foo instances. To fix this, in above solution I've changed the {"foo_name": "one"} for example to Foo('one').

Sign up to request clarification or add additional context in comments.

6 Comments

Image
In addition to installing an additional library, and limiting to versions, for a large project such as a larger number of classes and a number of attributes (as in my example) your mapping would turn into a complex solution. And I wrote the last part well - on request. :) . If there is no more elegant solution, complex will be acceptable.
I unfortunately don't know of an elegant solution using builtin modules in Python. I know that there some great validation libraries like pydantic, however even those require you to use a BaseModel or their own version of the @dataclass decorator, and also define alias mappings using Field. In that regards, I feel like this is a pretty elegant solution by itself, however there's likely a simpler solution with another library, or a better way that I haven't thought of myself.
Also, note that the Baz(**json_baz_d) wouldn't work in the original example above, unless you're using a model like from pydantic which overrides the constructor method; the reason is that this appears to be a nested dataclass model, and the inner dict objects unfortunately aren't converted by default.
Image
I elegantly think that it is clean code that would be easily applied in more complicated, ie on a larger project, because the problem you have presented is just a simple presentation of the problem. That's right, one of the solutions is that the @dataclass_json decorator with field(metadata=config(field_name ="overriddenGivenName")) has already mentioned.
@MilovanTomašević If possible, could you elaborate on what a larger example of the problem is? If there are a lot of dataclass fields and the key transform is very deterministic (for ex. snake_case to TitleCase) there might be a way to specify such a transform func and avoid defining field alias mappings as in this case.
|
2

It is possible that the solution with dataclasses-json makes the code more readable and cleaner.

pip install dataclasses-json

This library provides a simple API for encoding and decoding dataclasses to and from JSON.

import json
from typing import List
from dataclasses import dataclass, asdict, field
from dataclasses_json import config, dataclass_json


@dataclass_json
@dataclass
class Foo:
    foo_name: str = field(metadata=config(field_name="FOO NAME")) # foo_name -> FOO NAME


@dataclass_json
@dataclass
class Bar:
    bar_name: str = field(metadata=config(field_name="barName")) # bar_name -> barName


@dataclass_json
@dataclass
class Baz:
    baz_name: str = field(metadata=config(field_name="B A Z")) # baz_name -> B A Z
    baz_foo: List[Foo] = field(metadata=config(field_name="BAZ FOO")) # baz_foo -> BAZ FOO
    baz_bar: List[Bar] = field(metadata=config(field_name="BAZ BAR")) # baz_bar -> BAZ BAR


# encode
baz_e = Baz("name", [{"FOO NAME": "one"}, {"FOO NAME": "two"}], [{"barName": "first"}])
print(baz_e.to_dict())
# {'B A Z': 'name', 'BAZ FOO': [{'FOO NAME': 'one'}, {'FOO NAME': 'two'}], 'BAZ BAR': [{'barName': 'first'}]}


# decode
json_baz_d = {
    "B A Z": "name", 
    "BAZ FOO": [{"FOO NAME": "one"}, {"FOO NAME": "two"}], 
    "BAZ BAR":[{"barName": "first"}]
}
baz_d = Baz.from_dict(json_baz_d) # back to class instance
print(baz_d)
# Baz(baz_name='name', baz_foo=[Foo(foo_name='one'), Foo(foo_name='two')], baz_bar=[Bar(bar_name='first')])


# Mini test
test_from_to = Baz.from_json(baz_e.to_json())
print(test_from_to)
# Baz(baz_name='name', baz_foo=[Foo(foo_name='one'), Foo(foo_name='two')], baz_bar=[Bar(bar_name='first')])

test_to_from = Baz.to_json(test_from_to)
print(test_to_from)
# {"B A Z": "name", "BAZ FOO": [{"FOO NAME": "one"}, {"FOO NAME": "two"}], "BAZ BAR": [{"barName": "first"}]}

Encode or decode from camelCase (or kebab-case)?

  • JSON letter case by convention is camelCase, in Python members are by convention snake_case.

  • You can configure it to encode/decode from other casing schemes at both the class level and the field level.

from dataclasses import dataclass, field
from dataclasses_json import LetterCase, config, dataclass_json


# changing casing at the class level
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Foo:
    foo_bar: str
    foo_baz: str


f = Foo('one', 'two').to_json() 
print(f)
# {"fooBar": "one", "fooBaz": "two"}


# at the field level
@dataclass_json
@dataclass
class Foo:
    foo_bar: str = field(metadata=config(letter_case=LetterCase.CAMEL))
    foo_baz: str


f = Foo('one', 'two').to_json() 
print(f)
# {"fooBar": "one", "foo_baz": "two"}

ff = Foo.from_json(f)
print(ff)
# Foo(foo_bar='one', foo_baz='two')

Note:

  • In case of error:
ImportError: cannot import name '_TypedDictMeta' from 'typing_extensions'

You probably have an older version of typing-extensions, it is necessary to update it to the latest one.

pip install typing-extensions -U 

Comments

0

As @milovan-tomašević said:

It is possible that the solution with dataclasses-json makes the code more readable and cleaner.

pip install dataclasses-json

In addition, they say What if you want to work with camelCase JSON?

In case of camelCase JSON you can specify:

from dataclasses import dataclass
from dataclasses_json import dataclass_json, LetterCase

@dataclass
@dataclass_json(letter_case=LetterCase.CAMEL)
class Foo:

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.