blob: 75b6fc634d20b7974c192f7e75afb7e481b272e2 [file] [log] [blame]
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Deserialize python dataclass from json.
This is a minimal drop-in replacement from dataclasses_json.
Dataclasses annotated with `@dataclass_json` gets a `from_dict` factory class method.
Members can be renamed with `field` metadata:
from dataclasses import dataclass, field
@dataclass_json
@dataclass
class Process:
koid: int
name: str
vmos_koid: List[int] = field(metadata=config(field_name="vmos"))
The configuration also makes possible to override the conversion from json to the field.
"""
import dataclasses
import sys
import types
from collections import abc
from enum import Enum
from typing import (
Any,
Callable,
Dict,
ForwardRef,
Optional,
Union,
get_args,
get_origin,
)
_PYTHON_3_12 = sys.version_info >= (
3,
12,
)
class Undefined(Enum):
"""Missing fields always fails. Added for API compatibility."""
RAISE = 1
def config(
field_name: Optional[str] = None,
decoder: Optional[Callable[[Any], Any]] = None,
) -> Dict[str, Any]:
"""Returns a field metadata dictionary that override the default field name and deserialization.
Args:
field_name: JSON object field name holding the data for this field. The dataclass field name
is used by default.
decoder: function receiving the JSON value and returning a value to be assigned to the
dataclass field. `_default_decoder` is used by default.
Returns:
"""
return dict(field_name=field_name, decoder=decoder)
def _identity(x: Any) -> Any:
return x
def _default_decoder(cls: type, field_type: type) -> Callable[[Any], Any]:
origin = get_origin(field_type)
if origin is list or origin is abc.Sequence:
inner_types = get_args(field_type)
assert len(inner_types) == 1, f"Supports only list with one type"
inner_decoder = _default_decoder(cls, inner_types[0])
return lambda json_data: [inner_decoder(e) for e in json_data]
elif origin is Union or origin is types.UnionType:
inner_type, likely_null = get_args(field_type)
assert likely_null is type(None), "Supports only Optional"
return _default_decoder(cls, inner_type)
elif dataclasses.is_dataclass(field_type):
assert hasattr(field_type, "from_dict"), (
f"Class {field_type} does not have 'from_dict'. "
"Did you annotated with `@dataclass_json()`"
)
return field_type.from_dict
elif isinstance(field_type, ForwardRef):
resolved_decoder = None
def decode_forward_ref(x):
nonlocal resolved_decoder
if resolved_decoder:
return resolved_decoder(x)
evaluate_kwargs = dict()
if _PYTHON_3_12:
evaluate_kwargs["recursive_guard"] = frozenset()
resolved_decoder = field_type._evaluate(
sys.modules.get(cls.__module__).__dict__,
vars(cls),
set(),
**evaluate_kwargs,
).from_dict
return resolved_decoder(x)
return decode_forward_ref
else:
return _identity
def dataclass_json(
undefined: Optional[Undefined] = None,
) -> Callable[[type], type]:
"""Annotates a dataclass, adding a `from_dict` method that deserialises a json value."""
del undefined
def decorator(cls: type) -> type:
assert dataclasses.is_dataclass(cls)
json_to_py = dict()
for field in dataclasses.fields(cls):
json_key = field.metadata.get("field_name", field.name)
decoder = field.metadata.get("decoder") or _default_decoder(
cls, field.type
)
json_to_py[json_key or field.name] = (field.name, decoder)
def from_dict(data_dict: Dict[str, Any]) -> Any:
kws = dict()
for key, value in data_dict.items():
try:
field_name, decoder = json_to_py[key]
kws[field_name] = decoder(value)
except Exception as e:
raise ValueError(
f"Unable to decode field {key!r} of {cls.__name__!r}"
) from e
return cls(**kws)
assert not hasattr(cls, "from_dict")
cls.from_dict = from_dict # type: ignore[attr-defined]
return cls
return decorator