diff --git a/CHANGES.txt b/CHANGES.txt index 6750b53e..5323f401 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -7,6 +7,16 @@ Next release - Docstring fixes to ``colander.SchemaNode`` (``missing`` is not the ``null`` value when required, it's a special marker value). +- The concept of "schema binding" was added, which allows for a more + declarative-looking spelling of schemas and schema nodes which have + dependencies on values available after the schema has already been + fully constructed. See the new narrative chapter in the + documentation entitled "Schema Binding". + +- The interface of ``colander.SchemaNode`` has grown a ``__delitem__`` + method. The ``__iter__``, and ``__getitem__`` methods have now also + been properly documented. + 0.7.3 (2010/09/02) ------------------ diff --git a/colander/__init__.py b/colander/__init__.py index 4006bb39..5fce3e43 100644 --- a/colander/__init__.py +++ b/colander/__init__.py @@ -11,6 +11,7 @@ _marker = object() class _null(object): + """ Represents a null value in colander-related operations. """ def __nonzero__(self): return False @@ -943,7 +944,7 @@ def _zope_dottedname_style(self, node, value): used += '.' + n try: found = getattr(found, n) - except AttributeError: + except AttributeError: # pragma: no cover __import__(used) found = getattr(found, n) @@ -1160,6 +1161,20 @@ class SchemaNode(object): an object that implements the :class:`colander.interfaces.Validator` interface. + - ``after_bind``: A callback which is called after a clone of this + node has 'bound' all of its values successfully. This callback + is useful for performing arbitrary actions to the cloned node, + or direct children of the cloned node (such as removing or + adding children) at bind time. A 'binding' is the result of an + execution of the ``bind`` method of the clone's prototype node, + or one of the parents of the clone's prototype nodes. The + deepest nodes in the node tree are bound first, so the + ``after_bind`` methods of the deepest nodes are called before + the shallowest. The ``after_bind`` callback should should + accept two values: ``node`` and ``kw``. ``node`` will be a + clone of the bound node object, ``kw`` will be the set of + keywords passed to the ``bind`` method. + - ``title``: The title of this node. Defaults to a titleization of the ``name`` (underscores replaced with empty strings and the first letter of every resulting word capitalized). The title is @@ -1172,6 +1187,7 @@ class SchemaNode(object): - ``widget``: The 'widget' for this node. Defaults to ``None``. The widget attribute is not interpreted by Colander itself, it is only meaningful to higher-level systems such as Deform. + """ _counter = itertools.count() @@ -1190,22 +1206,11 @@ def __init__(self, typ, *children, **kw): self.title = kw.pop('title', self.name.replace('_', ' ').title()) self.description = kw.pop('description', '') self.widget = kw.pop('widget', None) + self.after_bind = kw.pop('after_bind', None) if kw: raise TypeError('Unknown keyword arguments: %s' % repr(kw)) self.children = list(children) - def __iter__(self): - """ Iterate over the children nodes of this schema node """ - return iter(self.children) - - def __repr__(self): - return '<%s.%s object at %d (named %s)>' % ( - self.__module__, - self.__class__.__name__, - id(self), - self.name, - ) - @property def required(self): """ A property which returns ``True`` if the ``missing`` value @@ -1268,12 +1273,6 @@ def add(self, node): """ Add a subnode to this node. """ self.children.append(node) - def __getitem__(self, name): - for node in self.children: - if node.name == name: - return node - raise KeyError(name) - def clone(self): """ Clone the schema node and return the clone. All subnodes are also cloned recursively. Attributes present in node @@ -1283,6 +1282,54 @@ def clone(self): cloned.children = [ node.clone() for node in self.children ] return cloned + def bind(self, **kw): + """ Resolve any deferred values attached to this schema node + and its children (recursively), using the keywords passed as + ``kw`` as input to each deferred value. This function + *clones* the schema it is called upon and returns the cloned + value. The original schema node (the source of the clone) + is not modified.""" + cloned = self.clone() + cloned._bind(kw) + return cloned + + def _bind(self, kw): + for child in self.children: + child._bind(kw) + for k, v in self.__dict__.items(): + if isinstance(v, deferred): + v = v(self, kw) + setattr(self, k, v) + if getattr(self, 'after_bind', None): + self.after_bind(self, kw) + + def __delitem__(self, name): + """ Remove a subnode by name """ + for idx, node in enumerate(self.children[:]): + if node.name == name: + return self.children.pop(idx) + raise KeyError(name) + + def __getitem__(self, name): + """ Get a subnode by name. """ + for node in self.children: + if node.name == name: + return node + raise KeyError(name) + + def __iter__(self): + """ Iterate over the children nodes of this schema node """ + return iter(self.children) + + def __repr__(self): + return '<%s.%s object at %d (named %s)>' % ( + self.__module__, + self.__class__.__name__, + id(self), + self.name, + ) + + class _SchemaMeta(type): def __init__(cls, name, bases, clsattrs): nodes = [] @@ -1339,3 +1386,13 @@ def __new__(cls, *args, **kw): class TupleSchema(Schema): schema_type = Tuple + +class deferred(object): + """ A decorator which can be used to define deferred schema values + (missing values, widgets, validators, etc.)""" + def __init__(self, wrapped): + self.wrapped = wrapped + + def __call__(self, node, kw): + return self.wrapped(node, kw) + diff --git a/colander/tests.py b/colander/tests.py index 6c57cebe..0d17a717 100644 --- a/colander/tests.py +++ b/colander/tests.py @@ -1409,6 +1409,17 @@ def test___getitem__failure(self): node = self._makeOne(None) self.assertRaises(KeyError, node.__getitem__, 'another') + def test___delitem__success(self): + node = self._makeOne(None) + another = self._makeOne(None, name='another') + node.add(another) + del node['another'] + self.assertEqual(node.children, []) + + def test___delitem__failure(self): + node = self._makeOne(None) + self.assertRaises(KeyError, node.__delitem__, 'another') + def test___iter__(self): node = self._makeOne(None) node.children = ['a', 'b', 'c'] @@ -1435,6 +1446,71 @@ def test_clone(self): self.assertEqual(inner_clone.name, 'inner') self.assertEqual(inner_clone.foo, 2) + def test_bind(self): + from colander import deferred + inner_typ = DummyType() + outer_typ = DummyType() + def dv(node, kw): + self.failUnless(node.name in ['outer', 'inner']) + self.failUnless('a' in kw) + return '123' + dv = deferred(dv) + outer_node = self._makeOne(outer_typ, name='outer', missing=dv) + inner_node = self._makeOne(inner_typ, name='inner', validator=dv, + missing=dv) + outer_node.children = [inner_node] + outer_clone = outer_node.bind(a=1) + self.failIf(outer_clone is outer_node) + self.assertEqual(outer_clone.missing, '123') + inner_clone = outer_clone.children[0] + self.failIf(inner_clone is inner_node) + self.assertEqual(inner_clone.missing, '123') + self.assertEqual(inner_clone.validator, '123') + + def test_bind_with_after_bind(self): + from colander import deferred + inner_typ = DummyType() + outer_typ = DummyType() + def dv(node, kw): + self.failUnless(node.name in ['outer', 'inner']) + self.failUnless('a' in kw) + return '123' + dv = deferred(dv) + def remove_inner(node, kw): + self.assertEqual(kw, {'a':1}) + del node['inner'] + outer_node = self._makeOne(outer_typ, name='outer', missing=dv, + after_bind=remove_inner) + inner_node = self._makeOne(inner_typ, name='inner', validator=dv, + missing=dv) + outer_node.children = [inner_node] + outer_clone = outer_node.bind(a=1) + self.failIf(outer_clone is outer_node) + self.assertEqual(outer_clone.missing, '123') + self.assertEqual(len(outer_clone.children), 0) + self.assertEqual(len(outer_node.children), 1) + +class TestDeferred(unittest.TestCase): + def _makeOne(self, wrapped): + from colander import deferred + return deferred(wrapped) + + def test_ctor(self): + wrapped = '123' + inst = self._makeOne(wrapped) + self.assertEqual(inst.wrapped, wrapped) + + def test___call__(self): + n = object() + k = object() + def wrapped(node, kw): + self.assertEqual(node, n) + self.assertEqual(kw, k) + return 'abc' + inst = self._makeOne(wrapped) + result= inst(n, k) + self.assertEqual(result, 'abc') + class TestSchema(unittest.TestCase): def test_alias(self): from colander import Schema diff --git a/docs/api.rst b/docs/api.rst index ccd70463..22e4cf29 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -104,6 +104,12 @@ Schema-Related .. autoclass:: SchemaNode :members: + .. automethod:: __delitem__ + + .. automethod:: __getitem__ + + .. automethod:: __iter__ + .. autoclass:: Schema .. autoclass:: MappingSchema @@ -114,3 +120,6 @@ Schema-Related .. attribute:: null + Represents a null value in colander-related operations. + + diff --git a/docs/binding.rst b/docs/binding.rst new file mode 100644 index 00000000..4243f45b --- /dev/null +++ b/docs/binding.rst @@ -0,0 +1,240 @@ +Schema Binding +============== + +.. note:: Schema binding is new in colander 0.8. + +Sometimes, when you define a schema at module-scope using a ``class`` +statement, you simply don't have enough information to provide +fully-resolved arguments to the :class:`colander.SchemaNode` +constructor. For example, the ``validator`` of a schema node may +depend on a set of values that are only available within the scope of +some function that gets called much later in the process lifetime; +definitely some time very much later than module-scope import. + +You needn't use schema binding at all to deal with this situation. +You can instead mutate a cloned schema object by changing its +attributes and assigning it values (such as widgets, validators, etc) +within the function which has access to the missing values +imperatively within the scope of that function. + +However, if you'd prefer, you can use "deferred" values as SchemaNode +keyword arguments to a schema defined at module scope, and +subsequently use "schema binding" to resolve them later. This can +make your schema seem "more declarative": it allows you to group all +the code that will be run when your schema is used together at module +scope. + +What Is Schema Binding? +----------------------- + +- Values passed to a SchemaNode (e.g. ``description``, ``missing``, + etc.) may be an instance of the ``colander.deferred`` class. + Instances of the ``colander.deferred`` class are callables which + accept two positional arguments: a ``node`` and a ``kw`` dictionary. + +- When a schema node is bound, it is cloned, and any + ``colander.deferred`` values it has as attributes will be resolved. + +- A ``colander.deferred`` value is a callable that accepts two + positional arguments: the schema node being bound and a set of + arbitrary keyword arguments. It should return a value appropriate + for its usage (a widget, a missing value, a validator, etc). + +- Deferred values are not resolved until the schema is bound. + +- Schemas are bound via the :meth:`colander.SchemaNode.bind` method. + For example: ``someschema.bind(a=1, b=2)``. The keyword values + passed to ``bind`` are presented as the value ``kw`` to each + ``colander.deferred`` value found. + +- The schema is bound recursively. Each of the schema node's children + are also bound. + +An Example +---------- + +Let's take a look at an example: + +.. code-block:: python + :linenos: + + import datetime + import colander + import deform + + @colander.deferred + def deferred_date_validator(node, kw): + max_date = kw.get('max_date') + if max_date is None: + max_date = datetime.date.today() + return colander.Range(min=datetime.date.min, max=max_date) + + @colander.deferred + def deferred_date_description(node, kw): + max_date = kw.get('max_date') + if max_date is None: + max_date = datetime.date.today() + return 'Blog post date (no earlier than %s)' % max_date.ctime() + + @colander.deferred + def deferred_date_missing(node, kw): + default_date = kw.get('default_date') + if default_date is None: + default_date = datetime.date.today() + return default_date + + @colander.deferred + def deferred_body_validator(node, kw): + max_bodylen = kw.get('max_bodylen') + if max_bodylen is None: + max_bodylen = 1 << 18 + return colander.Length(max=max_bodylen) + + @colander.deferred + def deferred_body_description(node, kw): + max_bodylen = kw.get('max_bodylen') + if max_bodylen is None: + max_bodylen = 1 << 18 + return 'Blog post body (no longer than %s bytes)' % max_bodylen + + @colander.deferred + def deferred_body_widget(node, kw): + body_type = kw.get('body_type') + if body_type == 'richtext': + widget = deform.widget.RichTextWidget() + else: + widget = deform.widget.TextAreaWidget() + return widget + + @colander.deferred + def deferred_category_validator(node, kw): + categories = kw.get('categories', []) + return colander.OneOf([ x[0] for x in categories ]) + + @colander.deferred + def deferred_category_widget(node, kw): + categories = kw.get('categories', []) + return deform.widget.RadioChoiceWidget(values=categories) + + class BlogPostSchema(colander.Schema): + title = colander.SchemaNode( + colander.String(), + title = 'Title', + description = 'Blog post title', + validator = colander.Length(min=5, max=100), + widget = deform.widget.TextInputWidget(), + ) + date = colander.SchemaNode( + colander.Date(), + title = 'Date', + missing = deferred_date_missing, + description = deferred_date_description, + validator = deferred_date_validator, + widget = deform.widget.DateInputWidget(), + ) + body = colander.SchemaNode( + colander.String(), + title = 'Body', + description = deferred_body_description, + validator = deferred_body_validator, + widget = deferred_body_widget, + ) + category = colander.SchemaNode( + colander.String(), + title = 'Category', + description = 'Blog post category', + validator = deferred_category_validator, + widget = deferred_category_widget, + ) + + schema = BlogPostSchema().bind( + max_date = datetime.date.max, + max_bodylen = 5000, + body_type = 'richtext', + default_date = datetime.date.today(), + categories = [('one', 'One'), ('two', 'Two')] + ) + +To perform binding, the ``bind`` method of a schema node must be +called. ``bind`` returns a *clone* of the schema node (and its +children, recursively), with all ``colander.deferred`` values +resolved. In the above example: + +- The ``date`` node's ``missing`` value will be ``datetime.date.today()``. + +- The ``date`` node's ``validator`` value will a + :class:`colander.Range` validator with a ``max`` of + ``datetime.date.max``. + +- The ``date`` node's ``widget`` will be of the type ``DateInputWidget``. + +- The ``body`` node's ``description`` will be the string ``Blog post + body (no longer than 5000 bytes)``. + +- The ``body`` node's ``validator`` value will a + :class:`colander.Length` validator with a ``max`` of 5000. + +- The ``body`` node's ``widget`` will be of the type ``RichTextWidget``. + +- The ``category`` node's ``validator`` will be of the type + :class:`colander.OneOf`, and its ``choices`` value will be ``['one', + 'two']``. + +- The ``category`` node's ``widget`` will be of the type + ``RadioChoiceWidget``, and the values it will be provided will be + ``[('one', 'One'), ('two', 'Two')]``. + +``after_bind`` +-------------- + +Whenever a cloned schema node has had its values successfully bound, +it can optionally call an ``after_bind`` callback attached to itself. +This can be useful for adding and removing children from schema nodes: + +.. code-block:: python + :linenos: + + def maybe_remove_date(node, kw): + if not kw.get('use_date'): + del node['date'] + + class BlogPostSchema(colander.Schema): + title = colander.SchemaNode( + colander.String(), + title = 'Title', + description = 'Blog post title', + validator = colander.Length(min=5, max=100), + widget = deform.widget.TextInputWidget(), + ) + date = colander.SchemaNode( + colander.Date(), + title = 'Date', + description = 'Date', + widget = deform.widget.DateInputWidget(), + ) + + blog_schema = BlogPostSchema(after_bind=maybe_remove_date) + blog_schema = blog_schema.bind({'use_date':False}) + +An ``after_bind`` callback is called after a clone of this node has +bound all of its values successfully. The above example removes the +``date`` node if the ``use_date`` keyword in the binding keyword +arguments is not true. + +The deepest nodes in the node tree are bound first, so the +``after_bind`` methods of the deepest nodes are called before the +shallowest. + +An ``after_bind`` callback should should accept two values: ``node`` +and ``kw``. ``node`` will be a clone of the bound node object, ``kw`` +will be the set of keywords passed to the ``bind`` method. It usually +operates on the ``node`` it is passed using the API methods described +in :class:`SchemaNode`. + +See Also +-------- + +See also the :meth:`colander.SchemaNode.bind` method and the +description of ``after_bind`` in the documentation of the +:class:`colander.SchemaNode` constructor. + diff --git a/docs/index.rst b/docs/index.rst index 0f29c43b..a09a61bf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,6 +56,7 @@ internationalizable. basics.rst null.rst extending.rst + binding.rst interfaces.rst api.rst glossary.rst