Skip to main content

Simple and flexible conversions between dataclasses and jsonable dictionaries.

Project description

dataclass-jsonable

dataclass-jsonable ci

中文说明

Simple and flexible conversions between dataclasses and jsonable dictionaries.

It maps dataclasses to jsonable dictionaries but not json strings.

Features

  • Easy to use.
  • Supports common type annotations.
  • Supports recursive conversions.
  • Supports field-level and dataclass-level overriding.

Installation

Requirements: Python >= 3.7

Install via pip:

pip install dataclass-jsonable

Quick Example

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import IntEnum
from typing import List
from dataclass_jsonable import J

class Color(IntEnum):
    BLACK = 0
    BLUE = 1
    RED = 2

@dataclass
class Pen(J):
    color: Color
    price: Decimal
    produced_at: datetime

@dataclass
class Box(J):
    pens: List[Pen]

box = Box(pens=[Pen(color=Color.BLUE, price=Decimal("20.1"), produced_at=datetime.now())])

# Encode to a jsonable dictionary.
d = box.json()
print(d)  # {'pens': [{'color': 1, 'price': '20.1', 'produced_at': 1660023062}]}

# Construct dataclass from a jsonable dictionary.
print(Box.from_json(d))

APIs are only the two: .json() and .from_json().

Built-in Supported Types

  • bool, int, float, str, None encoded as it is.

    @dataclass
    class Obj(J):
        a: int
        b: str
        c: bool
        d: None
    
    Obj(a=1, b="b", c=True, d=None).json()
    # => {'a': 1, 'b': 'b', 'c': True, 'd': None}
    
  • Decimal encoded to str.

    @dataclass
    class Obj(J):
        a: Decimal
    
    Obj(a=Decimal("3.1")).json()  # => {'a': '3.1'}
    
  • datetime encoded to timestamp integer via .timestamp() method. timedelta encoded to integer via .total_seconds() method.

    @dataclass
    class Obj(J):
        a: datetime
        b: timedelta
    
    Obj(a=datetime.now(), b=timedelta(minutes=1)).json()
    # => {'a': 1660062019, 'b': 60}
    
  • Enum and IntEnum encoded to their values via .value attribute.

    @dataclass
    class Obj(J):
        status: Status
    
    Obj(status=Status.DONE).json()  # => {'status': 1}
    
  • Any is encoded according to its type.

    @dataclass
    class Obj(J):
        a: Any
    
    Obj(1).json()  # {'a': 1}
    Obj("a").json()  # {'a': 'a'}
    Obj.from_json({"a": 1})  # Obj(a=1)
    
  • Optional[X] is supported, but Union[X, Y, ...] is not.

    @dataclass
    class Obj(J):
        a: Optional[int] = None
    
    Obj(a=1).json()  # => {'a': 1}
    
  • List[X], Tuple[X], Set[X] are all encoded to list.

    @dataclass
    class Obj(J):
        a: List[int]
        b: Set[int]
        c: Tuple[int, str]
        d: Tuple[int, ...]
    
    Obj(a=[1], b={2, 3}, c=(4, "5"), d=(7, 8, 9)).json())
    # => {'a': [1], 'b': [2, 3], 'c': [4, '5'], 'd': [7, 8, 9]}
    
    Obj.from_json({"a": [1], "b": [2, 3], "c": [4, "5"], "d": [7, 8, 9]}))
    # => Obj(a=[1], b={2, 3}, c=(4, '5'), d=(7, 8, 9))
    
  • Dict[str, X] encoded to dict.

    @dataclass
    class Obj(J):
        a: Dict[str, int]
    Obj(a={"x": 1}).json()  # => {'a': {'x': 1}}
    Obj.from_json({"a": {"x": 1}}) # => Obj(a={'x': 1})
    
  • Nested or recursively JSONAble (or J) dataclasses.

    @dataclass
    class Elem(J):
        k: str
    
    @dataclass
    class Obj(J):
        a: List[Elem]
    
    Obj([Elem("v")]).json()  # => {'a': [{'k': 'v'}]}
    Obj.from_json({"a": [{"k": "v"}]})  # Obj(a=[Elem(k='v')])
    
  • Postponed annotations (the ForwardRef in PEP 563).

    @dataclass
    class Node(J):
        name: str
        left: Optional["Node"] = None
        right: Optional["Node"] = None
    
    root = Node("root", left=Node("left"), right=Node("right"))
    root.json()
    # {'name': 'root', 'left': {'name': 'left', 'left': None, 'right': None}, 'right': {'name': 'right', 'left': None, 'right': None}}
    

If these built-in default conversion behaviors do not meet your needs, or your type is not on the list, you can use json_options introduced below to customize it.

Customization / Overriding Examples

We can override the default conversion behaviors with json_options, which uses the dataclass field's metadata for field-level customization purpose, and the namespace is j.

The following pseudo code gives the pattern:

from dataclasses import field
from dataclass_jsonable import json_options

@dataclass
class Struct(J):
    attr: T = field(metadata={"j": json_options(**kwds)})

An example list about json_options:

  • Specific a custom dictionary key over the default field's name:

    @dataclass
    class Person(J):
        attr: str = field(metadata={"j": json_options(name="new_attr")})
    Person(attr="value").json() # => {"new_attr": "value"}
    

    And more, we can use a function to specific a custom dictionary key. This may be convenient to work with class-level __default_json_options__ attribute (check it below).

    @dataclass
    class Obj(J):
        simple_value: int = field(metadata={"j": json_options(name_converter=to_camel_case)})
    Obj(simple_value=1).json()  # => {"simpleValue": 1}
    

    And we may specific a custom field name converter when converts dictionary to dataclass:

    @dataclass
    def Person(J):
      name: str = field(
            metadata={
                "j": json_options(
                    name_converter=lambda x: x.capitalize(),
                    name_inverter=lambda x: "nickname",
              )
          }
      )
    

    As the Person defined above, it will convert to dictionary like {"Name": "Jack"} and can be loaded from {"nickname": "Jack"}.

  • Omit a field if its value is empty:

    @dataclass
    class Book(J):
        name: str = field(metadata={"j": json_options(omitempty=True)})
    Book(name="").json() # => {}
    

    Further, we can specify what is 'empty' via option omitempty_tester:

    @dataclass
    class Book(J):
        attr: Optional[str] = field(
            default=None,
            metadata={
                # By default, we test `empty` using `not x`.
                "j": json_options(omitempty=True, omitempty_tester=lambda x: x is None)
            },
        )
    
    Book(attr="").json()  # => {'attr': ''}
    Book(attr=None).json()  # => {}
    
  • Always skip a field. So we can stop some "private" fields from exporting:

    @dataclass
    class Obj(J):
        attr: str = field(metadata={"j": json_options(skip=True)})
    
    Obj(attr="private").json() # => {}
    
  • Always keep a field without encoding nor decoding, this prevents the default encoding/decoding behavior:

    @dataclass
    class Obj(J):
        timestamp: datetime = field(metadata={"j": json_options(keep=True)})
    
    Obj(timestamp=datetime.now()).json() # =>  {'timestamp': datetime.datetime(2023, 9, 5, 14, 54, 24, 679103)}
    
  • dataclasses's field allows us to pass a default or default_factory argument to set a default value:

    @dataclass
    class Obj(J):
        attr: List[str] = field(default_factory=list, metadata={"j": json_options(**kwds)})
    

    There's also an option default_before_decoding in dataclass-jsonable, which specifics a default value before decoding if the key is missing in the dictionary. Sometimes this way is more concise:

    @dataclass
    class Obj(J):
        updated_at: datetime = field(metadata={"j": json_options(default_before_decoding=0)})
    
    Obj.from_json({})  # => Obj(updated_at=datetime.datetime(1970, 1, 1, 8, 0))
    

    dataclass-jsonable also introduces a class-level similar option __default_factory__. If a field has no default or default_factory declared, and has no default_before_decoding option used, this function will generate a default value according to its type, to prevent a "missing positional arguments" TypeError from raising.

    from dataclass_jsonable import J, zero
    
    @dataclass
    class Obj(J):
        __default_factory__ = zero
    
        n: int
        s: str
        k: List[str]
    
    Obj.from_json({})  # => Obj(n=0, s='', k=[])
    
  • Override the default encoders and decoders.

    This way, you have complete control over how to encode and decode at field level.

    @dataclass
    class Obj(J):
        elems: List[str] = field(
            metadata={
                "j": json_options(
                    encoder=lambda x: ",".join(x),
                    decoder=lambda x: x.split(","),
                )
            }
        )
    
    Obj(elems=["a", "b", "c"]).json()  # => {'elems': 'a,b,c'}
    Obj.from_json({"elems": "a,b,c"})  # => Obj(elems=['a', 'b', 'c'])
    

    The following code snippet about datetime is a very common example, you might want ISO format datetime conversion over timestamp integers.

    @dataclass
    class Record(J):
        created_at: datetime = field(
            default_factory=datetime.now,
            metadata={
                "j": json_options(
                    encoder=datetime.isoformat,
                    decoder=datetime.fromisoformat,
                )
            },
        )
    
    Record().json()  # => {'created_at': '2022-08-09T23:23:02.543007'}
    

    dataclass-jsonable gives encoder and decoder better alias names since 0.1.1: to_json and from_json.

    @dataclass
    class Obj(J):
        elems: List[str] = field(
            metadata={
                "j": json_options(
                    to_json=lambda x: ",".join(x),  # Alias for encoder
                    from_json=lambda x: x.split(","),  # Alias for decoder
                )
            }
        )
    
    Obj(elems=["a", "b", "c"]).json()  # => {'elems': 'a,b,c'}
    Obj.from_json({"elems": "a,b,c"})  # => Obj(elems=['a', 'b', 'c'])
    
  • For some very narrow scenarios, we may need to execute a hook function before decoding, for example, the data to be decoded is a serialized json string, and but we still want to use the built-in decoder functions instead of making a new decoder.

    import json
    
    @dataclass
    class Obj(J):
        data: Dict[str, Any] = field(metadata={"j": json_options(before_decoder=json.loads)})
    
    Obj.from_json({"data": '{"k": "v"}'})
    # => Obj(data={'k': 'v'})
    
  • Customize default behaviors at the class level.

    If an option is not explicitly set at the field level, the __default_json_options__ provided at the class level will be attempted.

    @dataclass
    class Obj(J):
        __default_json_options__ = json_options(omitempty=True)
    
        a: Optional[int] = None
        b: Optional[str] = None
    
    Obj(b="b").json() # => {'b': 'b'}
    
    @dataclass
    class Obj(J):
        __default_json_options__ = json_options(name_converter=to_camel_case)
    
        status_code: int
        simple_value: str
    
    Obj2(status_code=1, simple_value="simple").json()
    # => {"statusCode": 1, "simpleValue": "simple"}
    

Debuging

It provides a method obj._get_origin_json(), it returns the original json dictionary which constructs instance obj via from_json().

d = {"a": 1}
obj = Obj.from_json(d)
obj._get_origin_json()
# => {"a": 1}

License

BSD.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dataclass-jsonable-0.1.3.tar.gz (11.6 kB view hashes)

Uploaded Source

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page