Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow callables for 'default' in schema (and minor fixes) #200

Merged
merged 4 commits into from
Feb 8, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions cerberus/cerberus.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ def __normalize_mapping(self, mapping, schema):
self.__normalize_rename_fields(mapping, schema)
if self.purge_unknown:
self._normalize_purge_unknown(mapping, schema)
self._normalize_default(mapping, schema)
self.__normalize_default_fields(mapping, schema)
self._normalize_coerce(mapping, schema)
self.__normalize_containers(mapping, schema)
return mapping
Expand Down Expand Up @@ -672,14 +672,56 @@ def _normalize_rename_handler(self, mapping, schema, field):
mapping[new_name] = mapping[field]
del mapping[field]

def _normalize_default(self, mapping, schema):
def __normalize_default_fields(self, mapping, schema):
def has_no_value(field):
return field not in mapping or mapping[field] is None and \
not schema[field].get('nullable', False)
fields = list(filter(has_no_value, list(schema)))

# process constant default values first
for field in filter(lambda f: 'default' in schema[f], fields):
self._normalize_default(mapping, schema, field)

todo = list(filter(lambda f: 'default_setter' in schema[f], fields))
known_states = set()
while todo:
field = todo.pop(0)
try:
self._normalize_default_setter(mapping, schema, field)
except KeyError:
# delay processing of this field as it may depend on
# another default setter which is processed later
todo.append(field)
except Exception as e:
self._error(field, errors.SETTING_DEFAULT_FAILED, str(e))
self._watch_for_unresolvable_dependencies(todo, known_states)

def _watch_for_unresolvable_dependencies(self, todo, known_states):
""" Raises an error if the same todo list appears twice. """
state = repr(todo)
if state in known_states:
for field in todo:
msg = 'Circular/unresolvable dependencies for default setters.'
self._error(field, errors.SETTING_DEFAULT_FAILED, msg)
todo.remove(field)
else:
known_states.add(state)

def _normalize_default(self, mapping, schema, field):
""" {'nullable': True} """
for field in tuple(schema):
nullable = schema[field].get('nullable', False)
if 'default' in schema[field] and \
(field not in mapping or
mapping[field] is None and not nullable):
mapping[field] = schema[field]['default']
mapping[field] = schema[field]['default']

def _normalize_default_setter(self, mapping, schema, field):
""" {'anyof': [
{'type': 'callable'},
{'type': 'string'}
]} """
if 'default_setter' in schema[field]:
setter = schema[field]['default_setter']
if isinstance(setter, _str_type):
setter = self.__get_rule_handler('normalize_default_setter',
setter)
mapping[field] = setter(mapping)

# # Validating

Expand Down
2 changes: 2 additions & 0 deletions cerberus/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
COERCION_FAILED = ErrorDefinition(0x61, 'coerce')
RENAMING_FAILED = ErrorDefinition(0x62, 'rename_handler')
READONLY_FIELD = ErrorDefinition(0x63, 'readonly')
SETTING_DEFAULT_FAILED = ErrorDefinition(0x64, 'default_setter')

# groups
ERROR_GROUP = ErrorDefinition(0x80, None)
Expand Down Expand Up @@ -352,6 +353,7 @@ class BasicErrorHandler(BaseErrorHandler):
0x61: "field '{field}' cannot be coerced: {0}",
0x62: "field '{field}' cannot be renamed: {0}",
0x63: "field is read-only",
0x64: "default value for '{field}' cannot be set: {0}",

0x81: "mapping doesn't validate subschema: {0}",
0x82: "one or more sequence-items don't validate: {0}",
Expand Down
2 changes: 1 addition & 1 deletion cerberus/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def __init__(self, validator, schema=dict()):
self.validator = validator
self.schema = schema
self.validation_schema = SchemaValidationSchema(validator)
self.schema_validator = SchemaValidator(
self.schema_validator = SchemaValidator( # noqa
UnvalidatedSchema(), error_handler=errors.SchemaErrorHandler,
target_schema=schema, target_validator=validator)
self.schema_validator.allow_unknown = self.validation_schema
Expand Down
103 changes: 91 additions & 12 deletions cerberus/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1458,33 +1458,66 @@ def test_coercion_of_sequence_items(self):
self.assertIsInstance(x, float)

def test_default_missing(self):
self._test_default_missing({'default': 'bar_value'})

def test_default_setter_missing(self):
self._test_default_missing({'default_setter': lambda doc: 'bar_value'})

def _test_default_missing(self, default):
bar_schema = {'type': 'string'}
bar_schema.update(default)
schema = {'foo': {'type': 'string'},
'bar': {'type': 'string',
'default': 'bar_value'}}
'bar': bar_schema}
document = {'foo': 'foo_value'}
expected = {'foo': 'foo_value', 'bar': 'bar_value'}
self.assertNormalized(document, expected, schema)

def test_default_existent(self):
self._test_default_existent({'default': 'bar_value'})

def test_default_setter_existent(self):
def raise_error(doc):
raise RuntimeError('should not be called')
self._test_default_existent({'default_setter': raise_error})

def _test_default_existent(self, default):
bar_schema = {'type': 'string'}
bar_schema.update(default)
schema = {'foo': {'type': 'string'},
'bar': {'type': 'string',
'default': 'bar_value'}}
'bar': bar_schema}
document = {'foo': 'foo_value', 'bar': 'non_default'}
self.assertNormalized(document, document.copy(), schema)

def test_default_none_nullable(self):
self._test_default_none_nullable({'default': 'bar_value'})

def test_default_setter_none_nullable(self):
def raise_error(doc):
raise RuntimeError('should not be called')
self._test_default_none_nullable({'default_setter': raise_error})

def _test_default_none_nullable(self, default):
bar_schema = {'type': 'string',
'nullable': True}
bar_schema.update(default)
schema = {'foo': {'type': 'string'},
'bar': {'type': 'string',
'nullable': True,
'default': 'bar_value'}}
'bar': bar_schema}
document = {'foo': 'foo_value', 'bar': None}
self.assertNormalized(document, document.copy(), schema)

def test_default_none_nonnullable(self):
self._test_default_none_nullable({'default': 'bar_value'})

def test_default_setter_none_nonnullable(self):
self._test_default_none_nullable(
{'default_setter': lambda doc: 'bar_value'})

def _test_default_none_nonnullable(self, default):
bar_schema = {'type': 'string',
'nullable': False}
bar_schema.update(default)
schema = {'foo': {'type': 'string'},
'bar': {'type': 'string',
'nullable': False,
'default': 'bar_value'}}
'bar': bar_schema}
document = {'foo': 'foo_value', 'bar': 'bar_value'}
self.assertNormalized(document, document.copy(), schema)

Expand All @@ -1498,15 +1531,61 @@ def test_default_none_default_value(self):
self.assertNormalized(document, expected, schema)

def test_default_missing_in_subschema(self):
self._test_default_missing_in_subschema({'default': 'bar_value'})

def test_default_setter_missing_in_subschema(self):
self._test_default_missing_in_subschema(
{'default_setter': lambda doc: 'bar_value'})

def _test_default_missing_in_subschema(self, default):
bar_schema = {'type': 'string'}
bar_schema.update(default)
schema = {'thing': {'type': 'dict',
'schema': {'foo': {'type': 'string'},
'bar': {'type': 'string',
'default': 'bar_value'}}}}
'bar': bar_schema}}}
document = {'thing': {'foo': 'foo_value'}}
expected = {'thing': {'foo': 'foo_value',
'bar': 'bar_value'}}
self.assertNormalized(document, expected, schema)

def test_depending_default_setters(self):
schema = {
'a': {'type': 'integer'},
'b': {'type': 'integer', 'default_setter': lambda d: d['a'] + 1},
'c': {'type': 'integer', 'default_setter': lambda d: d['b'] * 2},
'd': {'type': 'integer',
'default_setter': lambda d: d['b'] + d['c']}
}
document = {'a': 1}
expected = {'a': 1, 'b': 2, 'c': 4, 'd': 6}
self.assertNormalized(document, expected, schema)

# def test_depending_and_nested_default_setters(self):
# schema = {
# 'outer': {'type': 'integer'},
# 'nested': {
# 'type': 'dict',
# 'schema': {
# 'a': {'type': 'integer'},
# 'b': {'type': 'integer',
# 'default_setter': lambda d: d['a'] + d['outer']}
# }
# },
# 'c': {'type': 'integer',
# 'default_setter': lambda d: d['nested']['b'] + 1}
# }
# document = {'outer': 1, 'nested': {'a': 1}}
# expected = {'outer': 1, 'nested': {'a': 1, 'b': 2}, 'c': 3}
# self.assertNormalized(document, expected, schema)

def test_circular_depending_default_setters(self):
schema = {
'a': {'type': 'integer', 'default_setter': lambda d: d['b'] + 1},
'b': {'type': 'integer', 'default_setter': lambda d: d['a'] + 1}
}
self.validator({}, schema)
self.assertIn(errors.SETTING_DEFAULT_FAILED, self.validator._errors)

def test_custom_coerce_and_rename(self):
class MyNormalizer(Validator):
def __init__(self, multiplier, *args, **kwargs):
Expand Down
19 changes: 19 additions & 0 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,25 @@ a method as ``rename_handler``. The method name must be prefixed with
>>> MyNormalizer(2).normalized(document, schema)
{'foo': 4}

Custom Default Setters
----------------------
Similar to custom rename handlers, it is also possible to create custom default
setters.

.. testcode::

from datetime import datetime

class MyNormalizer(Validator):
def _normalize_default_setter_utcnow(self, document):
return datetime.utcnow()

.. doctest::

>>> schema = {'creation_date': {'type': 'datetime', 'default_setter': 'utcnow'}}
>>> MyNormalizer().normalized({}, schema)
{'creation_date': datetime.datetime(...)}

Limitations
-----------
It may be a bad idea to overwrite particular contributed rules.
Expand Down
29 changes: 23 additions & 6 deletions docs/normalization-rules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,31 @@ You can set default values for missing fields in the document by using the ``def
.. doctest::

>>> v.schema = {'amount': {'type': 'integer'}, 'kind': {'type': 'string', 'default': 'purchase'}}
>>> v.normalized({'amount': 1})
{'amount': 1, 'kind': 'purchase'}
>>> v.normalized({'amount': 1}) == {'amount': 1, 'kind': 'purchase'}
True

>>> v.normalized({'amount': 1, 'kind': None}) == {'amount': 1, 'kind': 'purchase'}
True

>>> v.normalized({'amount': 1, 'kind': 'other'}) == {'amount': 1, 'kind': 'other'}
True

>>> v.normalized({'amount': 1, 'kind': None})
{'amount': 1, 'kind': 'purchase'}
You can also define a default setter callable to set the default value
dynamically. The callable gets called with the current (sub)document as the
only argument. Callables can even depend on one another, but normalizing will
fail if there is a unresolvable/circular dependency. If the constraint is a
string, it points to a :doc:`custom method <customize>`.

.. doctest::

>>> v.schema = {'a': {'type': 'integer'}, 'b': {'type': 'integer', 'default_setter': lambda doc: doc['a'] + 1}}
>>> v.normalized({'a': 1}) == {'a': 1, 'b': 2}
True

>>> v.normalized({'amount': 1, 'kind': 'other'})
{'amount': 1, 'kind': 'other'}
>>> v.schema = {'a': {'type': 'integer', 'default_setter': lambda doc: doc['not_there']}}
>>> v.normalized({})
>>> v.errors
{'a': "default value for 'a' cannot be set: Circular/unresolvable dependencies for default setters."}

.. versionadded:: 0.10

Expand Down
2 changes: 1 addition & 1 deletion docs/validation-rules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ expression. It is only tested on string values.
False

>>> v.errors
{'email': "value does not match regex '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'"}
{'email': "value does not match regex '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$'"}

For details on regular expression syntax, see the documentation on the standard
library's :mod:`re`-module.
Expand Down