Model is the entry point to Serious. You create it to serialize data and validate created objects. When does multiple things upon creation:
- builds a hierarchy of descriptors unwrapping the generics, optionals, etc;
- checks that this model conforms to your requirements;
- forms a tree of serializers to executed upon load/dump.
There are two model types at this point:
Protocol#
Model protocol is defined by 5 methods.
def __init__(cls: Type[T], *, **options):
- A constructor from a dataclass type and implementation specific options.
def load(data: D) -> T:
- Create a new dataclass instance from a model-specific encoded data.
def dump(obj: T) -> D:
- Encode a dataclass to model-specific type.
def load_many(data: DC) -> List[T]:
- Load a list of dataclass instances from model-specific encoded data collection.
def dump_many(obj: Iterable[T]) -> DC:
- Dump multiple objects at once to model-specific collection.
Common Options#
serializers
#
Type: Iterable[Type[FieldSerializer]]
Default: serious.serialization.field_serializers()
An ordered collection of field serializer classes. Pass a non-default collection to override how particular fields are serialized by the model. For more refer to custom serialization guide.
allow_any
#
Type: bool
Default: False
By default the dataclass and its fields cannot contain unambiguous fields annotated with Any
.
This also includes generics like list
which is equal to List[Any]
.
Both examples below are ambiguous in this manner:
@dataclass
class User:
metadata: dict
@dataclass
class Message:
extras: Any
Loading them will result in error:
>>> JsonModel(Message, allow_any=False)
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "..serious/serious/json/model.py", line 80, in __init__
key_mapper=JsonKeyMapper() if camel_case else None,
File "..serious/serious/serialization/model.py", line 71, in __init__
raise ModelContainsAny(descriptor.cls)
serious.errors.ModelContainsAny: <class '__main__.Message'>
You can pass allow_any=True
to model to serialize load/dump Any
fields as is,
although this can lead to uncertain behaviour.
allow_missing
#
Type: bool
Default: False
By default serious is strict in respect to missing data. A LoadError
will be raised if some field exists in dataclass
but is missing from loaded the data.
But some APIs prefer to omit null
values. To handle such use-case the fields should be marked Optional
:
@dataclass
class Dinosaur:
name: str
height: Optional[int]
Then loading JSON to a model with allow_missing=True
without height
will set it to None:
>>> JsonModel(Dinosaur, allow_missing=True).load('{"name": "Yoshi"}')
Dinosaur(name='Yoshi', height=None)
allow_unexpected
#
Type: bool
Default: False
If there will be some redundant data serious default is to error. The idea here is to fail fast.
But if you would like to just skip extra fields, then pass allow_unexpected=True
to your model:
>>> JsonModel(Dinosaur, allow_unexpected=True).load('{"name": "Yoshi", "height": null, "clothing": "orange boots"}')
Dinosaur(name='Yoshi', height=None)
JsonModel#
def \_\_init\_\_( self, cls: Type[T], *, serializers: Iterable[Type[FieldSerializer]] = field_serializers(), allow_any: bool = False, allow_missing: bool = False, allow_unexpected: bool = False, indent: Optional[int] = None, ):
cls
— the descriptor of the dataclass to load/dump.serializers
— field serializer classes in an order they will be tested for fitness for each field.allow_any
—False
to raise if the model contains fields annotated withAny
(this includes generics likeList[Any]
, or simplylist
).allow_missing
—False
to raise during load if data is missing the optional fields.allow_unexpected
—False
to raise during load if data contains some unknown fields.indent
— number of spaces JSON output will be indented by; `None` for most compact representation.
def load(self, json_: str) -> T:
- Creates an instance of dataclass from a JSON string.
def dump(self, o: Any) -> str:
- Dumps an instance of dataclass to a JSON string.
def load_many(self, json_: str) -> List[T]:
- Loads multiple
T
dataclass objects from JSON array of objects string. def dump_many(self, items: Collection[T]) -> str:
- Dumps a list/set/collection of objects to an array of objects JSON string.
DictModel#
def __init__( self, cls: Type[T], *, serializers: Iterable[Type[FieldSerializer]] = field_serializers(), allow_any: bool = False, allow_missing: bool = False, allow_unexpected: bool = False, ):
cls
— the descriptor of the dataclass to load/dump.serializers
— field serializer classes in an order they will be tested for fitness for each field.allow_any
—False
to raise if the model contains fields annotated withAny
(this includes generics likeList[Any]
, or simplylist
).allow_missing
—False
to raise during load if data is missing the optional fields.allow_unexpected
—False
to raise during load if data contains some unknown fields.
def load(self, data: Dict[str, Any]) -> T:
- Creates an instance of
T
from a dictionary with string keys. def dump(self, o: Any) -> Dict[str, Any]
- Dumps an instance of dataclass to a Python dictionary.
def load_many(self, items: Iterable[Dict[str, Any]]) -> List[T]:
- Loads multiple
T
dataclass objects from a list of dictionaries. def dump_many(self, items: Collection[T]) -> List[Dict[str, Any]]:
- Dumps a list/set/collection of objects to an list of primitive dictionaries.
Custom Model#
Models do not share any common parent class. Instead the idea is: "If it walks like a duck and it quacks like a duck, then it must be a duck".
So what you should do is implement the protocol described above.
Internally, your model will need to create a descriptor of your dataclass, specifying fields types and modifiers.
A root dataclass TypeDescriptor
is created by serious.descriptors.describe
function.
Having a descriptor in place, serious.serialization.SeriousModel
may be helpful.
SeriousModel
forms a tree of field serializers executed upon load and dump operations.
It does so from the provided descriptor and a list of all possible field serializers.
Check implementation for more details on how existing code base works and check sources for JsonModel for a comprehensive example:
class JsonModel(Generic[T]):
def __init__(
self,
cls: Type[T],
serializers: Iterable[Type[FieldSerializer]] = field_serializers(),
*,
allow_any: bool = False,
allow_missing: bool = False,
allow_unexpected: bool = False,
validate_on_load: bool = True,
validate_on_dump: bool = False,
ensure_frozen: Union[bool, Iterable[Type]] = False,
camel_case: bool = True,
indent: Optional[int] = None,
):
self._descriptor = describe(cls)
self._serializer: SeriousModel = SeriousModel(
self._descriptor,
serializers,
allow_any=allow_any,
allow_missing=allow_missing,
allow_unexpected=allow_unexpected,
validate_on_load=validate_on_load,
validate_on_dump=validate_on_dump,
ensure_frozen=ensure_frozen,
key_mapper=JsonKeyMapper() if camel_case else None,
)
self._dump_indentation = indent
@property
def cls(self):
return self._descriptor.cls
def load(self, json_: str) -> T:
data: MutableMapping = self._load_from_str(json_)
check_that_loading_an_object(data, self.cls)
return self._from_dict(data)
def load_many(self, json_: str) -> List[T]:
data: Collection = self._load_from_str(json_)
check_that_loading_a_list(data, self.cls)
return [self._from_dict(each) for each in data]
def dump(self, o: T) -> str:
check_is_instance(o, self.cls)
return self._dump_to_str(self._serializer.dump(o))
def dump_many(self, items: Collection[T]) -> str:
dict_items = list(map(self._dump, items))
return self._dump_to_str(dict_items)
def _dump(self, o) -> Dict[str, Any]:
return self._serializer.dump(check_is_instance(o, self.cls))
def _from_dict(self, data: MutableMapping) -> T:
return self._serializer.load(data)
def _load_from_str(self, json_: str) -> Any:
"""Override to customize JSON loading behaviour."""
return json.loads(json_)
def _dump_to_str(self, dict_items: Any) -> str:
"""Override to customize JSON dumping behaviour."""
return json.dumps(dict_items,
skipkeys=False,
ensure_ascii=False,
check_circular=True,
allow_nan=False,
indent=self._dump_indentation,
separators=None,
default=None,
sort_keys=False)