Commits

Andy Mikhailenko committed a2ce43e

Add tests to ensure 100% coverage of monk.manipulation. Fix some bugs. Add documentation.

Comments (0)

Files changed (6)

 The `manipulation` and `validation` functions (described below) support
 arbitrary nested structures.
 
+When this "natural" pythonic approach is not sufficient, you can mix it with
+a more verbose notation, e.g.::
+
+    title_spec = Rule(datatype=str, default='Untitled', validators=[...])
+
+There are also neat shortcuts::
+
+    spec = {
+        'url': optional(str),
+        'status': one_of(['new', 'in progress', 'closed']),
+        'blob': any_or_none,
+        'price': optional(in_range(5, 200)),
+    }
+
+By the way, the last one is translated into this one under the hood::
+
+    spec = {
+        'price': Rule(datatype=int, optional=True,
+                      validators=[monk.validators.validate_range(5, 200)]),
+    }
+
+And, yes, you can mix notations.  See FAQ.
+
+This very short intro shows that Monk requires almost **zero learning to
+start** and then provides very **powerful tools when you need them**;
+you won't have to rewrite the "intuitive" code, only augment complexity
+exactly in places where it's inevitable.
+
 Manipulation
 ............
 
 
     # default values are set for missing keys
 
-    >>> merged(spec, {})
+    >>> merge_defaults(spec, {})
     { 'title': 'Untitled',
       'comments': []
     }
 
     # it's easy to override the defaults
 
-    >>> merged(spec, {'title': 'Hello'})
+    >>> merge_defaults(spec, {'title': 'Hello'})
     { 'title': 'Hello',
       'comments': []
     }
     # nested lists of dictionaries can be auto-filled, too.
     # by the way, note the date.
 
-    >>> merged(spec, {'comments': ['author': 'john']})
+    >>> merge_defaults(spec, {'comments': ['author': 'john']})
     { 'title': 'Untitled',
       'comments': [
             { 'author': 'john',
 .. automodule:: monk.validation
    :members:
 
+.. automodule:: monk.validators
+   :members:
+
 .. automodule:: monk.manipulation
    :members:
 

monk/manipulation.py

 Data manipulation
 ~~~~~~~~~~~~~~~~~
 """
+from monk.compat import text_type
 from monk.schema import canonize
 
 
 __all__ = [
+    # functions
+    'merge_defaults', 'merged',
+    # helpers
+    'unfold_list_of_dicts', 'unfold_to_list',
+    # constants
+    'TYPE_MERGERS',
     # merger functions
-    'merge_any_value', 'merge_dict_value', 'merge_list_value',
-    # functions
-    'merge_value', 'merged',
-    # helpers
-    'unfold_list_of_dicts', 'unfold_to_list'
+    'merge_any', 'merge_dict', 'merge_list',
 ]
 
 
-def merge_any_value(spec, value):
-    """ The "any value" merger.
+# NOTE: updated at the end of file
+TYPE_MERGERS = {}
+""" The default set of type-specific value mergers:
 
-    Example::
+* ``dict`` -- :func:`merge_dict`
+* ``list`` -- :func:`merge_list`
 
-        >>> merge_any_value(None, None)
-        None
-        >>> merge_any_value(None, 123)
-        123
+"""
 
+
+def merge_any(spec, value, mergers, fallback):
+    """ Always returns the value as is.
     """
-    # there's no default value for this key, just a restriction on type
     return value
 
 
-def merge_dict_value(spec, value):
-    """ Nested dictionary.
-    Example::
+def merge_dict(spec, value, mergers, fallback):
+    """ Returns a dictionary based on `value` with each value recursively
+    merged with `spec`.
+    """
+    assert spec.datatype is dict
 
-        >>> merge_dict_value({'a': 123}, {})
-        {'a': 123}
-        >>> merge_dict_value({'a': 123}, {'a': 456})
-        {'a': 456}
+    if spec.optional and value is None:
+        return None
 
-    """
+    if spec.inner_spec is None:
+        if value is None:
+            return {}
+        else:
+            return value
+
     if value is not None and not isinstance(value, dict):
         # bogus value; will not pass validation but should be preserved
         return value
-    if spec.optional and value is None:
-        return None
-    return merged(spec.inner_spec or {}, value or {})
 
-
-def merge_list_value(spec, value):
-    """ Nested list.
-    """
-    item_spec = spec.inner_spec or None
-    item_rule = canonize(item_spec)
-
-    if not value:
-        return []
-
-    if item_rule.datatype is None:
-        # any value is accepted as list item
-        return value
-    elif item_rule.inner_spec:
-        return [merged(item_rule.inner_spec, item) for item in value]
-    else:
-        return value
-
-
-DATATYPE_MERGERS = {
-    dict: merge_dict_value,
-    list: merge_list_value,
-}
-"The default set of type-specific value mergers"
-
-
-def merge_value(spec, value, datatype_mergers,
-                fallback_merger=merge_any_value):
-    """ Returns a merged value based on given spec and data, using given
-    set of mergers.
-
-    If `value` is empty and `spec` has a default value, the default is used.
-
-    If spec datatype is present as a key in `datatype_mergers`,
-    the respective merger function is used to obtain the value.
-    If no merger is assigned to the datatype, `fallback_merger` is used.
-
-    :datatype_mergers:
-        A list of merger functions assigned to specific types of values.
-
-        A merger function should accept two arguments: `spec`
-        (a :class:`~monk.schema.Rule` instance) and `value`.
-
-    Example::
-
-        >>> merge_value({'a': 123}, {}, [merge_dict_value])
-        {'a': 123}
-        >>> merge_value({'a': 123}, {'a': 456}, [merge_dict_value])
-        {'a': 456}
-
-    """
-    rule = canonize(spec)
-
-    if value is None and rule.default is not None:
-        return rule.default
-
-    merger = datatype_mergers.get(rule.datatype, fallback_merger)
-
-    return merger(rule, value)
-
-
-def merged(spec, data, datatype_mergers=DATATYPE_MERGERS):
-    """ Returns a dictionary based on `spec` + `data`.
-
-    Does not validate values. If `data` overrides a default value, it is
-    trusted. The result can be validated later with
-    :func:`~monk.validation.validate`.
-
-    Note that a key/value pair is added from `spec` either if `data` does not
-    define this key at all, or if the value is ``None``. This behaviour may not
-    be suitable for all cases and therefore may change in the future.
-
-    You can fine-tune the process by changing the list of mergers.
-
-    :param spec:
-        `dict`. A document structure specification.
-    :param data:
-        `dict`. Overrides some or all default values from the spec.
-    :param mergers:
-        `sequence`. An ordered series of :class:`ValueMerger` subclasses.
-        Default is :attr:`VALUE_MERGERS`. The mergers are passed to
-        :func:`merge_value`.
-    """
+    data = value or {}
     result = {}
 
-    if not isinstance(data, dict):
-        raise TypeError('data must be a dictionary')
-
-    for key in set(list(spec.keys()) + list(data.keys())):
-        if key in spec:
-            value = merge_value(spec[key], data.get(key),
-                                datatype_mergers=datatype_mergers)
+    for key in set(list(spec.inner_spec.keys()) + list(data.keys())):
+        if key in spec.inner_spec:
+            value = merge_defaults(spec.inner_spec[key], data.get(key),
+                                   mergers, fallback)
         else:
             # never mind if there are nested structures: anyway we cannot check
             # them as they aren't in the spec
     return result
 
 
+def merge_list(spec, value, mergers, fallback):
+    """ Returns a list based on `value`:
+
+    * missing required value is converted to an empty list;
+    * missing required items are never created;
+    * nested items are merged recursively.
+
+    """
+    assert spec.datatype is list
+
+    item_spec = canonize(spec.inner_spec or None)
+
+    if spec.optional and value is None:
+        return None
+
+    if not value:
+        return []
+
+    if value is not None and not isinstance(value, list):
+        # bogus value; will not pass validation but should be preserved
+        return value
+
+    if item_spec.datatype is None:
+        # any value is accepted as list item
+        return value
+
+    if item_spec.inner_spec:
+        return [merge_defaults(item_spec.inner_spec, item, mergers, fallback)
+                for item in value]
+
+    return value
+
+
+def merge_defaults(spec, value, mergers=TYPE_MERGERS, fallback=merge_any):
+    """ Returns a copy of `value` recursively updated to match the `spec`:
+
+    * New values are added whenever possible (including nested ones).
+    * Existing values are never changed or removed.
+
+      * Exception: container values (lists, dictionaries) may be populated;
+        see respective merger functions for details.
+
+    The result may not pass validation against the `spec`
+    in the following cases:
+
+    a) a required value is missing and the spec does not provide defaults;
+    b) an existing value is invalid.
+
+    The business logic is as follows:
+
+    * if `value` is empty, use default value from `spec`;
+    * if `value` is present or `spec` has no default value:
+
+      * if `spec` datatype is present as a key in `mergers`,
+        use the respective merger function to obtain the value;
+      * if no merger is assigned to the datatype, use `fallback` function.
+
+    See documentation on concrete merger functions for further details.
+
+    :spec:
+        A "natural" or "verbose" spec.
+
+    :value:
+        The value to merge into the `spec`.
+
+    :mergers:
+        An dictionary of merger functions assigned to specific types
+        of values (sort of `{int: integer_merger_func}`).
+
+        A merger function should accept the same arguments as this function,
+        only with `spec` always being a :class:`~monk.schema.Rule` instance.
+
+        Default: :attr:`TYPE_MERGERS`.
+
+    :fallback:
+        A merger function to use when no datatype-specific merger is found.
+
+        Default: :func:`merge_any`
+
+    Examples (with standard mergers)::
+
+        >>> merge_defaults('foo', None)
+        'foo'
+        >>> merge_defaults('foo', 'bar')
+        'bar'
+        >>> merge_defaults({'a': 'foo'}, {})
+        {'a': 'foo'}
+        >>> merge_defaults({'a': [{'b': 123}]},
+        ...                {'a': [{'b': None},
+        ...                       {'x': 0}]})
+        {'a': [{'b': 123}, {'b': 123, 'x': 0}]}
+
+    """
+    rule = canonize(spec)
+
+    if value is None and rule.default is not None:
+        return rule.default
+
+    merger = mergers.get(rule.datatype, fallback)
+
+    return merger(rule, value, mergers=mergers, fallback=fallback)
+
+
+def merged(spec, data, mergers=TYPE_MERGERS):
+    """
+    .. deprecated:: 0.10.0
+
+       Use :func:`merge_defaults` instead.
+    """
+    import warnings
+    warnings.warn('merged() is deprecated, use merge_defaults() instead',
+                  DeprecationWarning)
+
+    return merge_defaults(spec, data, mergers=mergers)
+
+
 def unfold_list_of_dicts(value, default_key):
     """
     Converts given value to a list of dictionaries as follows:
         return []
     if isinstance(value, dict):
         return [value]
-    if isinstance(value, unicode):
+    if isinstance(value, text_type):
         return [{default_key: value}]
     if isinstance(value, list):
         if not all(isinstance(x, dict) for x in value):
             def _fix(x):
-                return {default_key: x} if isinstance(x, unicode) else x
-            return map(_fix, value)
+                return {default_key: x} if isinstance(x, text_type) else x
+            return list(map(_fix, value))
     return value
 
 
         return [value]
     else:
         return value
+
+
+TYPE_MERGERS.update({
+    dict: merge_dict,
+    list: merge_list,
+})
 __all__ = [
     'Rule', 'canonize',
     # shortcuts:
-    'any_value', 'any_or_none', 'optional'
+    'any_value', 'any_or_none', 'optional', 'in_range', 'one_of'
 ]
 
 
 
 def one_of(choices, first_is_default=False):
     """
-    A shortcut::
+    A shortcut for a rule with :func:`~monk.validators.validate_choice` validator.
+    ::
 
         choices = ['foo', 'bar']
 
 
 def in_range(start, stop, first_is_default=False):
     """
-    A shortcut::
+    A shortcut for a rule with :func:`~monk.validators.validate_range` validator.
+    ::
 
         # these expressions are equal:
 

monk/validators.py

 
 
 def validate_choice(choices):
+    """
+    Checks whether the value belongs to given set of choices
+    ::
+
+        >>> check = validate_choice(['a', 'c'])
+        >>> check('a')
+        >>> check('b')
+        Traceback (most recent call last):
+        ...
+        monk.errors.ValidationError: expected one of ['a', 'c'], got 'b'
+
+    """
     def _validate_choice(value):
         if value not in choices:
             raise ValidationError('expected one of {0}, got {1!r}'.format(choices, value))
 
 
 def validate_range(start, stop):
+    """
+    Checks whether the numeric value belongs to given range
+    ::
+
+        >>> check = validate_range(0,1)
+        >>> check(0)
+        >>> check(1)
+        >>> check(2)
+        Traceback (most recent call last):
+        ...
+        monk.errors.ValidationError: expected value in range 0..1, got 2
+
+    """
     def _validate_range(value):
         if not start <= value <= stop:
             raise ValidationError('expected value in range {0}..{1}, got {2!r}'.format(start, stop, value))
 
 
 def validate_length(expected):
+    """
+    Checks whether the value is of given length
+    ::
+
+        >>> check = validate_length(2)
+        >>> check('ab')
+        >>> check([1, 2])
+        >>> check('abc')
+        Traceback (most recent call last):
+        ...
+        monk.errors.ValidationError: expected value of length 2, got 'abc'
+
+    """
     def _validate_length(value):
         if len(value) != expected:
             raise ValidationError('expected value of length {0}, got {1!r}'.format(expected, value))

unittests/test_manipulation.py

 Data manipulation tests
 =======================
 """
+import mock
 import pytest
 
 from monk.compat import text_type as t
 from monk.schema import Rule, optional
-from monk.manipulation import merged
+import monk.manipulation as m
 
 
-class TestDocumentDefaults:
+class TestMergingDefaults:
+    "Basic behaviour of merge_defaults()"
+
+    def test_defaults_from_rule(self):
+        "Default value from rule"
+        rule = Rule(int, default=1)
+        assert m.merge_defaults(rule, None) == 1
+        assert m.merge_defaults(rule, 2) == 2
+
+    def test_type_merger(self):
+        "No default value → datatype-specific merger"
+        type_merger = mock.Mock()
+        fallback = mock.Mock()
+
+        m.merge_defaults(
+            Rule(int), 1, {int: type_merger}, fallback)
+
+        type_merger.assert_called_once_with(
+            Rule(int), 1, mergers={int: type_merger}, fallback=fallback)
+        fallback.assert_not_called()
+
+    def test_fallback(self):
+        "No datatype-specific merger → fallback merger"
+        type_merger = mock.Mock()
+        fallback = mock.Mock()
+
+        m.merge_defaults(
+            Rule(int), 1, {str: type_merger}, fallback)
+
+        type_merger.assert_not_called()
+        fallback.assert_called_once_with(
+            Rule(int), 1, mergers={str: type_merger}, fallback=fallback)
+
+    def test_deprecation(self):
+        "Deprecated function wraps the new one"
+        with mock.patch('monk.manipulation.merge_defaults') as new_func:
+            with mock.patch('warnings.warn') as warn:
+                new_func.return_value = 'returned'
+                m.merged('spec', 'value', 'mergers') == 'returned'
+                new_func.assert_called_once_with('spec', 'value', mergers='mergers')
+                warn.assert_called_once_with(
+                    'merged() is deprecated, use merge_defaults() instead',
+                    DeprecationWarning)
+
+
+class TestMergingDefaultsTypeSpecific:
+    "Type-specific mergers"
+
+    def test_default_type_mergers(self):
+
+        assert m.TYPE_MERGERS == {
+            dict: m.merge_dict,
+            list: m.merge_list,
+        }
+
+    def test_merge_any(self):
+
+        # datatype None stands for "any value is OK"
+        rule = Rule(datatype=None)
+
+        assert m.merge_any(rule, None, {}, None) == None
+        assert m.merge_any(rule, 1234, {}, None) == 1234
+        assert m.merge_any(rule, 'hi', {}, None) == 'hi'
+        assert m.merge_any(rule, {1:2}, {}, None) == {1:2}
+
+    def test_merge_dict(self):
+
+        with pytest.raises(AssertionError):
+            # function should check for rule datatype
+            m.merge_dict(Rule(datatype=int), None, {}, None)
+
+    def test_merge_dict_type(self):
+
+        rule = Rule(datatype=dict, optional=True)
+
+        # optional missing dictionary
+        assert m.merge_dict(rule, None, {}, None) == None
+
+        rule = Rule(datatype=dict)
+
+        # required missing dictionary → empty dictionary
+        assert m.merge_dict(rule, None, {}, None) == {}
+
+        # required empty dictionary
+        assert m.merge_dict(rule, {}, {}, None) == {}
+
+        # required non-empty dictionary
+        assert m.merge_dict(rule, {'x': 1}, {}, None) == {'x': 1}
+
+    def test_merge_dict_innerspec(self):
+
+        ## present but empty inner spec (optional)
+        rule = Rule(datatype=dict, inner_spec={}, optional=True)
+
+        # optional missing dictionary
+        assert m.merge_dict(rule, None, {}, None) == None
+
+        ## present but empty inner spec (required)
+        rule = Rule(datatype=dict, inner_spec={})
+
+        # required missing dictionary → empty dictionary
+        assert m.merge_dict(rule, None, {}, None) == {}
+
+        ## present non-empty inner spec (optional)
+        rule = Rule(datatype=dict, inner_spec={'a': 1}, optional=True)
+
+        # optional missing dictionary with required key
+        assert m.merge_dict(rule, None, {}, None) == None
+
+        # optional empty dictionary with required key
+        assert m.merge_dict(rule, {}, {}, None) == {'a': 1}
+
+        ## present non-empty inner spec (optional)
+        rule = Rule(datatype=dict, inner_spec={'a': optional(1)},
+                    optional=True)
+
+        # optional missing dictionary with optional key
+        assert m.merge_dict(rule, None, {}, None) == None
+
+        # optional empty dictionary with optional key
+        assert m.merge_dict(rule, {}, {}, None) == {'a': 1}
+
+        ## present non-empty inner spec (required)
+        rule = Rule(datatype=dict, inner_spec={'a': 1})
+
+        # required missing dictionary → inner spec
+        assert m.merge_dict(rule, None, {}, None) == {'a': 1}
+
+        # required empty dictionary → inner spec
+        assert m.merge_dict(rule, {}, {}, None) == {'a': 1}
+
+        # required non-empty dictionary → inner spec
+        fallback = lambda s, v, **kw: v
+        assert m.merge_dict(rule, {'a': 2}, {}, fallback) == {'a': 2}
+        assert m.merge_dict(rule, {'b': 3}, {}, fallback) == {'a': 1, 'b': 3}
+
+        # bogus value; will not pass validation but should be preserved
+        assert m.merge_dict(rule, 123, {}, None) == 123
+
+    def test_merge_list(self):
+
+        ## present but empty inner spec (optional)
+        rule = Rule(datatype=list, inner_spec=[], optional=True)
+
+        # optional missing list
+        assert m.merge_list(rule, None, {}, None) == None
+
+        ## present but empty inner spec (required)
+        rule = Rule(datatype=list, inner_spec=[])
+
+        # required missing list → empty list
+        assert m.merge_list(rule, None, {}, None) == []
+
+        ## present non-empty inner spec (optional)
+        rule = Rule(datatype=list, inner_spec=123, optional=True)
+
+        # optional missing list with required item(s)
+        assert m.merge_list(rule, None, {}, None) == None
+
+        # optional empty list with required item(s)
+        assert m.merge_list(rule, [], {}, None) == []
+
+        ## present non-empty inner spec (optional)
+        rule = Rule(datatype=list, inner_spec=[optional(1)],
+                    optional=True)
+
+        # optional missing list with optional item(s)
+        assert m.merge_list(rule, None, {}, None) == None
+
+        # optional empty list with optional item
+        assert m.merge_list(rule, [], {}, None) == []
+
+        ## present non-empty inner spec (required)
+        rule = Rule(datatype=list, inner_spec=123)
+
+        # required missing list → inner spec
+        assert m.merge_list(rule, None, {}, None) == []
+
+        # required empty list → inner spec
+        assert m.merge_list(rule, [], {}, None) == []
+
+        # required non-empty list → inner spec
+        fallback = lambda s, v, **kw: v
+        assert m.merge_list(rule, [None], {}, fallback) == [None]
+        assert m.merge_list(rule, [456], {}, fallback) == [456]
+
+        ## present inner spec with empty item spec
+        rule = Rule(datatype=list, inner_spec=None)
+
+        assert m.merge_list(rule, [456], {}, fallback) == [456]
+
+        ## present inner spec with item spec that has an inner spec
+        rule = Rule(datatype=list, inner_spec=[123])
+
+        assert m.merge_list(rule, [None], {}, fallback) == [123]
+
+        # bogus value; will not pass validation but should be preserved
+        assert m.merge_list(rule, 123, {}, None) == 123
+
+
+class TestMergingDefaultsNaturalNotation:
+    """
+    Tests for :func:`merge_defaults` with "natural" notation
+    and more or less complex (and random) cases.
+
+    These were written much earlier than :class:`TestMergingDefaults`
+    and may be outdated in terms of organization.
+    """
+
     def test_merge(self):
-        assert {'a': 1, 'b': 2} == merged({'a': 1}, {'b': 2})
+        assert {'a': 1, 'b': 2} == m.merge_defaults({'a': 1}, {'b': 2})
 
     def test_none(self):
-        assert {'a': None} == merged({'a': None}, {})
-        assert {'a': None} == merged({'a': None}, {'a': None})
-        assert {'a': 1234} == merged({'a': None}, {'a': 1234})
+        assert {'a': None} == m.merge_defaults({'a': None}, {})
+        assert {'a': None} == m.merge_defaults({'a': None}, {'a': None})
+        assert {'a': 1234} == m.merge_defaults({'a': None}, {'a': 1234})
 
     def test_type(self):
-        assert {'a': None} == merged({'a': t}, {})
-        assert {'a': None} == merged({'a': t}, {'a': None})
-        assert {'a': t('a')} == merged({'a': t}, {'a': t('a')})
+        assert {'a': None} == m.merge_defaults({'a': t}, {})
+        assert {'a': None} == m.merge_defaults({'a': t}, {'a': None})
+        assert {'a': t('a')} == m.merge_defaults({'a': t}, {'a': t('a')})
 
     def test_type_in_dict(self):
         spec = {'a': {'b': int}}
 
         # key is absent; should be inserted
-        assert {'a': {'b': None}} == merged(spec, {})
+        assert {'a': {'b': None}} == m.merge_defaults(spec, {})
         # same with nested key
-        assert {'a': {'b': None}} == merged(spec, {'a': {}})
+        assert {'a': {'b': None}} == m.merge_defaults(spec, {'a': {}})
 
         # key is present but value is None; should be overridden with defaults
         #
         #   XXX do we really need to override *present* values in data
         #       even if they are None?
         #
-        assert {'a': {'b': None}} == merged(spec, {'a': None})
-        assert {'a': {'b': None}} == merged(spec, {'a': {'b': None}})
+        assert {'a': {'b': None}} == m.merge_defaults(spec, {'a': None})
+        assert {'a': {'b': None}} == m.merge_defaults(spec, {'a': {'b': None}})
 
         # key is present, value is not None; leave as is
         # (even if it won't pass validation)
-        assert {'a': {'b': 1234}} == merged(spec, {'a': {'b': 1234}})
-        assert {'a': t('bogus string')} == merged(spec, {'a': t('bogus string')})
+        assert {'a': {'b': 1234}} == m.merge_defaults(spec, {'a': {'b': 1234}})
+        assert {'a': t('bogus string')} == m.merge_defaults(spec, {'a': t('bogus string')})
 
     def test_type_in_list(self):
-        assert {'a': []} == merged({'a': [int]}, {'a': []})
-        assert {'a': [123]} == merged({'a': [int]}, {'a': [123]})
-        assert {'a': [123, 456]} == merged({'a': [int]}, {'a': [123, 456]})
+        assert {'a': []} == m.merge_defaults({'a': [int]}, {'a': []})
+        assert {'a': [123]} == m.merge_defaults({'a': [int]}, {'a': [123]})
+        assert {'a': [123, 456]} == m.merge_defaults({'a': [int]}, {'a': [123, 456]})
 
     def test_rule_in_list(self):
-        assert {'a': []} == merged({'a': [Rule(datatype=int)]}, {'a': []})
-        assert {'a': []} == merged({'a': [Rule(datatype=int)]}, {'a': None})
-        assert {'a': []} == merged({'a': [Rule(datatype=int)]}, {})
+        assert {'a': []} == m.merge_defaults({'a': [Rule(datatype=int)]}, {'a': []})
+        assert {'a': []} == m.merge_defaults({'a': [Rule(datatype=int)]}, {'a': None})
+        assert {'a': []} == m.merge_defaults({'a': [Rule(datatype=int)]}, {})
 
     def test_instance(self):
-        assert {'a': 1} == merged({'a': 1}, {})
+        assert {'a': 1} == m.merge_defaults({'a': 1}, {})
 
     def test_instance_in_dict(self):
-        assert {'a': {'b': 1}} == merged({'a': {'b': 1}}, {})
+        assert {'a': {'b': 1}} == m.merge_defaults({'a': {'b': 1}}, {})
 
     def test_instance_in_list(self):
-        assert {'a': [1]} == merged({}, {'a': [1]})
-        assert {'a': [1]} == merged({'a': []}, {'a': [1]})
-        assert {'a': [0]} == merged({'a': [0]}, {'a': [0]})
-        assert {'a': [0, 1]} == merged({'a': [0]}, {'a': [0, 1]})
+        assert {'a': [1]} == m.merge_defaults({}, {'a': [1]})
+        assert {'a': [1]} == m.merge_defaults({'a': []}, {'a': [1]})
+        assert {'a': [0]} == m.merge_defaults({'a': [0]}, {'a': [0]})
+        assert {'a': [0, 1]} == m.merge_defaults({'a': [0]}, {'a': [0, 1]})
 
     def test_instance_in_list_of_dicts(self):
         spec = {'a': [{'b': 1}]}
-        assert {'a': []} == merged(spec, {})
-        assert {'a': []} == merged(spec, {'a': []})
-        assert {'a': [{'b': 1}]} == merged(spec, {'a': [{}]})
-        assert {'a': [{'b': 0}]} == merged(spec, {'a': [{'b': 0}]})
+        assert {'a': []} == m.merge_defaults(spec, {})
+        assert {'a': []} == m.merge_defaults(spec, {'a': []})
+        assert {'a': [{'b': 1}]} == m.merge_defaults(spec, {'a': [{}]})
+        assert {'a': [{'b': 0}]} == m.merge_defaults(spec, {'a': [{'b': 0}]})
 
     def test_complex_list_of_dicts(self):
         "some items are populated, some aren't"
                 {'b': 2, 'c': {'d': 1}}
             ]
         }
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
     def test_custom_structures(self):
         "custom keys should not be lost even if they are not in spec"
         data = {'a': [{'b': {'c': 123}}]}
-        assert data == merged({}, data)
+        assert data == m.merge_defaults({}, data)
 
     def test_unexpected_dict(self):
         """ Non-dictionary in spec, dict in data.
         Data is preserved though won't validate.
         """
-        assert {'a': {'b': 123}} == merged({'a': t}, {'a': {'b': 123}})
+        assert {'a': {'b': 123}} == m.merge_defaults({'a': t}, {'a': {'b': 123}})
 
     def test_unexpected_list(self):
         """ Non-list in spec, list in data.
         Data is preserved though won't validate.
         """
-        assert {'a': [123]} == merged({'a': t}, {'a': [123]})
+        assert {'a': [123]} == m.merge_defaults({'a': t}, {'a': [123]})
 
     def test_callable(self):
         """ Callable defaults.
         spec = {'text': lambda: t('hello')}
         data = {}
         expected = {'text': t('hello')}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
     def test_callable_nested(self):
         """ Nested callable defaults.
         spec = {'content': {'text': lambda: t('hello')}}
         data = {}
         expected = {'content': {'text': t('hello')}}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
     def test_rule_merger(self):
         spec = {'foo': Rule(str, default='bar')}
         data = {}
         expected = {'foo': 'bar'}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
     def test_required_inside_optional_dict(self):
         spec = {'foo': optional({'a': 1, 'b': optional(2)})}
 
         data = {}
         expected = {'foo': None}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
         data = {'foo': None}
         expected = {'foo': None}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
         data = {'foo': {}}
         expected = {'foo': {'a': 1, 'b': 2}}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
         data = {'foo': {'a': 3}}
         expected = {'foo': {'a': 3, 'b': 2}}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
 
         data = {'foo': {'b': 3}}
         expected = {'foo': {'a': 1, 'b': 3}}
-        assert merged(spec, data) == expected
+        assert m.merge_defaults(spec, data) == expected
+
+
+class TestUnfolding:
+    def test_unfold_to_list(self):
+        assert [1] == m.unfold_to_list(1)
+        assert [1] == m.unfold_to_list([1])
+
+    def test_unfold_list_of_dicts(self):
+        assert [{'x': 'a'}] == m.unfold_list_of_dicts([{'x': 'a'}], default_key='x')
+        assert [{'x': 'a'}] == m.unfold_list_of_dicts( {'x': 'a'}, default_key='x')
+        assert [{'x': 'a'}] == m.unfold_list_of_dicts(       'a', default_key='x')
+        assert [{'x': 'a'}, {'x': 'b'}] == \
+            m.unfold_list_of_dicts([{'x': 'a'}, 'b'], default_key='x')
+
+        # edge cases (may need revision)
+        assert [{'x': 1}] == m.unfold_list_of_dicts({'x': 1}, default_key='y')
+        assert [] == m.unfold_list_of_dicts(None, default_key='y')
+        assert 123 == m.unfold_list_of_dicts(123, default_key='x')
+