Skip to content

Commit

Permalink
Merge branch 'release/1.3'
Browse files Browse the repository at this point in the history
  • Loading branch information
timsavage committed Feb 5, 2018
2 parents 03e959f + fa1e36a commit 2d36005
Show file tree
Hide file tree
Showing 15 changed files with 357 additions and 61 deletions.
26 changes: 26 additions & 0 deletions docs/ref/codecs/dict_codec.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
##########
Dict Codec
##########

Codec for serialising and de-serialising Dictionary and List objects.

.. automodule:: odin.codecs.dict_codec

Methods
=======

.. autofunction:: load

.. autofunction:: dump


Example usage
=============

Loading a resource from a file::

from odin.codecs import dict_codec

my_dict = {}

resource = dict_codec.load(my_dict, MyResource)
9 changes: 9 additions & 0 deletions docs/ref/resources/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,15 @@ HttpDateTimeField

A :py:class:`datetime` field or date encoded in ISO-1123 or HTTP datetime string format.

.. _field-uuid_field:

UUIDField
=========

A :py:class:`UUID` field.

This field supports most accepted values for initializing a UUID except bytes_le.

.. _field-array_field:

ArrayField
Expand Down
10 changes: 5 additions & 5 deletions odin/codecs/csv_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from odin import bases
from odin.compatibility import deprecated
from odin.datastructures import CaseLessStringList
from odin.fields import NOT_PROVIDED
from odin.fields import NotProvided
from odin.resources import create_resource_from_iter, create_resource_from_dict
from odin.utils import getmeta, lazy_property
from odin.exceptions import CodecDecodeError, ValidationError
Expand Down Expand Up @@ -133,7 +133,7 @@ def create_resource(values, i):
for idx, row in enumerate(self._reader):
# Check if row is less than mapping (as this will causes errors)!
res = create_resource(
(s if s is NOT_PROVIDED else row[s] for s in mapping),
(s if s is NotProvided else row[s] for s in mapping),
idx + 1) # Add one to index as row "0" will be the header
if res:
yield res
Expand Down Expand Up @@ -196,7 +196,7 @@ def field_mapping(self):
if name in header:
mapping.append(header.index(name))
else:
mapping.append(NOT_PROVIDED)
mapping.append(NotProvided)

# Append any extra fields
for name in self.extra_field_names:
Expand Down Expand Up @@ -332,6 +332,6 @@ def dumps(resources, resource_type=None, cls=csv.writer, **kwargs):
:param kwargs: Additional parameters to be supplied to the writer instance.
"""
buf = six.StringIO.StringIO()
dump(buf, resources, resource_type, cls, **kwargs)
buf = six.StringIO()
dump(buf, resources, resource_type=resource_type, cls=cls, **kwargs)
return buf.getvalue()
2 changes: 1 addition & 1 deletion odin/codecs/dict_codec.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import six
from odin.utils import getmeta

from odin import bases
from odin import resources, ResourceAdapter
from odin.utils import getmeta


TYPE_SERIALIZERS = {}
Expand Down
4 changes: 2 additions & 2 deletions odin/contrib/inspect/resource.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import absolute_import, print_function
from humanfriendly import tables
from odin.fields import NOT_PROVIDED
from odin.fields import NotProvided
from odin.utils import field_iter
from .base import SummaryBase

Expand All @@ -20,7 +20,7 @@ def render(self):
data.append([
field.name,
field.__class__.__name__,
'' if field is not NOT_PROVIDED else str(field.default),
'' if field is not NotProvided else str(field.default),
field.doc_text
])
self.print(tables.format_pretty_table(data, column_names))
66 changes: 60 additions & 6 deletions odin/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import copy
import datetime
import six
import uuid

from odin import exceptions, datetimeutil, registration
from odin.utils import value_in_choices, getmeta
Expand All @@ -14,19 +15,24 @@
__all__ = (
'BooleanField', 'StringField', 'UrlField', 'IntegerField', 'FloatField', 'DateField',
'TimeField', 'NaiveTimeField', 'DateTimeField', 'NaiveDateTimeField', 'HttpDateTimeField', 'TimeStampField',
'EmailField', 'IPv4Field', 'IPv6Field', 'IPv46Field',
'DictField', 'ObjectField', 'ArrayField', 'TypedArrayField', 'TypedListField', 'TypedDictField', 'TypedObjectField'
'EmailField', 'IPv4Field', 'IPv6Field', 'IPv46Field', 'UUIDField',
'DictField', 'ObjectField', 'ArrayField', 'TypedArrayField', 'TypedListField', 'TypedDictField', 'TypedObjectField',
'NotProvided',
)


if six.PY3:
long = int


class NOT_PROVIDED:
class NotProvided:
pass


# Backwards compatibility
NOT_PROVIDED = NotProvided


class Field(BaseField):
"""
Base class for fields.
Expand All @@ -40,7 +46,7 @@ class Field(BaseField):
data_type_name = None

def __init__(self, verbose_name=None, verbose_name_plural=None, name=None, null=False, choices=None,
use_default_if_not_provided=False, default=NOT_PROVIDED, help_text='', validators=None,
use_default_if_not_provided=False, default=NotProvided, help_text='', validators=None,
error_messages=None, is_attribute=False, doc_text='', key=False):
"""
Initialisation of a Field.
Expand Down Expand Up @@ -125,7 +131,7 @@ def clean(self, value):
from to_python and validate are propagated. The correct value is
returned if no error is raised.
"""
if value is NOT_PROVIDED:
if value is NotProvided:
value = self.get_default() if self.use_default_if_not_provided else None
value = self.to_python(value)
self.validate(value)
Expand All @@ -136,7 +142,7 @@ def has_default(self):
"""
Returns a boolean of whether this field has a default value.
"""
return self.default is not NOT_PROVIDED
return self.default is not NotProvided

def get_default(self):
"""
Expand Down Expand Up @@ -571,6 +577,7 @@ def to_python(self, value):
msg = self.error_messages['invalid']
raise exceptions.ValidationError(msg)


ObjectField = DictField


Expand All @@ -592,6 +599,7 @@ def to_python(self, value):
msg = self.error_messages['invalid']
raise exceptions.ValidationError(msg)


ArrayField = ListField


Expand Down Expand Up @@ -748,6 +756,7 @@ def run_validators(self, value):
if value_errors:
raise exceptions.ValidationError(value_errors)


TypedObjectField = TypedDictField


Expand Down Expand Up @@ -805,3 +814,48 @@ class IPv46Field(StringField):
def __init__(self, **options):
options.setdefault('validators', []).append(validate_ipv46_address)
super(IPv46Field, self).__init__(**options)


class UUIDField(Field):
"""
An universally unique identifier.
Validates that the string represents a universally unique identifier.
"""
data_type_name = "UUID"

def __init__(self, **options):
super(UUIDField, self).__init__(**options)

def to_python(self, value):
if isinstance(value, uuid.UUID):
return value

elif isinstance(value, six.binary_type):
if len(value) == 16:
return uuid.UUID(bytes=value)

try:
value = value.decode('utf-8')
except UnicodeDecodeError as e:
raise exceptions.ValidationError(e.args[0], code='invalid')

elif isinstance(value, six.integer_types):
try:
return uuid.UUID(int=value)
except ValueError as e:
raise exceptions.ValidationError(e.args[0], code='invalid')

elif isinstance(value, (tuple, list)):
try:
return uuid.UUID(fields=value)
except ValueError as e:
raise exceptions.ValidationError(e.args[0], code='invalid')

elif not isinstance(value, six.text_type):
value = six.text_type(value)

try:
return uuid.UUID(value)
except ValueError as e:
raise exceptions.ValidationError(e.args[0], code='invalid')
38 changes: 28 additions & 10 deletions odin/mapping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import six
from odin import bases
from odin import registration
from odin.fields import NOT_PROVIDED
from odin.fields import NotProvided
from odin.resources import Resource
from odin.fields.composite import ListOf, DictAs
from odin.exceptions import MappingSetupError, MappingExecutionError
Expand All @@ -16,6 +16,7 @@
def force_tuple(value):
return value if isinstance(value, (list, tuple)) else (value,)


EMPTY_LIST = tuple()

FieldMapping = collections.namedtuple('FieldMapping', ('from_field', 'action', 'to_field',
Expand Down Expand Up @@ -121,6 +122,7 @@ def get_field_dict(self):
"""Return a dictionary of fields along with their names."""
return getmeta(self.obj).field_map


registration.register_field_resolver(ResourceFieldResolver, Resource)


Expand Down Expand Up @@ -447,12 +449,14 @@ def apply(cls, source_obj, context=None, allow_subclass=False, mapping_result=No

raise TypeError('`source_resource` parameter must be an instance of %s' % cls.from_obj)

def __init__(self, source_obj, context=None, allow_subclass=False):
def __init__(self, source_obj, context=None, allow_subclass=False, ignore_not_provided=False):
"""
Initialise instance of mapping.
:param source_obj: The source resource, this must be an instance of :py:attr:`Mapping.from_obj`.
:param context: An optional context value, this can be any value you want to aid in mapping
:param allow_subclass:
:param ignore_not_provided: Ignore values that are `NotProvided`.
"""
if allow_subclass:
if not isinstance(source_obj, self.from_obj):
Expand All @@ -462,6 +466,7 @@ def __init__(self, source_obj, context=None, allow_subclass=False):
raise TypeError('`source_resource` parameter must be an instance of %s' % self.from_obj)
self.source = source_obj
self.context = context or {}
self.ignore_not_provided = ignore_not_provided

@property
def loop_idx(self):
Expand Down Expand Up @@ -535,9 +540,14 @@ def _apply_rule(self, mapping_rule):
len(to_fields), len(to_values), mapping_rule))

if skip_if_none:
return dict((f, to_values[i]) for i, f in enumerate(to_fields) if to_values[i] is not None)
result = {f: to_values[i] for i, f in enumerate(to_fields) if to_values[i] is not None}
else:
result = {f: to_values[i] for i, f in enumerate(to_fields)}

if self.ignore_not_provided:
return {k: v for k, v in result.items() if v is not NotProvided}
else:
return dict((f, to_values[i]) for i, f in enumerate(to_fields))
return result

def create_object(self, **field_values):
"""
Expand All @@ -564,13 +574,14 @@ def convert(self, **field_values):

return self.create_object(**values)

def update(self, destination_obj, ignore_fields=None, fields=None):
def update(self, destination_obj, ignore_fields=None, fields=None, ignore_not_provided=False):
"""
Update an existing object with fields from the provided source object.
:param destination_obj: The existing destination object.
:param ignore_fields: A list of fields that should be ignored eg ID fields
:param fields: Collection of fields that should be mapped.
:param ignore_not_provided: Ignore field values that are `NotDefined`
"""
assert hasattr(self, '_mapping_rules')
Expand All @@ -579,19 +590,23 @@ def update(self, destination_obj, ignore_fields=None, fields=None):

for mapping_rule in self._mapping_rules:
for name, value in self._apply_rule(mapping_rule).items():
if name in ignore_fields or (fields and name not in fields) or value is NOT_PROVIDED:
continue
setattr(destination_obj, name, value)
if not (
(name in ignore_fields) or
(fields and name not in fields) or
(ignore_not_provided and value is NotProvided)
):
setattr(destination_obj, name, value)

return destination_obj

def diff(self, destination_obj):
def diff(self, destination_obj, ignore_not_provided=False):
"""
Return all fields that are different.
:note: a full mapping operation is performed during the diffing process.
:param destination_obj: The existing destination object.
:param ignore_not_provided: Ignore field values that are `NotDefined`
:return: set of fields that vary.
"""
Expand All @@ -600,7 +615,10 @@ def diff(self, destination_obj):
diff_fields = set()
for mapping_rule in self._mapping_rules:
for name, value in self._apply_rule(mapping_rule).items():
if value != getattr(destination_obj, name):
if not (
(value == getattr(destination_obj, name)) and
(ignore_not_provided and value is NotProvided)
):
diff_fields.add(name)
return diff_fields

Expand Down
Loading

0 comments on commit 2d36005

Please sign in to comment.