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

Commit

Permalink
Support conversion between TOML, YAML (#16)
Browse files Browse the repository at this point in the history
- Add Model.from_toml() and Model.to_toml() methods.
- Add Model.from_yaml() and Model.to_yaml() methods.
- The dependencies for these operations are made optional.

Resolves #7 
Resolves #8
  • Loading branch information
rossmacarthur committed Nov 10, 2018
1 parent 9a64818 commit 2306b13
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 23 deletions.
4 changes: 3 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ Version 0.2.0

*Unreleased*

- Add Field aliases (#14)
- Support conversion between TOML, YAML (`#16`_)
- Add Field aliases (`#14`_)
- Support arbitrary serializers and deserializers (`#6`_)
- General internal enhancements (`#5`_)

.. _#16: https://github.com/rossmacarthur/serde/pull/16
.. _#14: https://github.com/rossmacarthur/serde/pull/14
.. _#6: https://github.com/rossmacarthur/serde/pull/6
.. _#5: https://github.com/rossmacarthur/serde/pull/5
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ install: ## Install package.
$(VIRTUAL_ENV)/bin/pip install -e .

install-travis: ## Install package and linting and testing dependencies.
$(VIRTUAL_ENV)/bin/pip install -e ".[linting,testing]"
$(VIRTUAL_ENV)/bin/pip install -e ".[toml,yaml,linting,testing]"
$(VIRTUAL_ENV)/bin/pip install codecov

install-all: ## Install package and development dependencies.
$(VIRTUAL_ENV)/bin/pip install -e ".[linting,testing,documenting,packaging]"
$(VIRTUAL_ENV)/bin/pip install -e ".[toml,yaml,linting,testing,documenting,packaging]"

lint: ## Run all lints.
$(VIRTUAL_ENV)/bin/flake8 --max-complexity 10 .
Expand Down
8 changes: 6 additions & 2 deletions serde/field/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,17 +381,21 @@ class ModelField(InstanceField):
Person(name='Beyonce', birthday=Birthday(day=4, month='September'))
"""

def __init__(self, model, strict=True, **kwargs):
def __init__(self, model, dict=None, strict=True, **kwargs):
"""
Create a new ModelField.
Args:
model: the Model class that this Field wraps.
dict (type): the class of the deserialized dictionary. This defaults
to an `OrderedDict` so that the fields will be returned in the
order they were defined on the Model.
strict (bool): if set to False then no exception will be raised when
unknown dictionary keys are present when deserializing.
**kwargs: keyword arguments for the `InstanceField` constructor.
"""
super().__init__(model, **kwargs)
self.dict = dict
self.strict = strict

def serialize(self, value):
Expand All @@ -404,7 +408,7 @@ def serialize(self, value):
Returns:
dict: the serialized dictionary.
"""
value = value.to_dict()
value = value.to_dict(dict=self.dict)
return super().serialize(value)

def deserialize(self, value):
Expand Down
126 changes: 116 additions & 10 deletions serde/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,51 @@
from collections import OrderedDict
from functools import wraps

from .error import DeserializationError, SerializationError, ValidationError
from .error import DeserializationError, SerdeError, SerializationError, ValidationError
from .field import Field
from .util import create_function
from .util import create_function, try_import


def handle_field_errors(error_cls):
toml = try_import('toml')
yaml = try_import('ruamel.yaml')


def requires_module(module, package=None):
"""
Returns a decorator that handles missing optional modules.
Args:
module (str): the module to check is imported.
package (str): the PyPI package name. This is only used for the
exception message.
Returns:
function: the real decorator.
"""
def real_decorator(func):

@wraps(func)
def decorated_function(*args, **kwargs):
if not globals()[module]:
raise SerdeError('this feature requires the {!r} package to be installed'
.format(package or module))

return func(*args, **kwargs)

return decorated_function

return real_decorator


def handle_field_errors(error):
"""
Returns a decorator that handles exceptions from a Field function.
The decorated function needs to take the model class or instance as the
first parameter and the field instance as the second parameter.
Args:
error_cls (SerdeError): a SerdeError to wrap any generic exceptions that
error (SerdeError): a SerdeError to wrap any generic exceptions that
are generated by the Field function.
Returns:
Expand All @@ -31,11 +62,11 @@ def real_decorator(func):
def decorated_function(model, field, *args, **kwargs):
try:
return func(model, field, *args, **kwargs)
except error_cls as e:
except error as e:
e.add_context(field=field, model=model)
raise
except Exception as e:
raise error_cls(str(e) or repr(e), cause=e, field=field, model=model)
raise error(str(e) or repr(e), cause=e, field=field, model=model)

return decorated_function

Expand Down Expand Up @@ -383,10 +414,50 @@ def from_json(cls, s, strict=True, **kwargs):
"""
return cls.from_dict(json.loads(s, **kwargs), strict=strict)

def to_dict(self):
@classmethod
@requires_module('toml')
def from_toml(cls, s, strict=True, **kwargs):
"""
Load the Model from a TOML string.
Args:
s (str): the TOML string.
strict (bool): if set to False then no exception will be raised when
unknown dictionary keys are present.
**kwargs: extra keyword arguments to pass directly to `toml.loads`.
Returns:
Model: an instance of this Model.
"""
return cls.from_dict(toml.loads(s, **kwargs), strict=strict)

@classmethod
@requires_module('yaml', package='ruamel.yaml')
def from_yaml(cls, s, strict=True, **kwargs):
"""
Load the Model from a YAML string.
Args:
s (str): the YAML string.
strict (bool): if set to False then no exception will be raised when
unknown dictionary keys are present.
**kwargs: extra keyword arguments to pass directly to
`yaml.safe_load`.
Returns:
Model: an instance of this Model.
"""
return cls.from_dict(yaml.safe_load(s, **kwargs), strict=strict)

def to_dict(self, dict=None):
"""
Convert this Model to a dictionary.
Args:
dict (type): the class of the deserialized dictionary. This defaults
to an `OrderedDict` so that the fields will be returned in the
order they were defined on the Model.
Returns:
dict: the Model serialized as a dictionary.
Expand All @@ -408,7 +479,10 @@ def to_dict(self):
... 'age': 42
... }
"""
result = OrderedDict()
if dict is None:
dict = OrderedDict

result = dict()

for name, field in self._fields.items():
value = getattr(self, name)
Expand All @@ -418,14 +492,46 @@ def to_dict(self):

return result

def to_json(self, **kwargs):
def to_json(self, dict=None, **kwargs):
"""
Dump the Model as a JSON string.
Args:
dict (type): the class of the deserialized dictionary that is passed
to `json.dumps`.
**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)
return json.dumps(self.to_dict(dict=dict), **kwargs)

@requires_module('toml')
def to_toml(self, dict=None, **kwargs):
"""
Dump the Model as a TOML string.
Args:
dict (type): the class of the deserialized dictionary that is passed
to `toml.dumps`.
**kwargs: extra keyword arguments to pass directly to `toml.dumps`.
Returns:
str: a TOML representation of this Model.
"""
return toml.dumps(self.to_dict(dict=dict), **kwargs)

@requires_module('yaml', package='ruamel.yaml')
def to_yaml(self, dict=None, **kwargs):
"""
Dump the Model as a YAML string.
Args:
dict (type): the class of the deserialized dictionary that is passed
to `yaml.dump`.
**kwargs: extra keyword arguments to pass directly to `yaml.dump`.
Returns:
str: a YAML representation of this Model.
"""
return yaml.dump(self.to_dict(dict=dict), **kwargs)
18 changes: 18 additions & 0 deletions serde/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,30 @@
"""

import hashlib
import importlib
import inspect
import linecache
import re
from itertools import zip_longest


def try_import(name, package=None):
"""
Try import the given library, ignoring ImportErrors.
Args:
name (str): the name to import.
package (str): the package this module belongs to.
Returns:
module: the imported module or None.
"""
try:
return importlib.import_module(name, package=package)
except ImportError:
pass


def zip_equal(*iterables):
"""
A zip function that validates that all the iterables have the same length.
Expand Down
20 changes: 16 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ def find_version():
'validators>=0.12.0<0.13.0'
]

toml_requirements = [
'toml>=0.10.0<0.11.0'
]

yaml_requirements = [
'ruamel.yaml>=0.15.0<0.16.0'
]

lint_requirements = [
'flake8',
'flake8-docstrings',
Expand Down Expand Up @@ -66,10 +74,14 @@ def find_version():
packages=find_packages(exclude=['docs', 'tests']),
version=version,
install_requires=install_requirements,
extras_require={'linting': lint_requirements,
'testing': test_requirements,
'documenting': document_requirements,
'packaging': package_requirements},
extras_require={
'toml': toml_requirements,
'yaml': yaml_requirements,
'linting': lint_requirements,
'testing': test_requirements,
'documenting': document_requirements,
'packaging': package_requirements
},
python_requires='>=3.4',

author='Ross MacArthur',
Expand Down
46 changes: 43 additions & 3 deletions tests/test_model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from unittest import mock

from pytest import raises

from serde.error import DeserializationError, SerializationError, ValidationError
from serde.error import DeserializationError, SerdeError, SerializationError, ValidationError
from serde.field import Bool, Float, Int, List, ModelField, Str
from serde.model import Model

Expand Down Expand Up @@ -295,6 +297,13 @@ def deserialize(value):
with raises(DeserializationError):
Example.from_dict({'a': 5, 'b': {'x': 10.5}})

def test_from_json(self):
class Example(Model):
a = Int()
b = Str()

assert Example.from_json('{"a": 50, "b": "test"}') == Example(a=50, b='test')

def test_to_json(self):
class Example(Model):
a = Int()
Expand All @@ -303,9 +312,40 @@ class Example(Model):
example = Example(a=50, b='test')
assert example.to_json(sort_keys=True) == '{"a": 50, "b": "test"}'

def test_from_json(self):
def test_from_toml(self):
class Example(Model):
a = Int()
b = Str()

assert Example.from_json('{"a": 50, "b": "test"}') == Example(a=50, b='test')
with mock.patch('serde.model.toml', None):
with raises(SerdeError):
Example.from_toml('a = 50\nb = "test"\n')

assert Example.from_toml('a = 50\nb = "test"\n') == Example(a=50, b='test')

def test_to_toml(self):
class Example(Model):
a = Int()
b = Str()

example = Example(a=50, b='test')
assert example.to_toml() == 'a = 50\nb = "test"\n'

def test_from_yaml(self):
class Example(Model):
a = Int()
b = Str()

with mock.patch('serde.model.yaml', None):
with raises(SerdeError):
Example.from_yaml('a: 50\nb: test\n')

assert Example.from_yaml('a: 50\nb: test\n') == Example(a=50, b='test')

def test_to_yaml(self):
class Example(Model):
a = Int()
b = Str()

example = Example(a=50, b='test')
assert example.to_yaml(dict=dict, default_flow_style=False) == 'a: 50\nb: test\n'
11 changes: 10 additions & 1 deletion tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import traceback
import types

from pytest import raises

from serde.util import create_function, zip_equal
from serde.util import create_function, try_import, zip_equal


def test_try_import():
# Check that the returned value is a module.
assert isinstance(try_import('toml'), types.ModuleType)

# Check that the returned value is None.
assert try_import('not_a_real_package_i_hope') is None


def test_zip_equal():
Expand Down

0 comments on commit 2306b13

Please sign in to comment.