- 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.
This commit is contained in:
Chris McDonough
2010-09-08 20:43:18 +00:00
parent 289ab369c2
commit 514b06a58c
6 changed files with 412 additions and 19 deletions

View File

@@ -7,6 +7,16 @@ Next release
- Docstring fixes to ``colander.SchemaNode`` (``missing`` is not the - Docstring fixes to ``colander.SchemaNode`` (``missing`` is not the
``null`` value when required, it's a special marker value). ``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) 0.7.3 (2010/09/02)
------------------ ------------------

View File

@@ -11,6 +11,7 @@ _ = translationstring.TranslationStringFactory('colander')
_marker = object() _marker = object()
class _null(object): class _null(object):
""" Represents a null value in colander-related operations. """
def __nonzero__(self): def __nonzero__(self):
return False return False
@@ -943,7 +944,7 @@ class GlobalObject(object):
used += '.' + n used += '.' + n
try: try:
found = getattr(found, n) found = getattr(found, n)
except AttributeError: except AttributeError: # pragma: no cover
__import__(used) __import__(used)
found = getattr(found, n) found = getattr(found, n)
@@ -1160,6 +1161,20 @@ class SchemaNode(object):
an object that implements the an object that implements the
:class:`colander.interfaces.Validator` interface. :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 - ``title``: The title of this node. Defaults to a titleization
of the ``name`` (underscores replaced with empty strings and the of the ``name`` (underscores replaced with empty strings and the
first letter of every resulting word capitalized). The title is 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``. - ``widget``: The 'widget' for this node. Defaults to ``None``.
The widget attribute is not interpreted by Colander itself, it The widget attribute is not interpreted by Colander itself, it
is only meaningful to higher-level systems such as Deform. is only meaningful to higher-level systems such as Deform.
""" """
_counter = itertools.count() _counter = itertools.count()
@@ -1190,22 +1206,11 @@ class SchemaNode(object):
self.title = kw.pop('title', self.name.replace('_', ' ').title()) self.title = kw.pop('title', self.name.replace('_', ' ').title())
self.description = kw.pop('description', '') self.description = kw.pop('description', '')
self.widget = kw.pop('widget', None) self.widget = kw.pop('widget', None)
self.after_bind = kw.pop('after_bind', None)
if kw: if kw:
raise TypeError('Unknown keyword arguments: %s' % repr(kw)) raise TypeError('Unknown keyword arguments: %s' % repr(kw))
self.children = list(children) 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 @property
def required(self): def required(self):
""" A property which returns ``True`` if the ``missing`` value """ A property which returns ``True`` if the ``missing`` value
@@ -1268,12 +1273,6 @@ class SchemaNode(object):
""" Add a subnode to this node. """ """ Add a subnode to this node. """
self.children.append(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): def clone(self):
""" Clone the schema node and return the clone. All subnodes """ Clone the schema node and return the clone. All subnodes
are also cloned recursively. Attributes present in node are also cloned recursively. Attributes present in node
@@ -1283,6 +1282,54 @@ class SchemaNode(object):
cloned.children = [ node.clone() for node in self.children ] cloned.children = [ node.clone() for node in self.children ]
return cloned 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): class _SchemaMeta(type):
def __init__(cls, name, bases, clsattrs): def __init__(cls, name, bases, clsattrs):
nodes = [] nodes = []
@@ -1339,3 +1386,13 @@ class SequenceSchema(object):
class TupleSchema(Schema): class TupleSchema(Schema):
schema_type = Tuple 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)

View File

@@ -1409,6 +1409,17 @@ class TestSchemaNode(unittest.TestCase):
node = self._makeOne(None) node = self._makeOne(None)
self.assertRaises(KeyError, node.__getitem__, 'another') 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): def test___iter__(self):
node = self._makeOne(None) node = self._makeOne(None)
node.children = ['a', 'b', 'c'] node.children = ['a', 'b', 'c']
@@ -1435,6 +1446,71 @@ class TestSchemaNode(unittest.TestCase):
self.assertEqual(inner_clone.name, 'inner') self.assertEqual(inner_clone.name, 'inner')
self.assertEqual(inner_clone.foo, 2) 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): class TestSchema(unittest.TestCase):
def test_alias(self): def test_alias(self):
from colander import Schema from colander import Schema

View File

@@ -104,6 +104,12 @@ Schema-Related
.. autoclass:: SchemaNode .. autoclass:: SchemaNode
:members: :members:
.. automethod:: __delitem__
.. automethod:: __getitem__
.. automethod:: __iter__
.. autoclass:: Schema .. autoclass:: Schema
.. autoclass:: MappingSchema .. autoclass:: MappingSchema
@@ -114,3 +120,6 @@ Schema-Related
.. attribute:: null .. attribute:: null
Represents a null value in colander-related operations.

240
docs/binding.rst Normal file
View File

@@ -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.

View File

@@ -56,6 +56,7 @@ internationalizable.
basics.rst basics.rst
null.rst null.rst
extending.rst extending.rst
binding.rst
interfaces.rst interfaces.rst
api.rst api.rst
glossary.rst glossary.rst