Skip to content
This repository has been archived by the owner on Apr 10, 2023. It is now read-only.

Commit

Permalink
Better error handling and context (#80)
Browse files Browse the repository at this point in the history
- Change Model deserialization to always raise DeserializationErrors on failure.
- Change Model instantiation to always raise InstantiationErrors.
- Add ContextError when Fields are used in the wrong context.
- Support pretty printing of SerdeErrors and context.
- Support iterating through error context chain. (Resolves #38)
  • Loading branch information
rossmacarthur committed Feb 6, 2019
1 parent 404b9a9 commit 956198c
Show file tree
Hide file tree
Showing 6 changed files with 440 additions and 95 deletions.
212 changes: 188 additions & 24 deletions src/serde/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
This module contains Exception classes that are used in Serde.
"""

from collections import namedtuple


__all__ = [
'ContextError',
'DeserializationError',
'InstantiationError',
'NormalizationError',
'SerdeError',
'SerializationError',
Expand Down Expand Up @@ -46,7 +50,7 @@ def __repr__(self):

def __str__(self):
"""
Return a string representation of this BaseSerdeError..
Return a string representation of this BaseSerdeError.
"""
return self.message or self.__class__.__name__

Expand All @@ -63,71 +67,231 @@ class MissingDependency(BaseSerdeError):
"""


class ContextError(BaseSerdeError):
"""
Raised when Models or Fields are used in the wrong context.
"""


class SerdeError(BaseSerdeError):
"""
Raised when serializing, deserializing, or validating Models fails.
Raised when any Model stage fails.
::
>>> try:
... class User(Model):
... age = fields.Int(validators=[validate.between(0, 100)])
...
... User.from_dict({'age': -1})
... except SerdeError as e:
... error = e
...
>>> error.cause
<serde.exceptions.ValidationError: expected at least 0 but got -1>
>>> error.value
-1
>>> error.field.name
'age'
>>> error.model.__name__
'User'
"""

Context = namedtuple('Context', 'cause value field model')

def __init__(self, message, cause=None, value=None, field=None, model=None):
"""
Create a new SerdeError.
Args:
message (str): a message describing the error that occurred.
cause (Exception): the exception that caused this error.
value: the Field value context.
field (~serde.fields.Field): the Field context.
model (~serde.model.Model): the Model context.
cause (Exception): an exception for this context.
value: the value which caused this error.
field (serde.fields.Field): the Field where this error happened.
model (serde.model.Model): the Model where this error happened.
"""
super(SerdeError, self).__init__(message)
self.cause = None
self.value = None
self.field = None
self.model = None
self.add_context(cause=cause, value=value, field=field, model=model)
self.contexts = []

if cause or value or field or model:
self.add_context(cause, value, field, model)

def add_context(self, cause=None, value=None, field=None, model=None):
"""
Add cause/value/field/model context.
Add another context to this SerdeError.
Args:
cause (Exception): an exception for this context.
value: the value which caused this error.
field (~serde.fields.Field): the Field where this error happened.
model (~serde.model.Model): the Model where this error happened.
"""
self.contexts.append(SerdeError.Context(cause, value, field, model))

def iter_contexts(self):
"""
Iterate through the contexts in reverse order.
"""
return reversed(self.contexts)

@classmethod
def from_exception(cls, exception, value=None, field=None, model=None):
"""
Create a new SerdeError from another Exception.
Args:
exception (Exception): the Exception to convert from.
value: the value which caused this error.
field (~serde.fields.Field): the Field where this error happened.
model (~serde.model.Model): the Model where this error happened.
Returns:
SerdeError: an instance of SerdeError.
"""
if isinstance(exception, SerdeError):
self = cls(exception.message)
exception.contexts, self.contexts = [], exception.contexts
self.add_context(exception, value, field, model)
return self
else:
return cls(str(exception) or repr(exception), exception, value, field, model)

def __getattr__(self, name):
"""
Get an attribute of a SerdeError.
"""
if name in ('cause', 'value', 'field', 'model'):
for context in self.contexts:
value = getattr(context, name)

if value is not None:
return value

return None

return self.__getattribute__(name)

@staticmethod
def _pretty_context(context, seperator='\n', prefix='Due to => ', indent=4):
"""
Pretty format the given Context.
Args:
cause (Exception): the exception that caused this error.
value: the Field value context.
field (~serde.fields.Field): the Field context.
model (~serde.model.Model): the Model context.
seperator (str): the seperator for each context.
prefix (str): the prefix for each context. Example: 'Caused by: '.
indent (int): the number of spaces to indent each context line.
Returns:
str: the pretty formatted Context.
"""
if cause is not None:
self.cause = cause
lines = []

if context.value or context.field or context.model:
s = ''

if context.value is not None:
value = repr(context.value)

if len(value) > 30:
value = value[:26] + '... '

s += 'value {} '.format(value)

if value is not None:
self.value = value
if context.field is not None:
s += 'for field {!r} of type {!r} '.format(
context.field._name,
context.field.__class__.__name__
)

if field is not None:
self.field = field
if context.model is not None:
s += 'on model {!r} '.format(context.model.__name__)

if model is not None:
self.model = model
lines.append(s.strip())

if context.cause is not None:
if isinstance(context.cause, SerdeError):
lines.append(context.cause.pretty())
else:
lines.append(repr(context.cause) or str(context.cause))

return seperator.join(' ' * indent + prefix + s for s in lines)

def pretty(self, seperator='\n', prefix='Due to => ', indent=4):
"""
Return a pretty string representation of this SerdeError.
Args:
seperator (str): the seperator for each context.
prefix (str): the prefix for each context. Example: 'Caused by: '.
indent (int): the number of spaces to indent each context line.
Returns:
str: the pretty formatted SerdeError.
"""
lines = [self.__class__.__name__]

if self.message:
lines[0] += ': ' + self.message

lines.extend([
self._pretty_context(
context,
seperator=seperator,
prefix=prefix,
indent=indent
)
for context in self.iter_contexts()
])

return seperator.join(lines)


class SerializationError(SerdeError):
"""
Raised when field serialization fails.
This would be experienced when calling a serialization method like
`Model.to_dict() <serde.model.Model.to_dict()>`.
"""


class DeserializationError(SerdeError):
"""
Raised when field deserialization fails.
This would be experienced when calling a deserialization method like
`Model.from_dict() <serde.model.Model.from_dict()>`.
"""


class InstantiationError(SerdeError):
"""
Raised when field instantiation fails.
This would be experienced when instantiating a Model using
`Model.__init__() <serde.model.Model.__init__()>`.
"""


class NormalizationError(SerdeError):
"""
Raised when field normalization fails.
This would be experienced when normalizing a Model using
`Model.normalize_all() <serde.model.Model.normalize_all()>`. However, since
this method is automatically called when deserializing or instantiating a
Model you would not typically catch this exception because it would be
converted to an `InstantiationError` or `DeserializationError`.
"""


class ValidationError(SerdeError):
"""
Raised when field validation fails.
This would be experienced when validating a Model using
`Model.validate_all() <serde.model.Model.normalize_all()>`. However, since
this method is automatically called when deserializing or instantiating a
Model you would not typically catch this exception because it would be
converted to an `InstantiationError` or `DeserializationError`.
"""
24 changes: 12 additions & 12 deletions src/serde/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
>>> Person.from_dict({'name': 'Beyonce', 'fave_number': 4})
Traceback (most recent call last):
...
serde.exceptions.ValidationError: value is not odd!
serde.exceptions.DeserializationError: value is not odd!
The `create()` method can be used to generate a new Field class from arbitrary
functions without having to manually subclass a Field. For example if we wanted
Expand All @@ -55,7 +55,7 @@
import isodate

from serde import validate
from serde.exceptions import SerdeError, SkipSerialization, ValidationError
from serde.exceptions import ContextError, SerdeError, SkipSerialization, ValidationError
from serde.utils import try_import_all, zip_equal


Expand Down Expand Up @@ -204,11 +204,11 @@ def __setattr__(self, name, value):
Set a named attribute on a Field.
Raises:
`~serde.exceptions.SerdeError`: when the _name attribute is set
`~serde.exceptions.ContextError: when the _name attribute is set
after it has already been set.
"""
if name == '_name' and hasattr(self, '_name'):
raise SerdeError('Field instance used multiple times')
raise ContextError('Field instance used multiple times')

super(Field, self).__setattr__(name, value)

Expand Down Expand Up @@ -753,12 +753,12 @@ class Dict(Instance):
>>> Example({'pi': '3.1415927'})
Traceback (most recent call last):
...
serde.exceptions.ValidationError: expected 'float' but got 'str'
serde.exceptions.InstantiationError: expected 'float' but got 'str'
>>> Example.from_dict({'constants': {100: 3.1415927}})
Traceback (most recent call last):
...
serde.exceptions.ValidationError: expected 'str' but got 'int'
serde.exceptions.DeserializationError: expected 'str' but got 'int'
"""

def __init__(self, key=None, value=None, **kwargs):
Expand Down Expand Up @@ -865,12 +865,12 @@ class List(Instance):
>>> User(emails=1234)
Traceback (most recent call last):
...
serde.exceptions.ValidationError: expected 'list' but got 'int'
serde.exceptions.InstantiationError: 'int' object is not iterable
>>> User.from_dict({'emails': [1234]})
Traceback (most recent call last):
...
serde.exceptions.ValidationError: expected 'str' but got 'int'
serde.exceptions.DeserializationError: expected 'str' but got 'int'
"""

def __init__(self, element=None, **kwargs):
Expand Down Expand Up @@ -978,12 +978,12 @@ class Tuple(Instance):
>>> Person('Beyonce', birthday=(4, 'September'))
Traceback (most recent call last):
...
serde.exceptions.ValidationError: iterables have different lengths
serde.exceptions.InstantiationError: iterables have different lengths
>>> Person.from_dict({'name': 'Beyonce', 'birthday': (4, 9, 1994)})
Traceback (most recent call last):
...
serde.exceptions.ValidationError: expected 'str' but got 'int'
serde.exceptions.DeserializationError: expected 'str' but got 'int'
"""

def __init__(self, *elements, **kwargs):
Expand Down Expand Up @@ -1124,7 +1124,7 @@ class Choice(Field):
>>> Car('yellow')
Traceback (most recent call last):
...
serde.exceptions.ValidationError: 'yellow' is not a valid choice
serde.exceptions.InstantiationError: 'yellow' is not a valid choice
"""

def __init__(self, choices, **kwargs):
Expand Down Expand Up @@ -1284,7 +1284,7 @@ class Uuid(Instance):
>>> User('not a uuid')
Traceback (most recent call last):
...
serde.exceptions.ValidationError: expected 'UUID' but got 'str'
serde.exceptions.InstantiationError: expected 'UUID' but got 'str'
"""

def __init__(self, **kwargs):
Expand Down

0 comments on commit 956198c

Please sign in to comment.