Commits

Andy Mikhailenko committed 4c368b2 Merge

Merge branch layered-specs into default

  • Participants
  • Parent commits 3c1c49c, a3a89a4

Comments (0)

Files changed (16)

+py.test --cov monk --cov-report term --cov-report html "$@" \
+    && uzbl-browser htmlcov/index.html
 API reference
 =============
 
+.. automodule:: monk.schema
+   :members:
+
 .. automodule:: monk.validation
    :members:
 

docs/glossary.rst

+Glossary
+~~~~~~~~
+
+.. glossary::
+
+   natural spec
+       Readable and DRY representation of document specification.
+       Must be converted to :term:`detailed spec` before validation.
+
+   detailed spec
+       Rule-based verbose representation of document specification.
    :maxdepth: 2
 
    api
+   glossary
 
 Indices and tables
 ------------------
 .. _pymongo: http://api.mongodb.org/python/current/
 
 """
-__version__ = '0.6.0'
+__version__ = '0.7.0'
 # remember to also update:
 #
 # * PKGBUILD
+# coding: utf-8
+#
+#    Monk is an unobtrusive data modeling, manipulation and validation library.
+#    Copyright © 2011—2013  Andrey Mikhaylenko
+#
+#    This file is part of Monk.
+#
+#    Monk is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Lesser General Public License as published
+#    by the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    Monk is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public License
+#    along with Monk.  If not, see <http://gnu.org/licenses/>.
+"""
+Exceptions
+~~~~~~~~~~
+"""
+class ValidationError(Exception):
+    "Raised when a document or its part cannot pass validation."
+
+
+class StructureSpecificationError(ValidationError):
+    "Raised when malformed document structure is detected."
+
+
+class MissingValue(ValidationError):
+    """ Raised when the value is `None` and the rule neither allows this
+    (i.e. a `datatype` is defined) nor provides a `default` value.
+    """
+
+
+class MissingKey(ValidationError):
+    """ Raised when a dictionary key is defined in :attr:`Rule.inner_spec`
+    but is missing from the value.
+    """
+
+
+class UnknownKey(ValidationError):
+    """ Raised whan the value dictionary contains a key which is not
+    in the dictionary's :attr:`Rule.inner_spec`.
+    """

monk/manipulation.py

 
     Default sequence of mergers:
 
+    * :class:`RuleMerger`
     * :class:`TypeMerger`
     * :class:`DictMerger`
     * :class:`ListMerger`
 
 """
 from monk import compat
+from monk.schema import Rule
 
 
 __all__ = [
     # mergers
     'ValueMerger', 'TypeMerger', 'DictMerger', 'ListMerger', 'FuncMerger',
-    'AnyMerger',
+    'AnyMerger', 'RuleMerger',
     # functions
     'merge_value', 'merged',
     # helpers
         raise NotImplementedError  # pragma: nocover
 
 
+class RuleMerger(ValueMerger):
+    """ Rule. Uses defaults, if any.
+    Example::
+
+        >>> TypeMerger(int, None).process()
+        None
+        >>> TypeMerger(int, 123).process()
+        123
+
+    """
+    def check(self):
+        return isinstance(self.spec, Rule)
+
+    def process(self):
+        if self.value is None:
+            return self.spec.default
+        else:
+            return self.value
+
+
 class TypeMerger(ValueMerger):
     """ Type definition. Preserves empty values.
     Example::
             return self.value
 
 
-VALUE_MERGERS = TypeMerger, DictMerger, ListMerger, FuncMerger, AnyMerger
+VALUE_MERGERS = RuleMerger, TypeMerger, DictMerger, ListMerger, FuncMerger, AnyMerger
 
 
 def merge_value(spec, value, mergers):
 
     Does not validate values. If `data` overrides a default value, it is
     trusted. The result can be validated later with
-    :func:`~monk.validation.validate_structure`.
+    :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
     .. attribute:: structure
 
         The document structure specification. For details see
-        :func:`monk.validation.validate_structure_spec` and
-        :func:`monk.validation.validate_structure`.
+        :func:`monk.validation.validate`.
 
     """
     structure = {}
         for key, value in with_defaults.items():
             self[key] = value
 
-    def _validate_structure_spec(self):
-        validation.validate_structure_spec(self.structure)
-
     def validate(self):
-        validation.validate_structure(self.structure, self)
+        validation.validate(self.structure, self)
     """
     def __init__(self, *args, **kwargs):
         super(Document, self).__init__(*args, **kwargs)
-# TODO
-#        self._validate_structure_spec()
         self._insert_defaults()
         self._make_dot_expanded()
 
+# -*- coding: utf-8 -*-
+#
+#    Monk is an unobtrusive data modeling, manipulation and validation library.
+#    Copyright © 2011—2013  Andrey Mikhaylenko
+#
+#    This file is part of Monk.
+#
+#    Monk is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Lesser General Public License as published
+#    by the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    Monk is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public License
+#    along with Monk.  If not, see <http://gnu.org/licenses/>.
+"""
+Schema Definition
+~~~~~~~~~~~~~~~~~
+"""
+from . import compat, errors
+
+
+__all__ = [
+    'Rule', 'canonize',
+    # shortcuts:
+    'any_value', 'any_or_none', 'optional'
+]
+
+
+#-------------------------------------------
+# Classes
+#
+
+class Rule:
+    """
+    Extended specification of a field.  Allows marking it as optional.
+
+    :param datatype:
+        one of:
+
+        * a type/class (stands for "an instance of this type/class")
+        * `None` (stands for "any value of any kind or no value at all")
+
+    :param default:
+        an instance of `datatype` to be used in the absence of a value.
+
+    :param inner_spec:
+        a spec for nested data (can be another :class:`Rule` instance
+        or something that :func:`canonize` can convert to one).
+
+    :param optional:
+        ``bool``; if ``True``, :class:`~monk.validation.MissingValue`
+        is never raised.  Default is ``False``.
+
+    :param dict_skip_unknown_keys:
+        ``bool``; if ``True``, :class:`~monk.validation.UnknownKey`
+        is never raised.  Default is ``False``.
+
+    .. note:: Missing Value vs. Missing Key vs. Unknown Key
+
+       :MissingValue:
+            Applies: to any value on any level.
+
+            Trigger: the value is `None` and the rule neither allows this
+            (i.e. a `datatype` is defined) nor provides a `default` value.
+
+            Suppress: turn the `optional` setting on.
+            This allows the value to be `None` even if a `datatype` is defined.
+
+       :MissingKey:
+            Applies: to a dictionary key.
+
+            Trigger: a key is missing from the dictionary.
+
+            Suppress: turn the `optional` setting on.
+            This allows the key to be completely missing from an outer dictionary.
+
+       :UnknownKey:
+            Applies: to a dictionary key.
+
+            Trigger: the dictionary contains a key which is not in
+            the dictionary's `inner_spec`.
+
+            Suppress: turn the `dict_skip_unknown_keys` setting on
+            (of course on dictionary level).
+
+    """
+    def __init__(self, datatype, inner_spec=None, optional=False, dict_skip_unknown_keys=False, default=None):
+        if isinstance(datatype, type(self)):
+            raise ValueError('Cannot use a Rule instance as datatype')
+        self.datatype = datatype
+        self.inner_spec = inner_spec
+        self.optional = optional
+        self.dict_skip_unknown_keys = dict_skip_unknown_keys
+        self.default = default
+
+        # sanity checks
+
+        if default is not None and self.datatype and not isinstance(default, self.datatype):
+            raise TypeError('Default value must match datatype {0} (got {1})'.format(
+                self.datatype.__name__, default))
+
+#        if self.inner_spec and not isinstance(self.inner_spec, self.datatype):
+#            raise TypeError('Inner spec must match datatype {0} (got {1})'.format(
+#                self.datatype.__name__, inner_spec))
+
+    def __repr__(self):
+        return '<Rule {datatype}{optional}{default}{inner_spec}{skip_unknown_keys}>'.format(
+            datatype=('any' if self.datatype is None else
+                self.datatype.__name__),
+            optional=(' optional' if self.optional else ' required'),
+            skip_unknown_keys=(' dict:skip-unknown-keys' if self.dict_skip_unknown_keys else ''),
+            default=(' default={0}'.format(self.default)
+                     if self.default is not None else ''),
+            inner_spec=(' inner_spec={0}'.format(self.inner_spec)
+                     if self.inner_spec is not None else ''))
+
+    def __eq__(self, other):
+        if (isinstance(other, type(self)) and self.__dict__ == other.__dict__):
+            return True
+
+
+#-------------------------------------------
+# Functions
+#
+
+def canonize(spec, rule_kwargs={}):
+    """
+    Returns the canonic representation of given natural spec.
+
+    :param spec: :term:`natural spec` (a `dict`)
+
+    :return: :term:`detailed spec` (`dict` with :class:`Rule` instances as values)
+    """
+    value = spec
+
+    if isinstance(value, Rule):
+        rule = value
+    elif value is None:
+        rule = Rule(None, **rule_kwargs)
+    elif isinstance(value, type):
+        rule = Rule(value, **rule_kwargs)
+    elif type(value) in compat.func_types:
+        real_value = value()
+        kwargs = dict(rule_kwargs, default=real_value)
+        rule = Rule(datatype=type(real_value), **kwargs)
+    elif isinstance(value, list):
+        if value == []:
+            # no inner spec, just an empty list as the default value
+            kwargs = dict(rule_kwargs, default=value)
+            rule = Rule(datatype=list, **kwargs)
+        elif len(value) == 1:
+            # the only item as spec for each item of the collection
+            kwargs = dict(rule_kwargs, inner_spec=value[0])
+            rule = Rule(datatype=list, **kwargs)
+        else:
+            raise errors.StructureSpecificationError(
+                'Expected a list containing exactly 1 item; '
+                'got {cnt}: {spec}'.format(cnt=len(value), spec=value))
+    elif isinstance(value, dict):
+        kwargs = dict(rule_kwargs, inner_spec=value)
+        rule = Rule(datatype=dict, **kwargs)
+    else:
+        kwargs = dict(rule_kwargs, default=value)
+        rule = Rule(datatype=type(value), **kwargs)
+
+    return rule
+
+
+#-------------------------------------------
+# Shortcuts
+#
+
+def optional(spec):
+    """
+    Returns a canonized `spec` marked as optional.
+    ::
+
+        >>> optional(str) == Rule(datatype=str, optional=True)
+        True
+
+    """
+    if isinstance(spec, Rule):
+        spec.optional = True
+        return spec
+    else:
+        return canonize(spec, rule_kwargs={'optional': True})
+
+
+any_value = Rule(None)
+"A shortcut for ``Rule(None)``"
+
+
+any_or_none = Rule(None, optional=True)
+"A shortcut for ``Rule(None, optional=True)"

monk/validation.py

 #    along with Monk.  If not, see <http://gnu.org/licenses/>.
 """
 Validation
-==========
-
-.. attribute:: VALUE_VALIDATORS
-
-    Default sequence of validators:
-
-    * :class:`DictValidator`
-    * :class:`ListValidator`
-    * :class:`TypeValidator`
-    * :class:`FuncValidator`
-    * :class:`InstanceValidator`
-
+~~~~~~~~~~
 """
-# TODO yield/return subdocuments (spec and value) for external processing so
-#      that we don't pass validators/skip_missing/skip_unknown recursively to
-#      each validator.
-
 from . import compat
-from .manipulation import merged
-
+from .schema import canonize
+from . import errors
 
 __all__ = [
-    # errors
-    'ValidationError', 'StructureSpecificationError', 'MissingKey',
-    'UnknownKey',
-    # validators
-    'ValueValidator', 'DictValidator', 'ListValidator', 'TypeValidator',
-    'InstanceValidator', 'FuncValidator',
     # functions
-    'validate_structure_spec', 'validate_structure', 'validate_value',
-    # helpers
-    'Rule', 'optional'
+    'validate'
 ]
 
 
-class ValidationError(Exception):
-    "Raised when a document or its part cannot pass validation."
 
-
-class StructureSpecificationError(ValidationError):
-    "Raised when malformed document structure is detected."
-
-
-class MissingKey(ValidationError):
-    """ Raised when a key is defined in the structure spec but is missing from
-    a data dictionary.
-    """
-
-
-class UnknownKey(ValidationError):
-    """ Raised when a key in data dictionary is missing from the corresponding
-    structure spec.
-    """
-
-
-class ValueValidator(object):
-    """ Base class for value validators.
-    """
-    def __init__(self, spec, value, skip_missing=False, skip_unknown=False,
-                 value_preprocessor=None):
-        self.spec = spec
-        self.value = value
-        self.skip_missing = skip_missing
-        self.skip_unknown = skip_unknown
-        self.value_preprocessor = value_preprocessor
-
-
-    def check(self):
-        """ Returns ``True`` if this validator can handle given spec/value
-        pair, otherwise returns ``False``.
-
-        Subclasses must overload this method.
-        """
-        raise NotImplementedError
-
-    def validate(self):
-        """ Returns ``None`` if `self.value` is valid for `self.spec` or raises
-        a :class:`ValidationError`.
-
-        Subclasses must overload this method.
-        """
-        raise NotImplementedError
-
-
-class DictValidator(ValueValidator):
+def validate_dict(rule, value):
     """ Nested dictionary. May contain complex structures which are validated
     recursively.
 
     The specification can be any dictionary, whether empty or not. It will be
     treated as a separate document.
     """
-    def check(self):
-        return isinstance(self.spec, dict)
+    validate_type(rule, value)
 
-    def validate(self):
-        if not isinstance(self.value, dict):
-            raise TypeError('expected {spec.__name__}, got '
-                            '{valtype.__name__} {value!r}'.format(
-                            spec=dict, valtype=type(self.value),
-                            value=self.value))
+    if not rule.inner_spec:
+        # spec is {} which means "a dict of anything"
+        return
 
-        if not self.spec:
-            # spec is {} which means "a dict of anything"
-            return
+    spec_keys = set(rule.inner_spec.keys() if rule.inner_spec else [])
+    data_keys = set(value.keys() if value else [])
+    unknown = data_keys - spec_keys
+    #missing = spec_keys - data_keys
 
-        # validate value as a separate document
-        validate_structure(self.spec, self.value,
-                           skip_missing=self.skip_missing,
-                           skip_unknown=self.skip_unknown,
-                           value_preprocessor=self.value_preprocessor)
+    if unknown and not rule.dict_skip_unknown_keys:
+        raise errors.UnknownKey('Unknown keys: {0}'.format(
+            ', '.join(compat.safe_str(x) for x in unknown)))
 
+    for key in spec_keys | data_keys:
+        subrule = canonize(rule.inner_spec.get(key))
+        if key in data_keys:
+            value_ = value.get(key)
+            try:
+                validate(subrule, value_)
+            except (errors.ValidationError, TypeError) as e:
+                raise type(e)('{k}: {e}'.format(k=key, e=e))
+        else:
+            if subrule.optional:
+                continue
+            raise errors.MissingKey('{0}'.format(key))
 
 
-class ListValidator(ValueValidator):
+def validate_list(rule, value):
     """ Nested list. May contain complex structures which are validated
     recursively.
 
     The specification can be either an empty list::
 
-        >>> ListValidator([], [123]).validate()
+        >>> validate_list(Rule(list, inner_spec=[]), [123])
 
     ...or a list with exactly one item::
 
-        >>> ListValidator([int], [123, 456]).validate()
-        >>> ListValidator([{'foo': int], [{'foo': 123}]).validate()
+        >>> validate_list(Rule(list, inner_spec=[int]), [123, 456])
+        >>> validate_list(Rule(list, inner_spec=[{'foo': int]), [{'foo': 123}])
 
     """
-    def check(self):
-        return isinstance(self.spec, list)
+    if not isinstance(value, list):
+        raise TypeError('expected {spec.__name__}, got '
+                        '{valtype.__name__} {value!r}'.format(
+                        spec=list, valtype=type(value),
+                        value=value))
 
-    def validate(self):
-        if not isinstance(self.value, list):
-            raise TypeError('expected {spec.__name__}, got '
-                            '{valtype.__name__} {value!r}'.format(
-                            spec=list, valtype=type(self.value),
-                            value=self.value))
+    if not rule.inner_spec:
+        # spec is [] which means "a list of anything"
+        return
 
-        if 1 < len(self.spec):
-            raise StructureSpecificationError(
-                'Expected an empty list or a list containing exactly 1 item; '
-                'got {cnt}: {spec}'.format(cnt=len(self.spec), spec=self.spec))
+    item_spec = canonize(rule.inner_spec)
+    assert item_spec
+    print('canonize', rule.inner_spec, '->', item_spec)
 
-        if not self.spec:
-            # spec is [] which means "a list of anything"
-            return
+    # XXX custom validation stuff can be inserted here, e.g. min/max items
 
-        item_spec = self.spec[0]
+    print('value:', value)
+    for i, item in enumerate(value):
+        print('  item #', i,  'spec:', item_spec, 'item:', item)
+        try:
+            validate(item_spec, item)
+        except (errors.ValidationError, TypeError) as e:
+            raise type(e)('#{i}: {e}'.format(i=i, e=e))
 
-        for item in self.value:
-            if item_spec == dict or isinstance(item, dict):
 
-                # value is a dict; expected something else
-                if isinstance(item, dict) and not (
-                    isinstance(item_spec, dict) or item_spec == dict):
-                    raise TypeError('expected {spec}, got a dictionary'.format(
-                        spec=item_spec))
-
-                # validate each value in the list as a separate document
-                validate_structure(item_spec, item,
-                                   skip_missing=self.skip_missing,
-                                   skip_unknown=self.skip_unknown,
-                                   value_preprocessor=self.value_preprocessor)
-            else:
-                validate_value(item_spec, item, [TypeValidator])
-
-
-class TypeValidator(ValueValidator):
+def validate_type(rule, value):
     """ Simple type check.
     """
-    def check(self):
-        return isinstance(self.spec, type)
+    if not isinstance(value, rule.datatype):
+        raise TypeError('expected {typespec.__name__}, got '
+                        '{valtype.__name__} {value!r}'.format(
+                        typespec=rule.datatype, valtype=type(value),
+                        value=value))
 
-    def validate(self):
-        if not isinstance(self.value, self.spec):
-            raise TypeError('expected {typespec.__name__}, got '
-                            '{valtype.__name__} {value!r}'.format(
-                            typespec=self.spec, valtype=type(self.value),
-                            value=self.value))
 
 
-class InstanceValidator(ValueValidator):
-    """ Type check against an instance: both instances must be of the same
-    type. Example::
-
-        >>> InstanceValidator(1, 2).validate()
-        >>> InstanceValidator(1, 'a').validate()
-        TypeError: ...
-
+def validate(rule, value):
     """
-    def check(self):
-        # NOTE: greedy!
-        return not isinstance(self.spec, type)
-
-    def validate(self):
-        spec = type(self.spec)
-        validate_value(spec, self.value, [TypeValidator])
-
-
-class FuncValidator(ValueValidator):
-    """ Default value is obtained from a function with no arguments;
-    then check type against what the callable returns. (It is expected
-    that the callable does not have side effects.)
-    Example::
-
-        >>> FuncValidator(lambda: int, 2).validate()
-        >>> FuncValidator(lambda: int, 'a').validate()
-        TypeError: ...
-
-    Instances are also supported::
-
-        >>> FuncValidator(lambda: 1, 2).validate()
-        >>> FuncValidator(lambda: 1, 'a').validate()
-        TypeError: ...
-
-    """
-    def check(self):
-        return isinstance(self.spec, compat.func_types)
-
-    def validate(self):
-        spec = self.spec()
-        validate_value(spec, self.value, [TypeValidator, InstanceValidator])
-
-
-VALUE_VALIDATORS = (
-    DictValidator, ListValidator, TypeValidator, FuncValidator,
-    InstanceValidator
-)
-
-
-def validate_value(spec, value, validators,
-                   skip_missing=False, skip_unknown=False,
-                   value_preprocessor=None):
-    """ Checks if given `value` is valid for given `spec`, using given sequence
-    of `validators`.
-
-    The validators are expected to be subclasses of :class:`ValueValidator`.
-    They are polled one by one; the first one that agrees to process given
-    value is used to validate the value.
-    """
-    if value is None:
-        # empty value, ok unless required
-        return
-
-    if spec is None:
-        # any value is acceptable
-        return
-
-    if isinstance(spec, Rule):
-        rule = spec
-    else:
-        rule = Rule(spec)
-
-    for validator_class in validators:
-        validator = validator_class(rule.spec, value, skip_missing, skip_unknown,
-                   value_preprocessor=value_preprocessor)
-        if validator.check():
-            return validator.validate()
-    else:
-        pass  # for test coverage
-
-
-#def canonize(spec):
-#    canonic = {}
-#    for key, value in spec.iteritems():
-#        canonic[key] = value if isinstance(value, Rule) else Rule(value)
-#    return canonic
-
-
-def validate_structure(spec, data, skip_missing=False, skip_unknown=False,
-                       validators=VALUE_VALIDATORS, value_preprocessor=None):
-                       #this_level_skip_missing=None,
-                       #this_level_skip_unknown=None):
-    """ Validates given document against given structure specification.
+    Validates given value against given specification.
+    Raises an exception if the value is invalid.
     Always returns ``None``.
 
-    :param spec:
-        `dict`; document structure specification.
-    :param data:
-        `dict`; document to be validated against the spec.
-    :param skip_missing:
-        ``bool``; if ``True``, :class:`MissingKey` is never raised.
-        Default is ``False``.
-    :param skip_unknown:
-        ``bool``; if ``True``, :class:`UnknownKey` is never raised.
-        Default is ``False``.
-    :param validators:
-        `sequence`. An ordered series of :class:`ValueValidator` subclasses.
-        Default is :attr:`VALUE_VALIDATORS`. The validators are passed to
-        :func:`validate_value`.
+    :rule:
+        a :class:`~monk.schema.Rule` instance.
+    :value:
+        any value including complex structures.
 
     Can raise:
 
+    :class:`MissingValue`
+        if a dictionary key is in the spec but not in the value.
+        This applies to root and nested dictionaries.
+
     :class:`MissingKey`
-        if a key is in `spec` but not in `data`.
+        if a dictionary key is in the spec but not in the value.
+        This applies to root and nested dictionaries.
+
     :class:`UnknownKey`
-        if a key is in `data` but not in `spec`.
+        if a dictionary key is the value but not not in the spec.
+
     :class:`StructureSpecificationError`
-        if errors were found in `spec`.
+        if errors were found in spec.
+
     :class:`TypeError`
-        if a value in `data` does not belong to the designated type.
+        if the value (or a nested value) does not belong to the designated type.
 
     """
-    # compare the two structures; nested dictionaries are included in the
-    # comparison but nested lists are opaque and will be dealt with later on.
-    spec_keys = set(spec.keys())
-    data_keys = set(data.keys())
-    missing = spec_keys - data_keys
-    unknown = data_keys - spec_keys
+    rule = canonize(rule)
 
-#    if missing and not skip_missing:
-#        raise MissingKey('Missing keys: {0}'.format(', '.join(missing)))
+    if value is None:
+        # empty value, ok unless required
+        if rule.optional:
+            return
 
+        if rule.datatype is None:
+            raise errors.MissingValue('expected a value, got None')
+        else:
+            raise errors.MissingValue('expected {0}, got None'.format(rule.datatype.__name__))
 
-    if unknown and not skip_unknown:
-        raise UnknownKey('Unknown keys: {0}'.format(
-            ', '.join(compat.safe_str(x) for x in unknown)))
-
-    # check types and deal with nested lists
-    for key in spec_keys | data_keys:
-        typespec = spec.get(key)
-        rule = typespec if isinstance(typespec, Rule) else Rule(typespec)
-        if key in data_keys:
-            value = data.get(key)
-            if value_preprocessor:
-                value = value_preprocessor(typespec, value)
-            try:
-                validate_value(typespec, value, validators,
-                               skip_missing, skip_unknown,
-                               value_preprocessor=value_preprocessor)
-            except (MissingKey, UnknownKey, TypeError) as e:
-                raise type(e)('{k}: {e}'.format(k=key, e=e))
-        else:
-            if skip_missing or rule.skip_missing:
-                continue
-            raise MissingKey('{0}'.format(key))
-
-
-def validate_structure_spec(spec, validators=VALUE_VALIDATORS):
-    # this is a pretty dumb function that simply populates the data when normal
-    # manipulation function fails to do that because of ambiguity.
-    # The dictionaries are created even within lists; missing keys are created
-    # with None values.
-    # This enables validate_structure() to peek into nested levels (by default
-    # it bails out when a key is missing).
-    def dictmerger(typespec, value):
-        if value == [] and typespec:
-            for elem in typespec:
-                if isinstance(elem, type):
-                    # [int] -> [None]
-                    value.append(None)
-                elif isinstance(elem, dict):
-                    # [{'a': int}] -> [{'a': None}]
-                    value.append(merged(elem, {}))
-        return value
-    validate_structure(spec, merged(spec, {}), skip_missing=True, skip_unknown=True,
-                       validators=validators, value_preprocessor=dictmerger)
-
-
-class Rule:
-    "Extended specification of a field.  Allows marking it as optional."
-    def __init__(self, spec, skip_missing=False):
-        self.spec = spec
-        self.skip_missing = skip_missing
-
-    def __repr__(self):
-        return '<Rule {spec} missing:{missing}>'.format(
-            spec=str(self.spec).replace('<','').replace('>',''),
-            missing=self.skip_missing)
-
-
-optional = lambda x: Rule(x, skip_missing=True)
+    if rule.datatype is None:
+        # any value is acceptable
+        pass
+    elif rule.datatype == dict:
+        validate_dict(rule, value)
+    elif rule.datatype == list:
+        validate_list(rule, value)
+    else:
+        assert not rule.inner_spec
+        if isinstance(rule.datatype, type):
+            validate_type(rule, value)
 testrun = http://pypi.testrun.org
 
 [testenv]
-deps=mock
+deps=coverage
+     mock
      pytest
+     pytest-cov
      pymongo
-commands=py.test
+commands=py.test []

unittests/test_manipulation.py

 import pytest
 
 from monk.compat import text_type as t
+from monk.schema import Rule
 from monk.manipulation import merged
 
 
         data = {}
         expected = {'content': {'text': t('hello')}}
         assert merged(spec, data) == expected
+
+    def test_rule_merger(self):
+        spec = {'foo': Rule(str, default='bar')}
+        data = {}
+        expected = {'foo': 'bar'}
+        assert merged(spec, data) == expected

unittests/test_mongo.py

 
 from bson import DBRef, ObjectId
 from monk import mongo
+from monk.schema import optional
 from monk.compat import text_type as t
 
 
     class Entry(mongo.Document):
         collection = 'entries'
         structure = {
-            '_id': ObjectId,
+            '_id': optional(ObjectId),
             'title': t,
         }
 

unittests/test_rules.py

+# -*- coding: utf-8 -*-
+#
+#    Monk is an unobtrusive data modeling, manipulation and validation library.
+#    Copyright © 2011—2013  Andrey Mikhaylenko
+#
+#    This file is part of Monk.
+#
+#    Monk is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Lesser General Public License as published
+#    by the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    Monk is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public License
+#    along with Monk.  If not, see <http://gnu.org/licenses/>.
+"""
+Schema Rules Tests
+~~~~~~~~~~~~~~~~~~
+"""
+import datetime
+import pytest
+
+from monk import errors
+from monk.schema import Rule, canonize
+
+
+class TestRule:
+
+    def test_rule_as_datatype(self):
+        rule = Rule(None)
+        with pytest.raises(ValueError) as excinfo:
+            Rule(rule)
+        assert 'Cannot use a Rule instance as datatype' in excinfo.exconly()
+
+    def test_rule_repr(self):
+        assert repr(Rule(None)) == '<Rule any required>'
+        assert repr(Rule(None, optional=True)) == '<Rule any optional>'
+        assert repr(Rule(str)) == '<Rule str required>'
+        assert repr(Rule(str, default='foo')) == '<Rule str required default=foo>'
+
+    def test_sanity_default(self):
+        Rule(str)
+        Rule(str, default='foo')
+        with pytest.raises(TypeError) as excinfo:
+            Rule(str, default=123)
+        assert excinfo.exconly() == 'TypeError: Default value must match datatype str (got 123)'
+
+    @pytest.mark.xfail
+    def test_sanity_inner_spec(self):
+        #
+        # this won't work because only dict wants a dict as its inner_spec;
+        # a list doesn't need this duplication.
+        #
+        Rule(dict)
+        Rule(dict, inner_spec={})
+        with pytest.raises(TypeError) as excinfo:
+            Rule(dict, inner_spec=123)
+        assert excinfo.exconly() == 'TypeError: Inner spec must match datatype dict (got 123)'
+
+
+class TestCanonization:
+
+    def test_none(self):
+        assert canonize(None) == Rule(None)
+
+    def test_bool(self):
+        assert canonize(bool) == Rule(bool)
+        assert canonize(True)  == Rule(bool, default=True)
+        assert canonize(False)  == Rule(bool, default=False)
+
+    def test_datetime(self):
+        assert canonize(datetime.datetime) == Rule(datetime.datetime)
+
+        dt = datetime.datetime.now()
+        assert canonize(dt) == Rule(datetime.datetime, default=dt)
+
+    def test_dict(self):
+        assert canonize(dict) == Rule(dict)
+        assert canonize({'foo': 123}) == Rule(dict, inner_spec={'foo': 123})
+
+    def test_float(self):
+        assert canonize(float) == Rule(float)
+        assert canonize(.5) == Rule(float, default=.5)
+
+    def test_int(self):
+        assert canonize(int) == Rule(int)
+        assert canonize(5) == Rule(int, default=5)
+
+    def test_list(self):
+        assert canonize(list) == Rule(list)
+
+        assert canonize([]) == Rule(list, default=[])
+
+        with pytest.raises(errors.StructureSpecificationError) as excinfo:
+            canonize([1,2])
+        assert ("StructureSpecificationError: Expected a list "
+                "containing exactly 1 item; got 2: [1, 2]") in excinfo.exconly()
+
+    def test_string(self):
+        assert canonize(str) == Rule(str)
+        assert canonize('foo') == Rule(str, default='foo')
+
+    def test_rule(self):
+        rule = Rule(str, default='abc', optional=True)
+        assert rule == canonize(rule)

unittests/test_validation.py

 ================
 """
 import datetime
-import sys
 
 import bson
 import pytest
 
 from monk.compat import text_type, safe_unicode
-from monk.validation import (
-    validate_structure_spec, validate_structure, StructureSpecificationError,
-    MissingKey, UnknownKey, Rule, optional
-)
+from monk.errors import MissingKey, MissingValue, UnknownKey
+from monk.schema import Rule, optional, any_value, any_or_none
+from monk.validation import validate
 
 
-class TestStructureSpec:
-
-    def test_correct_types(self):
-        '`None` stands for "any value".'
-        validate_structure_spec({'foo': None})
-        validate_structure_spec({'foo': bool})
-        validate_structure_spec({'foo': dict})
-        validate_structure_spec({'foo': float})
-        validate_structure_spec({'foo': int})
-        validate_structure_spec({'foo': list})
-        validate_structure_spec({'foo': text_type})
-        validate_structure_spec({'foo': datetime.datetime})
-        validate_structure_spec({'foo': bson.Binary})
-        validate_structure_spec({'foo': bson.Code})
-        validate_structure_spec({'foo': bson.ObjectId})
-        validate_structure_spec({'foo': bson.DBRef})
-
-    def test_correct_structures(self):
-        # foo is of given type
-        validate_structure_spec({'foo': int})
-        # foo and bar are of given types
-        validate_structure_spec({'foo': int, 'bar': text_type})
-        # foo is a list of values of given type
-        validate_structure_spec({'foo': [int]})
-        # foo.bar is of given type
-        validate_structure_spec({'foo': {'bar': int}})
-        # foo.bar is a list of values of given type
-        validate_structure_spec({'foo': {'bar': [int]}})
-        # foo.bar is a list of mappings where each "baz" is of given type
-        validate_structure_spec({'foo': {'bar': [{'baz': [text_type]}]}})
-
-    def test_malformed_lists(self):
-        single_elem_err_msg = 'empty list or a list containing exactly 1 item'
-
-        with pytest.raises(StructureSpecificationError) as excinfo:
-            validate_structure_spec({'foo': [text_type, text_type]})
-        assert single_elem_err_msg in excinfo.exconly()
-
-        with pytest.raises(StructureSpecificationError) as excinfo:
-            validate_structure_spec({'foo': {'bar': [text_type, text_type]}})
-        assert single_elem_err_msg in excinfo.exconly()
-
-        with pytest.raises(StructureSpecificationError) as excinfo:
-            validate_structure_spec({'foo': {'bar': [{'baz': [text_type, text_type]}]}})
-        assert single_elem_err_msg in excinfo.exconly()
-
-
-class TestDocumentStructureValidation:
-
-    def test_correct_structures(self):
-        '''
-        # foo is of given type
-        validate_structure({'foo': int}, {}, skip_missing=True)
-        # foo and bar are of given types
-        validate_structure({'foo': int, 'bar': unicode}, {}, skip_missing=True)
-        # foo is a list of values of given type
-        validate_structure({'foo': [int]}, {}, skip_missing=True)
-        # foo.bar is of given type
-        validate_structure({'foo': {'bar': int}}, {}, skip_missing=True)
-        # foo.bar is a list of values of given type
-        validate_structure({'foo': {'bar': [int]}}, {}, skip_missing=True)
-        # foo.bar is a list of mappings where each "baz" is of given type
-        validate_structure({'foo': {'bar': [{'baz': [unicode]}]}}, {}, skip_missing=True)
-        '''
-
-    def test_malformed_lists(self):
-        pass
-
-    #---
-
-    def test_bad_types(self):
-        with pytest.raises(TypeError) as excinfo:
-            validate_structure({'a': int}, {'a': 'bad'})
-        assert "a: expected int, got str 'bad'" in excinfo.exconly()
-
-        with pytest.raises(TypeError) as excinfo:
-            validate_structure({'a': [int]}, {'a': 'bad'})
-        assert "a: expected list, got str 'bad'" in excinfo.exconly()
-
-        with pytest.raises(TypeError) as excinfo:
-            validate_structure({'a': [int]}, {'a': ['bad']})
-        assert "a: expected int, got str 'bad'" in excinfo.exconly()
-
-        with pytest.raises(TypeError) as excinfo:
-            validate_structure({'a': {'b': int}}, {'a': 'bad'})
-        assert "a: expected dict, got str 'bad'" in excinfo.exconly()
-
-        with pytest.raises(TypeError) as excinfo:
-            validate_structure({'a': {'b': int}}, {'a': {'b': 'bad'}})
-        assert "a: b: expected int, got str 'bad'" in excinfo.exconly()
-
-        with pytest.raises(TypeError) as excinfo:
-            validate_structure({'a': [{'b': [int]}]}, {'a': [{'b': ['bad']}]})
-        assert "a: b: expected int, got str 'bad'" in excinfo.exconly()
+class TestOverall:
 
     def test_empty(self):
-        validate_structure({'a': text_type}, {'a': None})
-        validate_structure({'a': list}, {'a': None})
-        validate_structure({'a': dict}, {'a': None})
 
-        # None is allowed to represent empty value, but bool(value)==False
-        # is not (unless bool is the correct type for this value)
-        validate_structure({'a': bool}, {'a': None})
-        validate_structure({'a': bool}, {'a': False})
+        # MISSING VALUE
+
+        validate({'a': optional(text_type)}, {'a': text_type('')})
+        with pytest.raises(MissingValue):
+            validate({'a': text_type}, {'a': None})
+
+        validate({'a': optional(dict)}, {'a': {}})
+        with pytest.raises(MissingValue):
+            validate({'a': dict}, {'a': None})
+
+        validate({'a': optional(list)}, {'a': []})
+        with pytest.raises(MissingValue):
+            validate({'a': list}, {'a': None})
+
+        validate({'a': bool}, {'a': True})
+        validate({'a': bool}, {'a': False})
+        validate({'a': optional(bool)}, {'a': None})
+        with pytest.raises(MissingValue):
+            validate({'a': bool}, {'a': None})
+
+        # TYPE ERROR
+
         with pytest.raises(TypeError):
-            validate_structure({'a': text_type}, {'a': False})
+            validate({'a': text_type}, {'a': False})
         with pytest.raises(TypeError):
-            validate_structure({'a': text_type}, {'a': 0})
+            validate({'a': text_type}, {'a': 0})
         with pytest.raises(TypeError):
-            validate_structure({'a': bool}, {'a': ''})
+            validate({'a': bool}, {'a': ''})
 
     def test_missing(self):
-        validate_structure({'a': text_type}, {}, skip_missing=True)
+
+        # MISSING KEY
+
+        validate({'a': Rule(text_type, optional=True)}, {})
         with pytest.raises(MissingKey):
-            validate_structure({'a': text_type}, {})
+            validate({'a': text_type}, {})
         with pytest.raises(MissingKey):
-            validate_structure({'a': text_type, 'b': int}, {'b': 1})
+            validate({'a': text_type, 'b': int}, {'b': 1})
 
     def test_unknown_keys(self):
-        validate_structure({}, {'x': 123}, skip_unknown=True)
+        # verbose notation
+        validate(Rule(dict, dict_skip_unknown_keys=True), {'x': 123})
+
+        # special behaviour: missing/empty inner_spec means "a dict of anything"
+        validate(Rule(dict), {'x': 123})
+
+        # inner_spec not empty, value matches it
+        validate({'x': None}, {'x': 123})
+
         with pytest.raises(UnknownKey):
-            validate_structure({}, {'x': 123})
+            validate({'x': None}, {'y': 123})
+
         with pytest.raises(UnknownKey):
-            validate_structure({'a': text_type}, {'a': text_type('A'), 'x': 123})
+            validate({'x': None}, {'x': 123, 'y': 456})
 
     def test_unknown_keys_encoding(self):
         with pytest.raises(UnknownKey):
-            validate_structure({'a': text_type}, {'привет': 1})
+            validate({'a': text_type}, {'привет': 1})
         with pytest.raises(UnknownKey):
-            validate_structure({'a': text_type}, {safe_unicode('привет'): 1})
+            validate({'a': text_type}, {safe_unicode('привет'): 1})
+
+
+class TestDataTypes:
 
     def test_bool(self):
-        validate_structure({'a': bool}, {'a': None})
-        validate_structure({'a': bool}, {'a': True})
-        validate_structure({'a': bool}, {'a': False})
+        validate({'a': bool}, {'a': True})
+        validate({'a': bool}, {'a': False})
 
     def test_bool_instance(self):
-        validate_structure({'a': True}, {'a': None})
-        validate_structure({'a': True}, {'a': True})
-        validate_structure({'a': True}, {'a': False})
+        validate({'a': True}, {'a': True})
+        validate({'a': True}, {'a': False})
 
     def test_dict(self):
-        validate_structure({'a': dict}, {'a': None})
-        validate_structure({'a': dict}, {'a': {}})
-        validate_structure({'a': dict}, {'a': {'b': 'c'}})
+        validate({'a': dict}, {'a': {}})
+        validate({'a': dict}, {'a': {'b': 'c'}})
 
     def test_dict_instance(self):
-        validate_structure({'a': {}}, {'a': None})
-        validate_structure({'a': {}}, {'a': {}})
-        validate_structure({'a': {}}, {'a': {'b': 123}})
+        validate({'a': {}}, {'a': {}})
+        validate({'a': {}}, {'a': {'b': 123}})
 
     def test_float(self):
-        validate_structure({'a': float}, {'a': None})
-        validate_structure({'a': float}, {'a': .5})
+        validate({'a': float}, {'a': .5})
 
     def test_float_instance(self):
-        validate_structure({'a': .2}, {'a': None})
-        validate_structure({'a': .2}, {'a': .5})
+        validate({'a': .2}, {'a': .5})
 
     def test_int(self):
-        validate_structure({'a': int}, {'a': None})
-        validate_structure({'a': int}, {'a': 123})
+        validate({'a': int}, {'a': 123})
 
     def test_int_instance(self):
-        validate_structure({'a': 1}, {'a': None})
-        validate_structure({'a': 1}, {'a': 123})
+        validate({'a': 1}, {'a': 123})
 
     def test_list(self):
-        validate_structure({'a': list}, {'a': None})
-        validate_structure({'a': list}, {'a': []})
-        validate_structure({'a': list}, {'a': ['b', 123]})
+        validate({'a': list}, {'a': []})
+        validate({'a': list}, {'a': ['b', 123]})
 
     def test_list_instance(self):
-        validate_structure({'a': []}, {'a': None})
-        validate_structure({'a': []}, {'a': []})
-        validate_structure({'a': []}, {'a': ['b', 123]})
+        validate({'a': []}, {'a': []})
+        validate({'a': []}, {'a': ['b', 123]})
 
-        validate_structure({'a': [int]}, {'a': None})
-        validate_structure({'a': [int]}, {'a': []})
-        validate_structure({'a': [int]}, {'a': [123]})
-        validate_structure({'a': [int]}, {'a': [123, 456]})
+        validate({'a': [int]}, {'a': []})
+        validate({'a': [int]}, {'a': [123]})
+        validate({'a': [int]}, {'a': [123, 456]})
         with pytest.raises(TypeError):
-            validate_structure({'a': [int]}, {'a': ['b', 123]})
+            validate({'a': [int]}, {'a': ['b', 123]})
         with pytest.raises(TypeError):
-            validate_structure({'a': [text_type]}, {'a': [{'b': 'c'}]})
+            validate({'a': [text_type]}, {'a': [{'b': 'c'}]})
 
     def test_unicode(self):
-        validate_structure({'a': text_type}, {'a': None})
-        validate_structure({'a': text_type}, {'a': text_type('hello')})
+        validate({'a': text_type}, {'a': text_type('hello')})
         with pytest.raises(TypeError):
-            validate_structure({'a': text_type}, {'a': 123})
+            validate({'a': text_type}, {'a': 123})
 
     def test_unicode_instance(self):
-        validate_structure({'a': text_type('foo')}, {'a': None})
-        validate_structure({'a': text_type('foo')}, {'a': text_type('hello')})
+        validate({'a': text_type('foo')}, {'a': text_type('hello')})
         with pytest.raises(TypeError):
-            validate_structure({'a': text_type('foo')}, {'a': 123})
+            validate({'a': text_type('foo')}, {'a': 123})
 
     def test_datetime(self):
-        validate_structure({'a': datetime.datetime}, {'a': None})
-        validate_structure({'a': datetime.datetime},
-                           {'a': datetime.datetime.utcnow()})
+        validate({'a': datetime.datetime},
+                 {'a': datetime.datetime.utcnow()})
         with pytest.raises(TypeError):
-            validate_structure({'a': datetime.datetime}, {'a': 123})
+            validate({'a': datetime.datetime}, {'a': 123})
 
     def test_datetime_instance(self):
-        validate_structure({'a': datetime.datetime(1900, 1, 1)}, {'a': None})
-        validate_structure({'a': datetime.datetime(1900, 1, 1)},
-                           {'a': datetime.datetime.utcnow()})
+        validate({'a': datetime.datetime(1900, 1, 1)},
+                 {'a': datetime.datetime.utcnow()})
         with pytest.raises(TypeError):
-            validate_structure({'a': datetime.datetime}, {'a': 123})
+            validate({'a': datetime.datetime}, {'a': 123})
 
     def test_objectid(self):
-        validate_structure({'a': bson.ObjectId}, {'a': None})
-        validate_structure({'a': bson.ObjectId}, {'a': bson.ObjectId()})
+        validate({'a': bson.ObjectId}, {'a': bson.ObjectId()})
 
     def test_dbref(self):
-        validate_structure({'a': bson.DBRef}, {'a': None})
-        validate_structure({'a': bson.DBRef},
+        validate({'a': bson.DBRef},
                            {'a': bson.DBRef('a', 'b')})
 
     def test_callable(self):
             def ometh(self):
                 return 1
 
-        validate_structure({'a': func}, {'a': 2})
-        validate_structure({'a': Obj.smeth}, {'a': 2})
-        validate_structure({'a': Obj.cmeth}, {'a': 2})
-        validate_structure({'a': Obj().ometh}, {'a': 2})
+        validate({'a': func}, {'a': 2})
+        validate({'a': Obj.smeth}, {'a': 2})
+        validate({'a': Obj.cmeth}, {'a': 2})
+        validate({'a': Obj().ometh}, {'a': 2})
 
         with pytest.raises(TypeError):
-            validate_structure({'a': func}, {'a': 'foo'})
+            validate({'a': func}, {'a': 'foo'})
         with pytest.raises(TypeError):
-            validate_structure({'a': Obj.smeth}, {'a': 'foo'})
+            validate({'a': Obj.smeth}, {'a': 'foo'})
         with pytest.raises(TypeError):
-            validate_structure({'a': Obj.cmeth}, {'a': 'foo'})
+            validate({'a': Obj.cmeth}, {'a': 'foo'})
         with pytest.raises(TypeError):
-            validate_structure({'a': Obj().ometh}, {'a': 'foo'})
+            validate({'a': Obj().ometh}, {'a': 'foo'})
 
     def test_valid_document(self):
         "a complex document"
                 },
             ],
         }
-        validate_structure(spec, data)
+        validate(spec, data)
 
 
-class TestValidationRules:
-    def test_simple(self):
-        # simple rule behaves as the spec within it
-        spec = {
-            'a': Rule(int),
-        }
-        validate_structure(spec, {'a': 1})
-        with pytest.raises(MissingKey):
-            validate_structure(spec, {})
-        with pytest.raises(TypeError):
-            validate_structure(spec, {'a': 'bogus'})
+class TestRuleSettings:
 
-    def test_skip_missing(self):
-        # the rule modifies behaviour of nested validator
-        spec = {
-            'a': optional(int),
-        }
-        validate_structure(spec, {})
+    def test_any_required(self):
+        "A value of any type"
 
-    def test_skip_missing_nested(self):
-        spec = {
-            'a': {'b': optional(int)},
-        }
+        # value is present
+        validate(Rule(datatype=None), 1)
 
-        validate_structure(spec, {'a': None})
+        # value is missing
+        with pytest.raises(MissingValue) as excinfo:
+            validate(Rule(datatype=None), None)
+        assert "MissingValue: expected a value, got None" in excinfo.exconly()
+
+    def test_any_optional(self):
+        "A value of any type or no value"
+
+        # value is present
+        validate(Rule(datatype=None, optional=True), 1)
+
+        # value is missing
+        validate(Rule(datatype=None, optional=True), None)
+
+    def test_typed_required(self):
+        "A value of given type"
+
+        # value is present and matches datatype
+        validate(Rule(int), 1)
+
+        # value is present but does not match datatype
+        with pytest.raises(TypeError) as excinfo:
+            validate(Rule(int), 'bogus')
+        assert "TypeError: expected int, got str 'bogus'" in excinfo.exconly()
+
+        # value is missing
+        with pytest.raises(MissingValue) as excinfo:
+            validate(Rule(int), None)
+        assert "MissingValue: expected int, got None" in excinfo.exconly()
+
+    def test_typed_optional(self):
+        "A value of given type or no value"
+
+        # value is present and matches datatype
+        validate(Rule(int, optional=True), 1)
+
+        # value is present but does not match datatype
+        with pytest.raises(TypeError) as excinfo:
+            validate(Rule(int), 'bogus')
+        assert "TypeError: expected int, got str 'bogus'" in excinfo.exconly()
+
+        # value is missing
+        validate(Rule(int, optional=True), None)
+
+    def test_typed_required_dict(self):
+        "A value of given type (dict)"
+
+        # value is present
+        validate(Rule(datatype=dict), {})
+
+        # value is missing
+        with pytest.raises(MissingValue) as excinfo:
+            validate(Rule(datatype=dict), None)
+        assert "MissingValue: expected dict, got None" in excinfo.exconly()
+
+    def test_typed_optional_dict(self):
+        "A value of given type (dict) or no value"
+
+        # value is present
+        validate(Rule(datatype=dict, optional=True), {})
+
+        # value is missing
+        validate(Rule(datatype=dict, optional=True), None)
+
+    def test_typed_required_list(self):
+        "A value of given type (list)"
+
+        # value is present
+        validate(Rule(datatype=list), [])
+
+        with pytest.raises(TypeError) as excinfo:
+            validate(Rule(datatype=list), 'bogus')
+        assert "TypeError: expected list, got str 'bogus'" in excinfo.exconly()
+
+        # value is missing
+        with pytest.raises(MissingValue) as excinfo:
+            validate(Rule(datatype=list), None)
+        assert "MissingValue: expected list, got None" in excinfo.exconly()
+
+    def test_typed_optional_list(self):
+        "A value of given type (list) or no value"
+
+        # value is present
+        validate(Rule(datatype=list, optional=True), [])
+
+        # value is missing
+        validate(Rule(datatype=list, optional=True), None)
+
+
+class TestNested:
+
+    def test_int_in_dict(self):
+        "A required int nested in a required dict"
+
+        spec = Rule(datatype=dict, inner_spec={'foo': int})
+
+        # key is missing
 
         with pytest.raises(MissingKey) as excinfo:
-            validate_structure(spec, {})
-        prefix = '' if sys.version_info < (3,0) else 'monk.validation.'
-        assert excinfo.exconly() == prefix + 'MissingKey: a'
+            validate(spec, {})
+        assert "MissingKey: foo" in excinfo.exconly()
 
-        validate_structure(spec, {'a': {}})
+        # key is present, value is missing
 
-    def test_skip_missing_nested_required(self):
-        "optional dict contains a dict with required values"
-        spec = {
-            'a': optional({'b': int}),
-        }
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, {'foo': None})
+        assert "MissingValue: foo: expected int, got None" in excinfo.exconly()
 
-        # None is OK (optional)
-        validate_structure(spec, {'a': None})
+        # key is present, value is present
 
-        # empty dict is OK (optional)
-        validate_structure(spec, {})
+        validate(spec, {'foo': 1})
 
-        # empty subdict fails because only its parent is optional
+    def test_dict_in_dict(self):
+        "A required dict nested in another required dict"
+
+        spec = Rule(datatype=dict, inner_spec={'foo': dict})
+
+        # key is missing
+
         with pytest.raises(MissingKey) as excinfo:
-            validate_structure(spec, {'a': {}})
-        prefix = '' if sys.version_info < (3,0) else 'monk.validation.'
-        assert excinfo.exconly() == prefix + 'MissingKey: a: b'
+            validate(spec, {})
+        assert "MissingKey: foo" in excinfo.exconly()
+
+        # key is present, value is missing
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, {'foo': None})
+        assert "MissingValue: foo: expected dict, got None" in excinfo.exconly()
+
+        # value is present
+
+        validate(spec, {'foo': {}})
+
+    def test_int_in_dict_in_dict(self):
+        "A required int nested in a required dict nested in another required dict"
+
+        spec = Rule(datatype=dict, inner_spec={
+            'foo': Rule(datatype=dict, inner_spec={
+                'bar': int})})
+
+        # inner key is missing
+
+        with pytest.raises(MissingKey) as excinfo:
+            validate(spec, {'foo': {}})
+        assert "MissingKey: foo: bar" in excinfo.exconly()
+
+        # inner key is present, inner value is missing
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, {'foo': {'bar': None}})
+        assert "MissingValue: foo: bar: expected int, got None" in excinfo.exconly()
+
+        # inner value is present
+
+        validate(spec, {'foo': {'bar': 123}})
+
+    def test_int_in_optional_dict(self):
+        "A required int nested in an optional dict"
+
+        spec = Rule(datatype=dict, optional=True, inner_spec={'foo': int})
+
+        # outer optional value is missing
+
+        validate(spec, None)
+
+        # outer optional value is present, inner key is missing
+
+        with pytest.raises(MissingKey) as excinfo:
+            validate(spec, {})
+        assert "MissingKey: foo" in excinfo.exconly()
+
+        # inner key is present, inner value is missing
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, {'foo': None})
+        assert "MissingValue: foo: expected int, got None" in excinfo.exconly()
+
+        # inner value is present
+
+        validate(spec, {'foo': 123})
+
+    def test_int_in_list(self):
+        spec = Rule(datatype=list, inner_spec=int)
+
+        # outer value is missing
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, None)
+        assert "MissingValue: expected list, got None" in excinfo.exconly()
+
+        # outer value is present, inner value is missing
+
+        validate(spec, [])
+
+        # inner value is present but is None
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, [None])
+        assert "MissingValue: #0: expected int, got None" in excinfo.exconly()
+
+        # inner value is present
+
+        validate(spec, [123])
+
+        # multiple inner values are present
+
+        validate(spec, [123, 456])
+
+        # one of the inner values is of a wrong type
+
+        with pytest.raises(TypeError) as excinfo:
+            validate(spec, [123, 'bogus'])
+        assert "TypeError: #1: expected int, got str 'bogus'" in excinfo.exconly()
+
+    def test_freeform_dict_in_list(self):
+        spec = Rule(datatype=list, inner_spec=dict)
+
+        # inner value is present
+
+        validate(spec, [{}])
+        validate(spec, [{'foo': 123}])
+
+        # multiple inner values are present
+
+        validate(spec, [{'foo': 123}, {'bar': 456}])
+
+        # one of the inner values is of a wrong type
+
+        with pytest.raises(TypeError) as excinfo:
+            validate(spec, [{}, 'bogus'])
+        assert "TypeError: #1: expected dict, got str 'bogus'" in excinfo.exconly()
+
+    def test_schemed_dict_in_list(self):
+        spec = Rule(datatype=list, inner_spec={'foo': int})
+
+        # dict in list: missing key
+
+        with pytest.raises(MissingKey) as excinfo:
+            validate(spec, [{}])
+
+        with pytest.raises(MissingKey) as excinfo:
+            validate(spec, [{'foo': 123}, {}])
+        assert "MissingKey: #1: foo" in excinfo.exconly()
+
+        # dict in list: missing value
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, [{'foo': None}])
+        assert "MissingValue: #0: foo: expected int, got None" in excinfo.exconly()
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, [{'foo': 123}, {'foo': None}])
+        assert "MissingValue: #1: foo: expected int, got None" in excinfo.exconly()
+
+        # multiple innermost values are present
+
+        validate(spec, [{'foo': 123}])
+        validate(spec, [{'foo': 123}, {'foo': 456}])
+
+        # one of the innermost values is of a wrong type
+
+        with pytest.raises(TypeError) as excinfo:
+            validate(spec, [{'foo': 123}, {'foo': 456}, {'foo': 'bogus'}])
+        assert "TypeError: #2: foo: expected int, got str 'bogus'" in excinfo.exconly()
+
+    def test_int_in_list_in_dict_in_list_in_dict(self):
+        spec = Rule(datatype=dict, inner_spec={'foo': [{'bar': [int]}]})
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, {'foo': None})
+        assert "MissingValue: foo: expected list, got None" in excinfo.exconly()
+
+        with pytest.raises(MissingValue) as excinfo:
+            validate(spec, {'foo': [{'bar': None}]})
+        assert "MissingValue: foo: #0: bar: expected list, got None" in excinfo.exconly()
+
+        validate(spec, {'foo': []})
+        validate(spec, {'foo': [{'bar': []}]})
+        validate(spec, {'foo': [{'bar': [1]}]})
+        validate(spec, {'foo': [{'bar': [1, 2]}]})
+
+        with pytest.raises(TypeError) as excinfo:
+            validate(spec, {'foo': [{'bar': [1, 'bogus']}]})
+        assert "TypeError: foo: #0: bar: #1: expected int, got str 'bogus'" in excinfo.exconly()
+
+
+class TestRuleShortcuts:
+
+    def test_any_value(self):
+        assert any_value == Rule(None)
+
+    def test_any_or_none(self):
+        assert any_or_none == Rule(None, optional=True)
+        assert any_or_none == optional(any_value)
+
+    def test_optional(self):
+        assert optional(str) == Rule(str, optional=True)
+        assert optional(Rule(str)) == Rule(str, optional=True)