From b0a13f0935c20d2bffb08d429aa54cfd466565c9 Mon Sep 17 00:00:00 2001 From: Dominik Kellner Date: Wed, 3 Feb 2016 15:16:01 +0100 Subject: [PATCH 1/4] Ignore flake8 error --- cerberus/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cerberus/schema.py b/cerberus/schema.py index 704cccfe..26e77fa8 100644 --- a/cerberus/schema.py +++ b/cerberus/schema.py @@ -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 From 1e8336a6c4dfd9165d463d3294b0df1391f02103 Mon Sep 17 00:00:00 2001 From: Dominik Kellner Date: Wed, 3 Feb 2016 15:38:27 +0100 Subject: [PATCH 2/4] Fix escaping of email regex in doctest --- docs/validation-rules.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/validation-rules.rst b/docs/validation-rules.rst index c80065bc..3388e1d2 100644 --- a/docs/validation-rules.rst +++ b/docs/validation-rules.rst @@ -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. From 98ad12c0afbe6b97aab60bff2bc7203c056c7dae Mon Sep 17 00:00:00 2001 From: Dominik Kellner Date: Wed, 3 Feb 2016 15:49:13 +0100 Subject: [PATCH 3/4] Fix heisenbugs caused by dict comparison in doctest --- docs/normalization-rules.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/normalization-rules.rst b/docs/normalization-rules.rst index d95d8a26..7c6c3e93 100644 --- a/docs/normalization-rules.rst +++ b/docs/normalization-rules.rst @@ -62,14 +62,14 @@ 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'} + >>> v.normalized({'amount': 1, 'kind': None}) == {'amount': 1, 'kind': 'purchase'} + True - >>> v.normalized({'amount': 1, 'kind': 'other'}) - {'amount': 1, 'kind': 'other'} + >>> v.normalized({'amount': 1, 'kind': 'other'}) == {'amount': 1, 'kind': 'other'} + True .. versionadded:: 0.10 From 620c10ec736fe91dac047ef40d5555eebcb7dbdf Mon Sep 17 00:00:00 2001 From: Dominik Kellner Date: Sun, 7 Feb 2016 18:06:17 +0100 Subject: [PATCH 4/4] Add `default_setter` rule for setting default values via callables (wip) --- cerberus/cerberus.py | 58 +++++++++++++++++--- cerberus/errors.py | 2 + cerberus/tests/tests.py | 103 +++++++++++++++++++++++++++++++---- docs/customize.rst | 19 +++++++ docs/normalization-rules.rst | 17 ++++++ 5 files changed, 179 insertions(+), 20 deletions(-) diff --git a/cerberus/cerberus.py b/cerberus/cerberus.py index c5657cf6..94e0be92 100644 --- a/cerberus/cerberus.py +++ b/cerberus/cerberus.py @@ -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 @@ -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 diff --git a/cerberus/errors.py b/cerberus/errors.py index 39f1d986..c14a2e0d 100644 --- a/cerberus/errors.py +++ b/cerberus/errors.py @@ -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) @@ -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}", diff --git a/cerberus/tests/tests.py b/cerberus/tests/tests.py index 887a9ae1..4b910ddc 100644 --- a/cerberus/tests/tests.py +++ b/cerberus/tests/tests.py @@ -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) @@ -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): diff --git a/docs/customize.rst b/docs/customize.rst index 93b8581d..66027e94 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -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. diff --git a/docs/normalization-rules.rst b/docs/normalization-rules.rst index 7c6c3e93..505eee9d 100644 --- a/docs/normalization-rules.rst +++ b/docs/normalization-rules.rst @@ -71,6 +71,23 @@ You can set default values for missing fields in the document by using the ``def >>> v.normalized({'amount': 1, 'kind': 'other'}) == {'amount': 1, 'kind': 'other'} True +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 `. + +.. 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.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 .. _type-coercion: