models: Add fields for supporting validation of models

Feature makes use of python descriptors to hook in type checking and other
validation when fields get set.
This commit is contained in:
Thomas Adamcik 2015-04-04 15:17:31 +02:00
parent 5c0430ef4a
commit 07912e1091
2 changed files with 315 additions and 0 deletions

View File

@ -2,6 +2,127 @@ from __future__ import absolute_import, unicode_literals
import json
# TODO: split into base models, serialization and fields?
class Field(object):
def __init__(self, default=None, type=None, choices=None):
"""
Base field for use in :class:`ImmutableObject`. These fields are
responsible type checking and other data sanitation in our models.
For simplicity fields use the Python descriptor protocol to store the
values in the instance dictionary. Also note that fields are mutable if
the object they are attached to allow it.
Default values will be validated with the exception of :class:`None`.
:param default: default value for field
:param type: if set the field value must be of this type
:param choices: if set the field value must be one of these
"""
self._name = None # Set by FieldMeta
self._choices = choices
self._default = default
self._type = type
if self._default is not None:
self.validate(self._default)
def validate(self, value):
"""Validate and possibly modify the field value before assignment"""
if self._type and not isinstance(value, self._type):
raise TypeError('Expected %s to be a %s, not %r' %
(self._name, self._type, value))
if self._choices and value not in self._choices:
raise TypeError('Expected %s to be a one of %s, not %r' %
(self._name, self._choices, value))
return value
def __get__(self, instance, owner):
if not instance:
return self
return instance.__dict__.get(self._name, self._default)
def __set__(self, instance, value):
if value is None:
value = self._default
value = self.validate(value)
if value is not None:
instance.__dict__[self._name] = value
else:
self.__delete__(instance)
def __delete__(self, instance):
instance.__dict__.pop(self._name, None)
class String(Field):
def __init__(self, default=None):
"""
Specialized :class:`Field` which is wired up for bytes and unicode.
:param default: default value for field
"""
# TODO: normalize to unicode?
# TODO: only allow unicode?
# TODO: disallow empty strings?
super(String, self).__init__(type=basestring, default=default)
class Integer(Field):
def __init__(self, default=None, min=None, max=None):
"""
:class:`Field` for storing integer numbers.
:param default: default value for field
:param min: if set the field value larger or equal to this value
:param max: if set the field value smaller or equal to this value
"""
self._min = min
self._max = max
super(Integer, self).__init__(type=(int, long), default=default)
def validate(self, value):
value = super(Integer, self).validate(value)
if self._min is not None and value < self._min:
raise ValueError('Expected %s to be at least %d, not %d' %
(self._name, self._min, value))
if self._max is not None and value > self._max:
raise ValueError('Expected %s to be at most %d, not %d' %
(self._name, self._max, value))
return value
class Collection(Field):
def __init__(self, type, container=tuple):
"""
:class:`Field` for storing collections of a given type.
:param type: all items stored in the collection must be of this type
:param container: the type to store the items in
"""
super(Collection, self).__init__(type=type, default=container())
def validate(self, value):
if isinstance(value, basestring):
raise TypeError('Expected %s to be a collection of %s, not %r'
% (self._name, self._type.__name__, value))
for v in value:
if not isinstance(v, self._type):
raise TypeError('Expected %s to be a collection of %s, not %r'
% (self._name, self._type.__name__, value))
return self._default.__class__(value) or None
class FieldOwner(type):
"""Helper to automatically assign field names to descriptors."""
def __new__(cls, name, bases, attrs):
for key, value in attrs.items():
if isinstance(value, Field):
value._name = key
return super(FieldOwner, cls).__new__(cls, name, bases, attrs)
class ImmutableObject(object):

194
tests/models/test_fields.py Normal file
View File

@ -0,0 +1,194 @@
from __future__ import absolute_import, unicode_literals
import unittest
from mopidy.models import * # noqa: F403
def create_instance(field):
"""Create an instance of a dummy class for testing fields."""
class Dummy(object):
__metaclass__ = FieldOwner
attr = field
return Dummy()
class FieldDescriptorTest(unittest.TestCase):
def test_raw_field_accesible_through_class(self):
field = Field()
instance = create_instance(field)
self.assertEqual(field, instance.__class__.attr)
def test_field_knows_its_name(self):
instance = create_instance(Field())
self.assertEqual('attr', instance.__class__.attr._name)
def test_field_has_none_as_default(self):
instance = create_instance(Field())
self.assertIsNone(instance.attr)
def test_field_does_not_store_default_in_dict(self):
instance = create_instance(Field())
self.assertNotIn('attr', instance.__dict__)
def test_field_assigment_and_retrival(self):
instance = create_instance(Field())
instance.attr = 1234
self.assertEqual(1234, instance.attr)
self.assertEqual(1234, instance.__dict__['attr'])
def test_field_can_be_reassigned(self):
instance = create_instance(Field())
instance.attr = 1234
instance.attr = 5678
self.assertEqual(5678, instance.attr)
def test_field_can_be_deleted(self):
instance = create_instance(Field())
instance.attr = 1234
del instance.attr
self.assertEqual(None, instance.attr)
self.assertNotIn('attr', instance.__dict__)
def test_field_can_be_set_to_none(self):
instance = create_instance(Field())
instance.attr = 1234
instance.attr = None
self.assertEqual(None, instance.attr)
self.assertNotIn('attr', instance.__dict__)
class FieldTest(unittest.TestCase):
def test_default_handling(self):
instance = create_instance(Field(default=1234))
self.assertEqual(1234, instance.attr)
def test_type_checking(self):
instance = create_instance(Field(type=set))
instance.attr = set()
with self.assertRaises(TypeError):
instance.attr = 1234
def test_choices_checking(self):
instance = create_instance(Field(choices=(1, 2, 3)))
instance.attr = 1
with self.assertRaises(TypeError):
instance.attr = 4
def test_default_respects_type_check(self):
with self.assertRaises(TypeError):
create_instance(Field(type=int, default='123'))
def test_default_respects_choices_check(self):
with self.assertRaises(TypeError):
create_instance(Field(choices=(1, 2, 3), default=5))
class StringTest(unittest.TestCase):
def test_default_handling(self):
instance = create_instance(String(default='abc'))
self.assertEqual('abc', instance.attr)
def test_str_allowed(self):
instance = create_instance(String())
instance.attr = str('abc')
self.assertEqual(b'abc', instance.attr)
def test_unicode_allowed(self):
instance = create_instance(String())
instance.attr = unicode('abc')
self.assertEqual(u'abc', instance.attr)
def test_other_disallowed(self):
instance = create_instance(String())
with self.assertRaises(TypeError):
instance.attr = 1234
def test_empty_string(self):
instance = create_instance(String())
instance.attr = ''
self.assertEqual('', instance.attr)
class IntegerTest(unittest.TestCase):
def test_default_handling(self):
instance = create_instance(Integer(default=1234))
self.assertEqual(1234, instance.attr)
def test_int_allowed(self):
instance = create_instance(Integer())
instance.attr = int(123)
self.assertEqual(123, instance.attr)
def test_long_allowed(self):
instance = create_instance(Integer())
instance.attr = long(123)
self.assertEqual(123, instance.attr)
def test_float_disallowed(self):
instance = create_instance(Integer())
with self.assertRaises(TypeError):
instance.attr = 123.0
def test_numeric_string_disallowed(self):
instance = create_instance(Integer())
with self.assertRaises(TypeError):
instance.attr = '123'
def test_other_disallowed(self):
instance = create_instance(String())
with self.assertRaises(TypeError):
instance.attr = tuple()
def test_min_validation(self):
instance = create_instance(Integer(min=0))
instance.attr = 0
self.assertEqual(0, instance.attr)
with self.assertRaises(ValueError):
instance.attr = -1
def test_max_validation(self):
instance = create_instance(Integer(max=10))
instance.attr = 10
self.assertEqual(10, instance.attr)
with self.assertRaises(ValueError):
instance.attr = 11
class CollectionTest(unittest.TestCase):
def test_container_instance_is_default(self):
instance = create_instance(Collection(type=int, container=frozenset))
self.assertEqual(frozenset(), instance.attr)
def test_empty_collection(self):
instance = create_instance(Collection(type=int, container=frozenset))
instance.attr = []
self.assertEqual(frozenset(), instance.attr)
self.assertNotIn('attr', instance.__dict__)
def test_collection_gets_stored_in_container(self):
instance = create_instance(Collection(type=int, container=frozenset))
instance.attr = [1, 2, 3]
self.assertEqual(frozenset([1, 2, 3]), instance.attr)
self.assertEqual(frozenset([1, 2, 3]), instance.__dict__['attr'])
def test_collection_with_wrong_type(self):
instance = create_instance(Collection(type=int, container=frozenset))
with self.assertRaises(TypeError):
instance.attr = [1, '2', 3]
def test_collection_with_string(self):
instance = create_instance(Collection(type=int, container=frozenset))
with self.assertRaises(TypeError):
instance.attr = '123'
def test_strings_should_not_be_considered_a_collection(self):
instance = create_instance(Collection(type=str, container=tuple))
with self.assertRaises(TypeError):
instance.attr = b'123'