From 8bacc4df89fd74f0182d5c63255abf1612664b24 Mon Sep 17 00:00:00 2001 From: Allan Caffee Date: Mon, 2 Jun 2014 16:49:00 -0700 Subject: [PATCH 1/4] DictField: Allow None to be used as a default --- jsonmapper/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/jsonmapper/__init__.py b/jsonmapper/__init__.py index 3a1d88c..96cbd88 100644 --- a/jsonmapper/__init__.py +++ b/jsonmapper/__init__.py @@ -331,10 +331,22 @@ class DictField(Field): >>> blog.post.author.name u'John Doe' + >>> class Blog(Mapping): + ... post = DictField(Post, default=None) + + >>> blog = Blog() + >>> blog.post is None + True + """ - def __init__(self, mapping=None, name=None, default=None): - default = default or {} - Field.__init__(self, name=name, default=lambda: default.copy()) + def __init__(self, mapping=None, name=None, default=DEFAULT): + if default is DEFAULT: + default = {} + elif callable(getattr(default, 'copy', None)): + default = default.copy + else: + default = default + Field.__init__(self, name=name, default=default) self.mapping = mapping def _to_python(self, value): From 509c8b0d20f6607bf3175cf66cbc7c29c2518251 Mon Sep 17 00:00:00 2001 From: Allan Caffee Date: Thu, 12 Jun 2014 11:18:07 -0700 Subject: [PATCH 2/4] Implement validation of fields This change is backwards incompatible because it requires that all fields without a default be present. Fetching any field that doesn't have a default will raise an AttributeError. Validating the presence and correct types for all fields can be done explicitly by calling ``validate``. This function will also check for unrecognized fields and raise an exception for these fields unless it has been configured not to do so. --- jsonmapper/__init__.py | 106 ++++++++++++++++++++++++++++++++++++++--- test/test_validate.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 test/test_validate.py diff --git a/jsonmapper/__init__.py b/jsonmapper/__init__.py index 96cbd88..7f192d4 100644 --- a/jsonmapper/__init__.py +++ b/jsonmapper/__init__.py @@ -23,6 +23,48 @@ >>> person.age 42 +In addition to wrapping dictionaries in Python objects JSONMapper can be used +to validate JSON data structures. This allows for input validation like one +might use for a REST API. If we had an API to that acted as a directory for +`People` we might validate the input to make sure the requrired fields `name` +and `age` were specified. + +>>> person = Person(age=42) +>>> person.validate() +Traceback (most recent call last): +... +ValueError: Field 'name' has no default and was not specified. +>>> person.name +Traceback (most recent call last): +... +AttributeError: A value was not set for 'name' and there is no default. +>>> person.name = 'John Doe' +>>> person.validate() + +Notice that `name` and `age` are required because no default value was +specified. If we wanted to allow age to be optional we could set a default. In +this case ``None`` will suffice: + +>>> class Person(Mapping): +... name = TextField() +... age = IntegerField(default=None) +... added = DateTimeField(default=datetime.now) +>>> person = Person(name='John Doe') +>>> person.age is None +True +>>> person.validate() + +Of course more often than not you'll be reading your input from a JSON dict +rather than contstructing it explicitly. The :method:`Mapping.wrap` method +wraps a dictionary to provide a mapping object. + +>>> content = {'age': 42} +>>> person = Person.wrap(content) +>>> person.validate() +Traceback (most recent call last): +... +ValueError: Field 'name' has no default and was not specified. + """ import copy @@ -50,25 +92,32 @@ class Field(object): the mapping of a document. """ - def __init__(self, name=None, default=None): + def __init__(self, name=None, default=DEFAULT): self.name = name self.default = default + self.is_required = default is DEFAULT def __get__(self, instance, owner): if instance is None: return self - value = instance._data.get(self.name) - if value is not None: + value = instance._data.get(self.name, DEFAULT) + if self.default is DEFAULT and value is DEFAULT: + raise AttributeError('A value was not set for %r and there is no default.' % self.name) + + if value not in (None, DEFAULT): value = self._to_python(value) - elif self.default is not None: + elif self.default is not DEFAULT: default = self.default if callable(default): default = default() value = default + return value def __set__(self, instance, value): - if value is not None: + # I feel like this should just be 'is not DEFAULT' but + # everything breaks when I set it to this. + if value not in (DEFAULT, None): value = self._to_json(value) instance._data[self.name] = value @@ -104,7 +153,14 @@ def __init__(self, **values): if attrname in values: setattr(self, attrname, values.pop(attrname)) else: - setattr(self, attrname, getattr(self, attrname)) + try: + # Try to get and set the attribute to convert it to Python if + # it's set. If it isn't set and there isn't a default then + # catch the error and ignore it. The user will see the error + # when they call validate or attempt to access the attribute. + setattr(self, attrname, getattr(self, attrname)) + except AttributeError: + pass def __repr__(self): return '<%s %r>' % (type(self).__name__, self._data) @@ -174,6 +230,37 @@ def items(self): """ return self._data.items() + def validate(self, allow_extras=False): + """Validate the correctness of the fields. + + This method checks both that all defined fields have values. If any + field does not have an explicit value and a default is provided the + field will be set to the specified default. + + :keyword allow_extras: If set to ``True`` no error will be raised if + unknown fields are found in the dict. By default a + :exc:`ValueError` will be raised. + :raises ValueError: If any field is not present in this + :class:`Mapping` and there is no default value. + """ + for name, field in self._fields.items(): + if field.is_required and self._data.get(name, DEFAULT) is DEFAULT: + raise ValueError('Field %r has no default and was not specified.' % name) + + # Check that the field converts properly. + value = getattr(self, name) + setattr(self, name, value) + # Recurse into Mappings where possible. + if callable(getattr(value, 'validate', None)): + value.validate(allow_extras=allow_extras) + + if not allow_extras: + for name, value in self._data.items(): + if name not in self._fields: + raise ValueError( + 'Encountered unexpected field %r in mapping of type %s. Value was %r.' % ( + name, type(self).__name__, value)) + class TextField(Field): """Mapping field for string values.""" @@ -328,8 +415,10 @@ class DictField(Field): u'Foo' >>> blog = Blog(post=post) + >>> blog.post.content = "My super interesting blog post!" >>> blog.post.author.name u'John Doe' + >>> blog.validate() >>> class Blog(Mapping): ... post = DictField(Post, default=None) @@ -557,3 +646,8 @@ def remove(self, value): def pop(self, *args): return self.field._to_python(self.list.pop(*args)) + + def validate(self, *args, **kwargs): + for value in self: + if callable(getattr(value, 'validate', None)): + value.validate(*args, **kwargs) diff --git a/test/test_validate.py b/test/test_validate.py new file mode 100644 index 0000000..88b3846 --- /dev/null +++ b/test/test_validate.py @@ -0,0 +1,78 @@ +from datetime import datetime + +import pytest + +from jsonmapper import * + + +def test_validate(): + class Person(Mapping): + name = TextField() + age = IntegerField() + added = DateTimeField(default=datetime.now) + office = TextField(default=None) + + p = Person(name='John Doe', age=42) + p.validate() + + # Wrong value for age. + p = Person.wrap({ + 'name': 'John Doe', + 'age': 'a string', + }) + with pytest.raises(ValueError): + p.validate() + + # No value for age. + p = Person() + with pytest.raises(ValueError): + p.validate() + + p = Person.wrap({ + 'name': 'John Doe', + 'age': 42, + 'unknown': 'data', + }) + with pytest.raises(ValueError): + p.validate() + p.validate(allow_extras=True) + + +def test_embedded_validation(): + class Post(Mapping): + title = TextField() + content = TextField() + author = DictField(Mapping.build( + name = TextField(), + email = TextField() + )) + extra = DictField() + + class Blog(Mapping): + posts = ListField(DictField(Post)) + + my_post = Post(title='My Post', content='content') + with pytest.raises(ValueError): + my_post.validate() + + blog = Blog(posts=[my_post]) + with pytest.raises(ValueError): + blog.validate() + + my_post.author.name = 'Adam' + my_post.author.email = 'adam@example.com' + my_post.validate() + blog.validate() + + class ArbitraryMapping(Mapping): + dict_field = DictField(Mapping.build( + req_field=TextField(), + opt_field=TextField(default=None), + )) + + am = ArbitraryMapping() + with pytest.raises(ValueError): + am.validate() + + am.dict_field.req_field = 'Some text' + am.validate() From 86de3cf7bdb5d11e4b5ec371a6f321b1a5f56cd0 Mon Sep 17 00:00:00 2001 From: Allan Caffee Date: Fri, 13 Jun 2014 10:29:48 -0700 Subject: [PATCH 3/4] Fix the default default --- jsonmapper/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonmapper/__init__.py b/jsonmapper/__init__.py index 7f192d4..689e3f0 100644 --- a/jsonmapper/__init__.py +++ b/jsonmapper/__init__.py @@ -430,7 +430,7 @@ class DictField(Field): """ def __init__(self, mapping=None, name=None, default=DEFAULT): if default is DEFAULT: - default = {} + default = dict elif callable(getattr(default, 'copy', None)): default = default.copy else: From e4130f7c8436f808944b18e9e96e0bb86845a6c5 Mon Sep 17 00:00:00 2001 From: Allan Caffee Date: Fri, 13 Jun 2014 10:49:15 -0700 Subject: [PATCH 4/4] Fix the indentation --- jsonmapper/__init__.py | 14 +++++++------- test/test_validate.py | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/jsonmapper/__init__.py b/jsonmapper/__init__.py index 689e3f0..b4c6374 100644 --- a/jsonmapper/__init__.py +++ b/jsonmapper/__init__.py @@ -102,7 +102,7 @@ def __get__(self, instance, owner): return self value = instance._data.get(self.name, DEFAULT) if self.default is DEFAULT and value is DEFAULT: - raise AttributeError('A value was not set for %r and there is no default.' % self.name) + raise AttributeError('A value was not set for %r and there is no default.' % self.name) if value not in (None, DEFAULT): value = self._to_python(value) @@ -154,13 +154,13 @@ def __init__(self, **values): setattr(self, attrname, values.pop(attrname)) else: try: - # Try to get and set the attribute to convert it to Python if - # it's set. If it isn't set and there isn't a default then - # catch the error and ignore it. The user will see the error - # when they call validate or attempt to access the attribute. - setattr(self, attrname, getattr(self, attrname)) + # Try to get and set the attribute to convert it to Python if + # it's set. If it isn't set and there isn't a default then + # catch the error and ignore it. The user will see the error + # when they call validate or attempt to access the attribute. + setattr(self, attrname, getattr(self, attrname)) except AttributeError: - pass + pass def __repr__(self): return '<%s %r>' % (type(self).__name__, self._data) diff --git a/test/test_validate.py b/test/test_validate.py index 88b3846..65f8fa6 100644 --- a/test/test_validate.py +++ b/test/test_validate.py @@ -17,8 +17,8 @@ class Person(Mapping): # Wrong value for age. p = Person.wrap({ - 'name': 'John Doe', - 'age': 'a string', + 'name': 'John Doe', + 'age': 'a string', }) with pytest.raises(ValueError): p.validate() @@ -29,9 +29,9 @@ class Person(Mapping): p.validate() p = Person.wrap({ - 'name': 'John Doe', - 'age': 42, - 'unknown': 'data', + 'name': 'John Doe', + 'age': 42, + 'unknown': 'data', }) with pytest.raises(ValueError): p.validate()