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 #815

Closed
wants to merge 1 commit into from
Closed
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
8 changes: 7 additions & 1 deletion docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1178,7 +1178,13 @@ defining the field validation rules. Allowed validation rules are:

``default`` The default value for the field. When serving
POST and PUT requests, missing fields will be
assigned the configured default values.
assigned the configured default values. If the
default value is a callable, it is evaluated
before assignment.

Callables can even depend on one another, but
a ``RuntimeError`` will be raised if there is a
circular dependency.

It works also for types ``dict`` and ``list``.
The latter is restricted and works only for
Expand Down
45 changes: 35 additions & 10 deletions eve/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,26 +94,51 @@ def resolve_default_values(document, defaults):
.. versionadded:: 0.2
"""
todo = [(defaults, document)]
circular_dependency_checker = CircularDependencyChecker()
while len(todo) > 0:
defaults, document = todo.pop()
circular_dependency_checker.register_todo_list(todo)
defaults, document_part = todo.pop(0)
if isinstance(defaults, list) and len(defaults):
todo.extend((defaults[0], item) for item in document)
todo.extend((defaults[0], item) for item in document_part)
continue
for name, value in defaults.items():
if callable(value):
try:
value = value(document)
except KeyError:
todo.append(({name: value}, document_part))
continue
if isinstance(value, dict):
# default dicts overwrite simple values
existing = document.setdefault(name, {})
existing = document_part.setdefault(name, {})
if not isinstance(existing, dict):
document[name] = {}
todo.append((value, document[name]))
if isinstance(value, list) and len(value):
existing = document.get(name)
document_part[name] = {}
todo.append((value, document_part[name]))
elif isinstance(value, list) and len(value):
existing = document_part.get(name)
if not existing:
document.setdefault(name, value)
document_part.setdefault(name, value)
continue
if all(isinstance(item, (dict, list)) for item in existing):
todo.extend((value[0], item) for item in existing)
else:
document.setdefault(name, existing)
document_part.setdefault(name, existing)
else:
document.setdefault(name, value)
document_part.setdefault(name, value)


class CircularDependencyChecker(object):
"""Raises an error if the same todo list appears twice."""

def __init__(self):
self.known_states = set()

def register_todo_list(self, todo):
# Pickling or similar serializing techniques won't work as there are
# lambda functions in `todo`. As we don't need persistance we can just
# use a `repr` to detect equal todo lists.
state = repr(todo)
if state in self.known_states:
raise RuntimeError('circular dependency for default values')
else:
self.known_states.add(state)
74 changes: 68 additions & 6 deletions eve/tests/default_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,19 @@ def test_lists_of_lists_with_a_dict(self):


class TestResolveDefaultValues(unittest.TestCase):
def test_one_level(self):
def _test_one_level(self, defaults):
document = {'name': 'john'}
defaults = {'email': 'noemail'}
resolve_default_values(document, defaults)
self.assertEqual({'name': 'john', 'email': 'noemail'}, document)

def test_multilevel(self):
def test_one_level(self):
self._test_one_level({'email': 'noemail'})

def test_one_level_callable(self):
self._test_one_level({'email': lambda document: 'noemail'})

def _test_multilevel(self, defaults):
document = {'name': 'myname', 'one': {'hey': 'jude'}}
defaults = {'one': {'two': {'three': 'banana'}}}
resolve_default_values(document, defaults)
expected = {
'name': 'myname',
Expand All @@ -190,21 +194,33 @@ def test_multilevel(self):
}
self.assertEqual(expected, document)

def test_multilevel(self):
self._test_multilevel({'one': {'two': {'three': 'banana'}}})

def test_multilevel_callable(self):
self._test_multilevel(
{'one': {'two': {'three': lambda document: 'banana'}}})

def test_value_instead_of_dict(self):
document = {'name': 'john'}
defaults = {'name': {'first': 'john'}}
resolve_default_values(document, defaults)
self.assertEqual(document, defaults)

def test_lists(self):
def _test_lists(self, defaults):
document = {"one": [{"name": "john"}, {}]}
defaults = {"one": [{"title": "M."}]}
resolve_default_values(document, defaults)
expected = {"one": [
{"name": "john", "title": "M."},
{"title": "M."}]}
self.assertEqual(expected, document)

def test_lists(self):
self._test_lists({"one": [{"title": "M."}]})

def test_lists_callable(self):
self._test_lists({"one": lambda document: [{"title": "M."}]})

def test_list_of_list_single_value(self):
document = {'one': [[], []]}
defaults = {'one': [['listoflist']]}
Expand Down Expand Up @@ -241,3 +257,49 @@ def test_list_of_list_dict_value(self):
resolve_default_values(document, defaults)
expected = {'one': [[{'name': 'banana'}], [{'name': 'banana'}]]}
assert expected == document

def test_depending_callables(self):
document = {'a': 1}
defaults = {
'c': lambda document: document['b'] + 1,
'd': lambda document: document['c'] + 1,
'b': lambda document: document['a'] + 1
}
resolve_default_values(document, defaults)
expected = {'a': 1, 'b': 2, 'c': 3, 'd': 4}
self.assertEqual(expected, document)

def test_depending_and_nested_callables(self):
document = {'nested': {'a': 1}, 'outer': 7}
defaults = {
'foo': lambda document: document['nested']['d'],
'nested': {
'c': lambda document: document['nested']['b'] + 1,
'b': lambda document: document['nested']['a'] + 1,
'd': lambda document: document['outer'] + 1
}
}
resolve_default_values(document, defaults)
expected = {'nested': {'a': 1, 'b': 2, 'c': 3, 'd': 8},
'outer': 7, 'foo': 8}
self.assertEqual(expected, document)

def test_circular_depending_callables(self):
document = {}
defaults = {
'a': lambda document: document['b'] + 1,
'b': lambda document: document['a'] + 1
}
self.assertRaises(RuntimeError, resolve_default_values, document,
defaults)

def test_callable_with_multiple_dependencies(self):
document = {'a': 1}
defaults = {
'd': lambda document: document['b'] + document['c'],
'c': lambda document: document['b'] * 2,
'b': lambda document: document['a'] + 1
}
resolve_default_values(document, defaults)
expected = {'a': 1, 'b': 2, 'c': 4, 'd': 6}
self.assertEqual(expected, document)