diff --git a/CHANGES.txt b/CHANGES.txt index 9ca578a..c2a682f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -14,6 +14,192 @@ Features - Add Python 3.3 to tox configuration and use newer tox testing regime (setup.py dev). +- Calling ``bind`` on a schema node e.g. ``cloned_node = somenode.bind(a=1, + b=2)`` on a schema node now results in the cloned node having a + ``bindings`` attribute of the value ``{'a':1, 'b':2}``. + +- It is no longer necessary to pass a ``typ`` argument to a SchemaNode + constructor if the node class has a ``__schema_type__`` callable as a class + attribute which, when called with no arguments, returns a schema type. + This callable will be called to obtain the schema type if a ``typ`` is not + supplied to the constructor. The default ``SchemaNode`` object's + ``__schema_type__`` callable raises a ``NotImplementedError`` when it is + called. + +- SchemaNode now has a ``raise_invalid`` method which accepts a message and + raises a colander.Invalid exception using ``self`` as the node and the + message as its message. + +- It is now possible and advisable to subclass ``SchemaNode`` in order to + create a bundle of default node behavior. The subclass can define the + following methods and attributes: ``preparer``, ``validator``, ``default``, + ``missing``, ``name``, ``title``, ``description``, ``widget``, and + ``after_bind``. For example, the older, more imperative style that + looked like this still works:: + + from colander import SchemaNode + + ranged_int = colander.SchemaNode( + validator=colander.Range(0, 10), + default = 10, + title='Ranged Int' + ) + + But you can alternately now do something like:: + + from colander import SchemaNode + + class RangedIntSchemaNode(SchemaNode): + validator = colander.range(0, 10) + default = 10 + title = 'Ranged Int' + + ranged_int = RangedInt() + + Values that are expected to be callables can be methods of the schemanode + subclass instead of plain attributes:: + + from colander import SchemaNode + + class RangedIntSchemaNode(SchemaNode): + default = 10 + title = 'Ranged Int' + + def validator(self, node, cstruct): + if not 0 < cstruct < 10: + raise colander.Invalid(node, 'Must be between 0 and 10') + + ranged_int = RangedInt() + + When implementing a method value that expects ``node``, ``node`` must be + provided in the call signature, even though ``node`` will almost always be + the same as ``self``. This is because Colander simply treats the method + as another kind of callable, be it a method, or a function, or an instance + that has a ``__call__`` method. It doesn't care that it happens to be a + method of ``self``, and it needs to support callables that are not + methods, so it sends ``node`` in regardless. + + Normal inheritance rules apply to class attributes and methods defined in + a schemanode subclass. If your schemanode subclass inherits from another + schemanode class, your schemanode subclass' methods and class attributes + will override the superclass' methods and class attributes. + + Method values that need to be deferred for binding cannot currently be + implemented as ``colander.deferred`` callables. For example this will + *not* work:: + + from colander import SchemaNode + + class RangedIntSchemaNode(SchemaNode): + default = 10 + title = 'Ranged Int' + + @colander.deferred + def validator(self, node, kw): + request = kw['request'] + def avalidator(node, cstruct): + if not 0 < cstruct < 10: + if request.user != 'admin': + raise colander.Invalid(node, 'Must be between 0 and 10') + return avalidator + + ranged_int = RangedInt() + bound_ranged_int = ranged_int.bind(request=request) + + This will result in:: + + TypeError: avalidator() takes exactly 3 arguments (2 given) + + Instead of trying to defer methods via a decorator, you can instead use + the ``bindings`` attribute of ``self`` to obtain access to the bind + parameters within values that are methody:: + + from colander import SchemaNode + + class RangedIntSchemaNode(SchemaNode): + default = 10 + title = 'Ranged Int' + + def validator(self, node, cstruct): + request = self.bindings['request'] + if not 0 < cstruct < 10: + if request.user != 'admin': + raise colander.Invalid(node, 'Must be between 0 and 10') + + ranged_int = RangedInt() + bound_range_int = ranged_int.bind(request=request) + + You can use ``after_bind`` to set attributes of the schemanode that rely + on binding variables, such as ``missing`` and ``default``:: + + from colander import SchemaNode + + class RangedIntSchemaNode(SchemaNode): + default = 10 + title = 'Ranged Int' + + def validator(self, node, cstruct): + request = self.bindings['request'] + if not 0 < cstruct < 10: + if request.user != 'admin': + raise colander.Invalid(node, 'Must be between 0 and 10') + + def after_bind(self, node, kw): + self.request = kw['request'] + self.default = self.request.user.id + + Non-method values can still be implemented as ``colander.deferred`` + however:: + + from colander import SchemaNode + + def _missing(node, kw): + request = kw['request'] + if request.user.name == 'admin': + return 10 + return 20 + + class RangedIntSchemaNode(SchemaNode): + default = 10 + title = 'Ranged Int' + missing = colander.deferred(_missing) + + ranged_int = RangedInt() + + You can override the default values of a schemanode subclass in its + constructor:: + + from colander import SchemaNode + + class RangedIntSchemaNode(SchemaNode): + default = 10 + title = 'Ranged Int' + validator = colander.Range(0, 10) + + ranged_int = RangedInt(validator=colander.Range(0, 20)) + + In the above example, the validation will be done on 0-20, not 0-10. + + If your schema node names conflict with schema value attribute names, you + can work around it with the ``name`` argument to the schema node:: + + from colander import SchemaNode, Schema + + class TitleNode(SchemaNode): + validator = colander.range(0, 10) + default = 10 + + class SomeSchema(Schema): + title = 'Some Schema' + thisnamewontmatter = TitleNode(name='title') + +Backwards Incompatibilities +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Passing non-SchemaNode derivative instances as ``*children`` into a + SchemaNode constructor is no longer supported. Symptom: ``AttributeError: + name`` when constructing a SchemaNode. + 0.9.9 (2012-09-24) ------------------ diff --git a/colander/__init__.py b/colander/__init__.py index 7686d14..eb8348e 100644 --- a/colander/__init__.py +++ b/colander/__init__.py @@ -1540,17 +1540,35 @@ class Time(SchemaType): def timeparse(t, format): return datetime.datetime(*time.strptime(t, format)[0:6]).time() -class SchemaNode(object): +def _add_node_children(node, children): + for n in children: + insert_before = getattr(n, 'insert_before', None) + exists = node.get(n.name, _marker) is not _marker + # use exists for microspeed; we could just call __setitem__ + # exclusively, but it does an enumeration that's unnecessary in the + # common (nonexisting) case (.add is faster) + if insert_before is None: + if exists: + node[n.name] = n + else: + node.add(n) + else: + if exists: + del node[n.name] + node.add_before(insert_before, n) + +class _SchemaNode(object): """ Fundamental building block of schemas. The constructor accepts these positional arguments: - - ``typ`` (required): The 'type' for this node. It should be an + - ``typ``: The 'type' for this node. It should be an instance of a class that implements the - :class:`colander.interfaces.Type` interface. + :class:`colander.interfaces.Type` interface. If ``typ`` is not passed, + it defaults to ``colander.Mapping()``. - - ``children``: a sequence of subnodes. If the subnodes of this + - ``*children``: a sequence of subnodes. If the subnodes of this node are not known at construction time, they can later be added via the ``add`` method. @@ -1607,29 +1625,49 @@ class SchemaNode(object): """ _counter = itertools.count() + preparer = None + validator = None + default = null + missing = required + name = '' + raw_title = _marker + title = '' + description = '' + widget = None + after_bind = None + bindings = None - def __new__(cls, *arg, **kw): - inst = object.__new__(cls) - inst._order = next(cls._counter) - return inst + def __new__(cls, *args, **kw): + node = object.__new__(cls) + node._order = next(cls._counter) + node.children = [] + _add_node_children(node, cls.__all_schema_nodes__) + return node - def __init__(self, typ, *children, **kw): - self.typ = typ - self.preparer = kw.pop('preparer', None) - self.validator = kw.pop('validator', None) - self.default = kw.pop('default', null) - self.missing = kw.pop('missing', required) - self.name = kw.pop('name', '') - self.raw_title = kw.pop('title', _marker) - if self.raw_title is _marker: - self.title = self.name.replace('_', ' ').title() + def __init__(self, *arg, **kw): + # bw compat forces us to treat first arg as type always + if arg: + self.typ = arg[0] + _add_node_children(self, arg[1:]) else: - self.title = self.raw_title - self.description = kw.pop('description', '') - self.widget = kw.pop('widget', None) - self.after_bind = kw.pop('after_bind', None) + self.typ = self.__schema_type__() + + # bw compat forces us to manufacture a title if one is not supplied + title = kw.get('title', _marker) + if title is _marker: + name = kw.get('name', self.name) + kw['title'] = name.replace('_', ' ').title() + else: + kw['raw_title'] = title + self.__dict__.update(kw) - self.children = list(children) + + @staticmethod + def __schema_type__(): + raise NotImplementedError( + 'Schema node construction without a typ argument or ' + 'a __schema_node__ callable present on the node class ' + ) @property def required(self): @@ -1787,9 +1825,12 @@ class SchemaNode(object): return cloned def _bind(self, kw): + self.bindings = kw for child in self.children: child._bind(kw) - for k, v in self.__dict__.items(): + names = dir(self) + for k in names: + v = getattr(self, k) if isinstance(v, deferred): v = v(self, kw) setattr(self, k, v) @@ -1857,77 +1898,66 @@ class SchemaNode(object): self.name, ) + def raise_invalid(self, msg, node=None): + """ Raise a :exc:`colander.Invalid` exception with the message + ``msg``. ``node``, if supplied, should be an instance of a + :class:`colander.SchemaNode`. If it is not supplied, ``node`` will + be this node. Example usage:: + + class CustomSchemaNode(SchemaNode): + def validator(self, node, cstruct): + if cstruct != 'the_right_thing': + self.raise_invalid('Not the right thing') + + """ + if node is None: + node = self + raise Invalid(node, msg) + class _SchemaMeta(type): def __init__(cls, name, bases, clsattrs): nodes = [] for name, value in clsattrs.items(): - if isinstance(value, SchemaNode): + if isinstance(value, _SchemaNode): + delattr(cls, name) if not value.name: value.name = name if value.raw_title is _marker: value.title = name.replace('_', ' ').title() nodes.append((value._order, value)) - cls.__schema_nodes__ = nodes + cls.__class_schema_nodes__ = nodes # Combine all attrs from this class and its subclasses. extended = [] for c in cls.__mro__: - extended.extend(getattr(c, '__schema_nodes__', [])) + extended.extend(getattr(c, '__class_schema_nodes__', [])) # Sort the attrs to maintain the order as defined, and assign to the # class. extended.sort() - cls.nodes = [x[1] for x in extended] + cls.__all_schema_nodes__ = [x[1] for x in extended] -def _Schema__new__(cls, *args, **kw): - node = object.__new__(cls.node_type) - node.name = None - node._order = next(SchemaNode._counter) - typ = cls.schema_type() - node.__init__(typ, *args, **kw) - for n in cls.nodes: - insert_before = getattr(n, 'insert_before', None) - exists = node.get(n.name, _marker) is not _marker - # use exists for microspeed; we could just call __setitem__ - # exclusively, but it does an enumeration that's unnecessary in the - # common (nonexisting) case (.add is faster) - if insert_before is None: - if exists: - node[n.name] = n - else: - node.add(n) - else: - if exists: - del node[n.name] - node.add_before(insert_before, n) - return node - -Schema = _SchemaMeta('Schema', (object,), - dict(schema_type=Mapping, - node_type=SchemaNode, - __new__=_Schema__new__)) +# metaclass spelling compatibility across Python 2 and Python 3 +SchemaNode = _SchemaMeta( + 'SchemaNode', + (_SchemaNode,), + {} + ) + +class Schema(SchemaNode): + __schema_type__ = Mapping MappingSchema = Schema +class TupleSchema(SchemaNode): + __schema_type__ = Tuple -def _SequenceSchema__new__(cls, *args, **kw): - node = object.__new__(cls.node_type) - node.name = None - node._order = next(SchemaNode._counter) - typ = cls.schema_type() - node.__init__(typ, *args, **kw) - if not len(cls.nodes) == 1: - raise Invalid(node, - 'Sequence schemas must have exactly one child node') - for n in cls.nodes: - node.add(n) - return node +class SequenceSchema(SchemaNode): + __schema_type__ = Sequence -SequenceSchema = _SchemaMeta('SequenceSchema', (object,), - dict(schema_type=Sequence, - node_type=SchemaNode, - __new__=_SequenceSchema__new__)) - -class TupleSchema(Schema): - schema_type = Tuple + def __init__(self, *args, **kw): + SchemaNode.__init__(self, *args, **kw) + if len(self.children) != 1: + raise Invalid(self, + 'Sequence schemas must have exactly one child node') class deferred(object): """ A decorator which can be used to define deferred schema values diff --git a/colander/compat.py b/colander/compat.py index c00b0f4..e969abd 100644 --- a/colander/compat.py +++ b/colander/compat.py @@ -5,7 +5,7 @@ PY3 = sys.version_info[0] == 3 if PY3: # pragma: no cover string_types = str, text_type = str -else: +else: # pragma: no cover string_types = basestring, text_type = unicode @@ -21,7 +21,7 @@ if PY3: # pragma: no cover if isinstance(v, str): return False return hasattr(v, '__iter__') -else: +else: # pragma: no cover def is_nonstr_iter(v): return hasattr(v, '__iter__') diff --git a/colander/tests/test_colander.py b/colander/tests/test_colander.py index 3b15683..41fd19e 100644 --- a/colander/tests/test_colander.py +++ b/colander/tests/test_colander.py @@ -2033,10 +2033,11 @@ class TestSchemaNode(unittest.TestCase): self.assertTrue(hasattr(node, '_order')) def test_ctor_no_title(self): - node = self._makeOne(None, 0, validator=1, default=2, name='name_a', + child = DummySchemaNode(None, name='fred') + node = self._makeOne(None, child, validator=1, default=2, name='name_a', missing='missing') self.assertEqual(node.typ, None) - self.assertEqual(node.children, [0]) + self.assertEqual(node.children, [child]) self.assertEqual(node.validator, 1) self.assertEqual(node.default, 2) self.assertEqual(node.missing, 'missing') @@ -2044,36 +2045,40 @@ class TestSchemaNode(unittest.TestCase): self.assertEqual(node.title, 'Name A') def test_ctor_with_title(self): - node = self._makeOne(None, 0, validator=1, default=2, name='name', + child = DummySchemaNode(None, name='fred') + node = self._makeOne(None, child, validator=1, default=2, name='name', title='title') self.assertEqual(node.typ, None) - self.assertEqual(node.children, [0]) + self.assertEqual(node.children, [child]) self.assertEqual(node.validator, 1) self.assertEqual(node.default, 2) self.assertEqual(node.name, 'name') self.assertEqual(node.title, 'title') def test_ctor_with_description(self): - node = self._makeOne(None, 0, validator=1, default=2, name='name', + node = self._makeOne(None, validator=1, default=2, name='name', title='title', description='desc') self.assertEqual(node.description, 'desc') def test_ctor_with_widget(self): - node = self._makeOne(None, 0, widget='abc') + node = self._makeOne(None, widget='abc') self.assertEqual(node.widget, 'abc') def test_ctor_with_preparer(self): - node = self._makeOne(None, 0, preparer='abc') + node = self._makeOne(None, preparer='abc') self.assertEqual(node.preparer, 'abc') def test_ctor_without_preparer(self): - node = self._makeOne(None, 0) + node = self._makeOne(None) self.assertEqual(node.preparer, None) def test_ctor_with_unknown_kwarg(self): - node = self._makeOne(None, 0, foo=1) + node = self._makeOne(None, foo=1) self.assertEqual(node.foo, 1) + def test_ctor_without_type(self): + self.assertRaises(NotImplementedError, self._makeOne) + def test_required_true(self): node = self._makeOne(None) self.assertEqual(node.required, True) @@ -2367,7 +2372,119 @@ class TestSchemaNode(unittest.TestCase): node = self._makeOne(typ) self.assertEqual(node.cstruct_children(None), []) self.assertEqual(len(w), 1) + + def test_raise_invalid(self): + import colander + typ = DummyType() + node = self._makeOne(typ) + self.assertRaises(colander.Invalid, node.raise_invalid, 'Wrong') + +class TestSchemaNodeSubcassing(unittest.TestCase): + def test_subclass_uses_validator_method(self): + import colander + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + name = 'my' + def validator(self, node, cstruct): + if cstruct > 10: + self.raise_invalid('Wrong') + node = MyNode() + self.assertRaises(colander.Invalid, node.deserialize, 20) + + def test_subclass_uses_missing(self): + import colander + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + name = 'my' + missing = 10 + node = MyNode() + result = node.deserialize(colander.null) + self.assertEqual(result, 10) + + def test_subclass_value_overridden_by_constructor(self): + import colander + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + name = 'my' + missing = 10 + node = MyNode(missing=5) + result = node.deserialize(colander.null) + self.assertEqual(result, 5) + def test_method_values_can_rely_on_binding(self): + import colander + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + def amethod(self): + return self.bindings['request'] + + node = MyNode() + newnode = node.bind(request=True) + self.assertEqual(newnode.amethod(), True) + + def test_nonmethod_values_can_rely_on_after_bind(self): + import colander + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + def after_bind(self, node, kw): + self.missing = kw['missing'] + + node = MyNode() + newnode = node.bind(missing=10) + self.assertEqual(newnode.deserialize(colander.null), 10) + + def test_deferred_methods_dont_quite_work_yet(self): + import colander + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + @colander.deferred + def avalidator(self, node, kw): + def _avalidator(node, cstruct): + self.raise_invalid('Foo') + return _avalidator + + node = MyNode() + self.assertRaises(TypeError, node.bind) + + def test_nonmethod_values_can_be_deferred_though(self): + import colander + def _missing(node, kw): + return 10 + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Int + missing = colander.deferred(_missing) + + node = MyNode() + bound_node = node.bind() + self.assertEqual(bound_node.deserialize(colander.null), 10) + + def test_schema_child_names_conflict_with_value_names_notused(self): + import colander + def _missing(node, kw): + return 10 + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Mapping + title = colander.SchemaNode( + colander.String(), + ) + node = MyNode() + self.assertEqual(node.title, '') + + def test_schema_child_names_conflict_with_value_names_used(self): + import colander + def _missing(node, kw): + return 10 + doesntmatter = colander.SchemaNode( + colander.String(), + name='name', + ) + class MyNode(colander.SchemaNode): + __schema_type__ = colander.Mapping + name = 'fred' + wontmatter = doesntmatter + node = MyNode() + self.assertEqual(node.name, 'fred') + self.assertEqual(node['name'], doesntmatter) class TestMappingSchemaInheritance(unittest.TestCase): def test_single_inheritance(self): @@ -2508,7 +2625,7 @@ class TestSchema(unittest.TestCase): node = MySchema(default='abc') self.assertTrue(hasattr(node, '_order')) self.assertEqual(node.default, 'abc') - self.assertEqual(node.__class__, colander.SchemaNode) + self.assertTrue(isinstance(node, colander.SchemaNode)) self.assertEqual(node.typ.__class__, colander.Mapping) self.assertEqual(node.children[0].typ.__class__, colander.String) self.assertEqual(node.children[0].title, 'Thing A') @@ -2535,7 +2652,7 @@ class TestSequenceSchema(unittest.TestCase): inner = _inner node = MySchema() self.assertTrue(hasattr(node, '_order')) - self.assertEqual(node.__class__, colander.SchemaNode) + self.assertTrue(isinstance(node, colander.SchemaNode)) self.assertEqual(node.typ.__class__, colander.Sequence) self.assertEqual(node.children[0], _inner) @@ -2567,7 +2684,7 @@ class TestTupleSchema(unittest.TestCase): thing = colander.SchemaNode(colander.String()) node = MySchema() self.assertTrue(hasattr(node, '_order')) - self.assertEqual(node.__class__, colander.SchemaNode) + self.assertTrue(isinstance(node, colander.SchemaNode)) self.assertEqual(node.typ.__class__, colander.Tuple) self.assertEqual(node.children[0].typ.__class__, colander.String) @@ -2895,6 +3012,51 @@ class TestDeclarative(unittest.TestCase, TestFunctional): schema = MainSchema(name=name) return schema +class TestUltraDeclarative(unittest.TestCase, TestFunctional): + + def _makeSchema(self, name='schema'): + + import colander + + class IntSchema(colander.SchemaNode): + __schema_type__ = colander.Int + + class StringSchema(colander.SchemaNode): + __schema_type__ = colander.String + + class TupleSchema(colander.TupleSchema): + tupint = IntSchema() + tupstring = StringSchema() + + class MappingSchema(colander.MappingSchema): + key = IntSchema() + key2 = IntSchema() + + class SequenceOne(colander.SequenceSchema): + tup = TupleSchema() + + class SequenceTwo(colander.SequenceSchema): + mapping = MappingSchema() + + class IntSchemaRanged(IntSchema): + validator = colander.Range(0, 10) + + class GlobalObjectSchema(colander.SchemaNode): + def __schema_type__(self): + return colander.GlobalObject(package=colander) + + class MainSchema(colander.MappingSchema): + int = IntSchemaRanged() + ob = GlobalObjectSchema() + seq = SequenceOne() + tup = TupleSchema() + seq2 = SequenceTwo() + + MainSchema.name = name + + schema = MainSchema() + return schema + class Test_null(unittest.TestCase): def test___nonzero__(self): from colander import null