"""
This module defines the core `~serde.Model` class.
"""
import inspect
import json
from collections import OrderedDict
from serde.exceptions import ContextError, add_context
from serde.fields import Field, _resolve
from serde.utils import dict_partition, zip_until_right
__all__ = ['Model']
class Fields(OrderedDict):
"""
An `~collections.OrderedDict` that allows value access with dot notation.
"""
def __getattr__(self, name):
"""
Return values in the dictionary using attribute access with keys.
"""
try:
return self[name]
except KeyError:
return super(Fields, self).__getattribute__(name)
class ModelType(type):
"""
A metaclass for a `Model`.
This metaclass pulls `~serde.fields.Field` attributes off the defined class.
These can be accessed using the ``__fields__`` attribute on the class. Model
methods use the ``__fields__`` attribute to instantiate, serialize,
deserialize, normalize, and validate models.
"""
@staticmethod
def _pop_meta(attrs):
"""
Handle the Meta class attributes.
"""
abstract = False
tag = None
if 'Meta' in attrs:
meta = attrs.pop('Meta').__dict__
if 'abstract' in meta:
abstract = meta['abstract']
if 'tag' in meta:
tag = meta['tag']
return abstract, tag
def __new__(cls, cname, bases, attrs):
"""
Create a new `Model` class.
Args:
cname (str): the class name.
bases (tuple): the base classes.
attrs (dict): the attributes for this class.
Returns:
Model: a new model class.
"""
parent = None
abstract, tag = cls._pop_meta(attrs)
# Split the attrs into Fields and non-Fields.
fields, final_attrs = dict_partition(attrs, lambda k, v: isinstance(v, Field))
if '__annotations__' in attrs:
if fields:
raise ContextError(
'simultaneous use of annotations and class attributes '
'for field definitions'
)
fields = OrderedDict(
(k, _resolve(v)) for k, v in attrs.pop('__annotations__').items()
)
# Create our Model class.
model_cls = super(ModelType, cls).__new__(cls, cname, bases, final_attrs)
# Bind the Model to the Fields.
for name, field in fields.items():
field._bind(model_cls, name=name)
# Bind the Model to the Tags.
if tag:
tag._bind(model_cls)
tags = [tag]
else:
tags = []
# Loop though the base classes, and pull Fields and Tags off.
for base in inspect.getmro(model_cls)[1:]:
if getattr(base, '__class__', None) is cls:
fields.update(
[
(name, field)
for name, field in base.__fields__.items()
if name not in attrs
]
)
tags = base.__tags__ + tags
if not parent:
parent = base
# Assign all the things to the Model!
model_cls._abstract = abstract
model_cls._parent = parent
model_cls._fields = Fields(sorted(fields.items(), key=lambda x: x[1].id))
model_cls._tag = tag
model_cls._tags = tags
return model_cls
@property
def __abstract__(cls):
"""
Whether this model class is abstract or not.
"""
return cls._abstract
@property
def __parent__(cls):
"""
This model class's parent model class.
"""
return cls._parent
@property
def __fields__(cls):
"""
A map of attribute name to field instance.
"""
return cls._fields.copy()
@property
def __tag__(cls):
"""
The model class's tag (or None).
"""
return cls._tag
@property
def __tags__(cls):
"""
The model class's tag and all parent class's tags.
"""
return cls._tags[:]
[docs]class Model(object, metaclass=ModelType):
"""
The base model.
"""
def __init__(self, *args, **kwargs):
"""
Create a new model.
Args:
*args: positional arguments values for each field on the model. If
these are given they will be interpreted as corresponding to the
fields in the order they are defined on the model class.
**kwargs: keyword argument values for each field on the model.
"""
if self.__class__.__abstract__:
raise TypeError(
f'unable to instantiate abstract model {self.__class__.__name__!r}'
)
try:
for name, value in zip_until_right(self.__class__.__fields__.keys(), args):
if name in kwargs:
raise TypeError(
f'__init__() got multiple values for keyword argument {name!r}'
)
kwargs[name] = value
except ValueError:
max_args = len(self.__class__.__fields__) + 1
given_args = len(args) + 1
raise TypeError(
f'__init__() takes a maximum of {max_args!r} '
f'positional arguments but {given_args!r} were given'
)
for field in self.__class__.__fields__.values():
with add_context(field):
field._instantiate_with(self, kwargs)
if kwargs:
kwarg = next(iter(kwargs.keys()))
raise TypeError(f'__init__() got an unexpected keyword argument {kwarg!r}')
self._normalize()
self._validate()
def __eq__(self, other):
"""
Whether two models are the same.
"""
return isinstance(other, self.__class__) and all(
getattr(self, name) == getattr(other, name)
for name in self.__class__.__fields__.keys()
)
def __hash__(self):
"""
Return a hash value for this model.
"""
return hash(
tuple(
(name, getattr(self, name)) for name in self.__class__.__fields__.keys()
)
)
def __repr__(self):
"""
Return the canonical string representation of this model.
"""
return '<{module}.{name} model at 0x{id:x}>'.format(
module=self.__class__.__module__,
name=self.__class__.__qualname__,
id=id(self),
)
[docs] def to_dict(self):
"""
Convert this model to a dictionary.
Returns:
~collections.OrderedDict: the model serialized as a dictionary.
"""
d = OrderedDict()
for field in self.__class__.__fields__.values():
with add_context(field):
d = field._serialize_with(self, d)
for tag in reversed(self.__class__.__tags__):
with add_context(tag):
d = tag._serialize_with(self, d)
return d
[docs] def to_json(self, **kwargs):
"""
Dump the model as a JSON string.
Args:
**kwargs: extra keyword arguments to pass directly to `json.dumps`.
Returns:
str: a JSON representation of this model.
"""
return json.dumps(self.to_dict(), **kwargs)
[docs] @classmethod
def from_dict(cls, d):
"""
Convert a dictionary to an instance of this model.
Args:
d (dict): a serialized version of this model.
Returns:
Model: an instance of this model.
"""
model = cls.__new__(cls)
model_cls = None
tag = model.__class__.__tag__
while tag and model_cls is not model.__class__:
model_cls = model.__class__
with add_context(tag):
model, d = tag._deserialize_with(model, d)
tag = model.__class__.__tag__
for field in reversed(model.__class__.__fields__.values()):
with add_context(field):
model, d = field._deserialize_with(model, d)
model._normalize()
model._validate()
return model
[docs] @classmethod
def from_json(cls, s, **kwargs):
"""
Load the model from a JSON string.
Args:
s (str): the JSON string.
**kwargs: extra keyword arguments to pass directly to `json.loads`.
Returns:
Model: an instance of this model.
"""
return cls.from_dict(json.loads(s, **kwargs))
def _normalize(self):
"""
Normalize all fields on this model, and the model itself.
This is called by the model constructor and on deserialization, so this
is only needed if you modify attributes directly and want to renormalize
the model instance.
"""
for field in self.__class__.__fields__.values():
with add_context(field):
field._normalize_with(self)
self.normalize()
[docs] def normalize(self):
"""
Normalize this model.
Override this method to add any additional normalization to the model.
This will be called after all fields have been normalized.
"""
pass
def _validate(self):
"""
Validate all fields on this model, and the model itself.
This is called by the model constructor and on deserialization, so this
is only needed if you modify attributes directly and want to revalidate
the model instance.
"""
for field in self.__class__.__fields__.values():
with add_context(field):
field._validate_with(self)
self.validate()
[docs] def validate(self):
"""
Validate this model.
Override this method to add any additional validation to the model. This
will be called after all fields have been validated.
"""
pass