From 514b06a58c93d94fe7a7b2bf1cf9499ba5d6229a Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 8 Sep 2010 20:43:18 +0000 Subject: [PATCH] - 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. --- CHANGES.txt | 10 ++ colander/__init__.py | 95 +++++++++++++---- colander/tests.py | 76 ++++++++++++++ docs/api.rst | 9 ++ docs/binding.rst | 240 +++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 6 files changed, 412 insertions(+), 19 deletions(-) create mode 100644 docs/binding.rst 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