]> git.llucax.com Git - software/pymin.git/commitdiff
Add pymin.validatedclass module.
authorLeandro Lucarella <llucax@gmail.com>
Sat, 29 Dec 2007 05:20:25 +0000 (02:20 -0300)
committerLeandro Lucarella <llucax@gmail.com>
Sat, 29 Dec 2007 05:20:25 +0000 (02:20 -0300)
pymin/validatedclass.py [new file with mode: 0644]

diff --git a/pymin/validatedclass.py b/pymin/validatedclass.py
new file mode 100644 (file)
index 0000000..3ad8aec
--- /dev/null
@@ -0,0 +1,293 @@
+# vim: set et sts=4 sw=4 encoding=utf-8 :
+
+r"""Validated classes constructed with declarative style.
+
+This is a black magic module to ease the creation of classes that get
+their attributes validated using formencode validators at creation time
+(providing a constructor) and when updating (via a provided update
+method).
+
+The important classes of this module are Field and ValidatedClass. When
+you'd like a class to validate their attributes, just inherit from
+ValidatedClass and declare all the attributes it will have as class
+attributes using Field instances. For example:
+
+>>> import formencode
+>>> class Test(ValidatedClass):
+>>>     name = Field(formencode.validators.String(not_empty=True))
+>>>     age = Field(formencode.validators.Int(max=110, if_empty=None,
+>>>                                                    if_missing=None))
+>>>     # Some global validation after individual fields validation
+>>>     # (use pre_validator to validate *before* the individual fields
+>>>     # validation)
+>>>     def chained_validator(self, fields, state):
+>>>         if 'Jr' in fields['name'] and fields['age'] > 25:
+>>>             raise formencode.Invalid(u"Junior can't be older than 25 years",
+>>>                                     fields, state, error_dict=dict(
+>>>                                         age='Should not be more than 25'))
+>>> try:
+>>>     # Will fail because 'name' is mandatory
+>>>     t = Test()
+>>>     assert 'It should raised' is False
+>>> except formencode.Invalid, e:
+>>>     print unicode(e), e.error_dict
+>>> t = Test(name='Graham') # Keyword arguments are valid
+>>> assert t.name == 'Graham'
+>>> t = Test('Graham') # But can be used without keywords too!
+>>>                    # Use the order of fields declaration
+>>> assert t.name == 'Graham'
+>>> t = Test('Graham', 20)
+>>> assert t.name == 'Graham' and t.age == 20
+>>> t.update('Graham Jr.') # An update method is provided
+>>> assert t.name == 'Graham Jr.'
+>>> t.update(age=18) # And accepts keyword arguments too
+>>> assert t.age == 18
+>>> # Updates are validated
+>>> try:
+>>>     # Will fail because Junior can't be older than 25 years
+>>>     t.update(age=40)
+>>>     assert 'It should raised' is False
+>>> except formencode.Invalid, e:
+>>>     print unicode(e), e.error_dict
+>>> # Other operations are not
+>>> t.age = 50
+>>> assert t.age == 50
+>>> # But you can use an empty update to validate
+>>> try:
+>>>     # Will fail because Junior can't be older than 25 years
+>>>     t.update()
+>>>     assert 'It should raised' is False
+>>> except formencode.Invalid, e:
+>>>     print unicode(e), e.error_dict
+>>> # You can use the alias validate() too
+>>> try:
+>>>     # Will fail because Junior can't be older than 25 years
+>>>     t.validate()
+>>>     assert 'It should raised' is False
+>>> except formencode.Invalid, e:
+>>>     print unicode(e), e.error_dict
+
+Nice, ugh?
+"""
+
+__ALL__ = ('Field', 'ValidatedClass')
+
+from formencode import Invalid
+from formencode.schema import Schema
+from formencode.validators import FancyValidator, OneOf, CIDR, Int
+
+# FIXME not thread safe (use threadlocal?)
+# This is a counter to preserve the order of declaration of fields (class
+# attributes). When a new Field is instantiated, the Field stores internally
+# the current counter value and increments the counter, so then, when doing
+# the metaclass magic, you can know the order in which the fields were declared
+declarative_count = 0
+
+class Field(object):
+    r"""Field(validator[, doc]) -> Field object
+
+    This is a object used to declare class attributes that will be validated.
+    The only purpose of this class is declaration. After a Field is declared,
+    the metaclass process it and remove it, leaving the attributes as regular
+    objects.
+
+    validator - A field validator. You can use any formencode validator.
+    doc - Document string (not used yet)
+
+    See module documentation for examples of usage.
+    """
+    def __init__(self, validator, doc=None):
+        r"Initialize the object, see the class documentation for details."
+        self.validator = validator
+        self.doc = doc
+        # Set and update the declarative counter
+        global declarative_count
+        self._declarative_count = declarative_count
+        declarative_count += 1
+
+class ValidatedMetaclass(type):
+    r"""ValidatedMetaclass(classname, bases, class_dict) -> type
+
+    This metaclass does the magic behind the scenes. It inspects the class
+    for Field instances, using them to build a validator schema and replacing
+    them with regular objects (None by default). It looks for pre_validator
+    and chained_validator attributes (assuming they are methods), and builds
+    a simple FancyValidator to add them as pre and chained validators to the
+    schema.
+
+    This metaclass add this attributes to the class:
+        class_validator - Schema validator for the class
+        validated_fields - Tuple of declared class fields (preserving order)
+
+    And remove this attributes (if present):
+        pre_validator - Provided pre validator, added to the class_validator
+        chained_validator - Provided chained validator, added too
+
+    This metaclass should be used indirectly inheriting from ValidatedClass.
+    """
+    def __new__(meta, classname, bases, class_dict):
+        # Reset the declarative_count so we can order again the fields
+        # (this is not extrictly necessary, it's just to avoid the counter
+        # to go too high)
+        global declarative_count
+        declarative_count = 0
+        # List of attributes that are Fields
+        fields = [(k, v) for k, v in class_dict.items() if isinstance(v, Field)]
+        # Sort them according to the declarative counter
+        fields.sort(key=lambda i: i[1]._declarative_count)
+        # Validated fields to preserve field order for constructor
+        validated_fields = list()
+        # Create a new validator schema for the new class
+        schema = Schema()
+        for name, field in fields:
+            validated_fields.append(name)
+            # We don't want the class attribute to be a Field
+            class_dict[name] = None
+            # But we want its validator to go into the schema
+            schema.add_field(name, field.validator)
+        # Check if the class has a pre and/or chained validators to check if
+        # the class is valid as a whole before/after (respectively) validating
+        # each individual field
+        for key, add_to_schema in (('pre_validator', schema.add_pre_validator),
+                        ('chained_validator', schema.add_chained_validator)):
+            if key in class_dict:
+                # Create a simple fancy validator
+                class Validator(FancyValidator):
+                    validate_python = class_dict[key]
+                # And add it to the schema's special validators
+                add_to_schema(Validator)
+                # We don't need the validator in the class anymore
+                del class_dict[key]
+        # Now we add the special new attributes to the new class
+        class_dict['validated_fields'] = tuple(validated_fields)
+        class_dict['class_validator'] = schema
+        return type.__new__(meta, classname, bases, class_dict)
+
+def join_args(args, names, kwargs):
+    r"""join_args(args, names, kwargs) -> dict
+
+    This is a helper function to join positional arguments ('args') to keyword
+    arguments ('kwargs'). This is done using the 'names' list, which maps
+    positional arguments indexes to keywords. It *modifies* kwargs to add the
+    positional arguments using the mapped keywords (and checking for
+    duplicates). The number of argument passed is checked too (it should't be
+    greater than len(names). Extra keywords are not checked though, because it
+    assumes the validator schema takes care of that.
+
+    args - Positional arguments.
+    names - list of keywords.
+    kwargs - Keywords arguments.
+    """
+    if len(args) > len(names):
+        raise Invalid('Too many arguments', args, None)
+    for i in range(len(args)):
+        if names[i] in kwargs:
+            raise Invalid("Duplicated value for argument '%s'" % names[i],
+                                    kwargs[names[i]], None)
+        kwargs[names[i]] = args[i]
+    return kwargs
+
+class ValidatedClass(object):
+    r"""ValidatedClass(*args, **kw) -> ValidatedClass object
+
+    You should inherit your classes from this one, declaring the class
+    attributes using the Field class to specify a validator for each
+    attribute.
+
+    Please see the module documentation for details and examples of usage.
+    """
+
+    __metaclass__ = ValidatedMetaclass
+
+    def __init__(self, *args, **kw):
+        r"Initialize and validate the object, see the class documentation."
+        for name in self.validated_fields:
+            # Create all the attributes
+            setattr(self, name, None)
+        # Update the attributes with the arguments passed
+        self.update(**join_args(args, self.validated_fields, kw))
+
+    def update(self, *args, **kw):
+        r"update(*args, **kw) - Update objects attributes validating them."
+        # Get class attributes as a dict
+        attrs = dict([(k, getattr(self, k)) for k in self.validated_fields])
+        # Update the dict with the arguments passed
+        attrs.update(join_args(args, self.validated_fields, kw))
+        # Validate the resulting dict
+        attrs = self.class_validator.to_python(attrs)
+        # If we are here, there were no errors, so update the real attributes
+        for k, v in attrs.items():
+            setattr(self, k, v)
+
+    def validate(self):
+        r"validate() - Validate the object's attributes."
+        self.update()
+
+
+if __name__ == '__main__':
+
+    import formencode
+
+    class Test(ValidatedClass):
+        name = Field(formencode.validators.String(not_empty=True))
+        age = Field(formencode.validators.Int(max=110, if_empty=None,
+                                                       if_missing=None))
+        # Some global validation after individual fields validation
+        def chained_validator(self, fields, state):
+            if 'Jr' in fields['name'] and fields['age'] > 25:
+                raise formencode.Invalid(u"Junior can't be older than 25 years",
+                                        fields, state, error_dict=dict(
+                                            age='Should not be more than 25'))
+
+    try:
+        # Will fail because 'name' is mandatory
+        t = Test()
+        assert 'It should raised' is False
+    except formencode.Invalid, e:
+        print unicode(e), e.error_dict
+
+    t = Test(name='Graham') # Keyword arguments are valid
+    assert t.name == 'Graham'
+
+    t = Test('Graham') # But can be used without keywords too!
+                       # Use the order of fields declaration
+    assert t.name == 'Graham'
+
+    t = Test('Graham', 20)
+    assert t.name == 'Graham' and t.age == 20
+
+    t.update('Graham Jr.') # An update method is provided
+    assert t.name == 'Graham Jr.'
+
+    t.update(age=18) # And accepts keyword arguments too
+    assert t.age == 18
+
+    # Updates are validated
+    try:
+        # Will fail because Junior can't be older than 25 years
+        t.update(age=40)
+        assert 'It should raised' is False
+    except formencode.Invalid, e:
+        print unicode(e), e.error_dict
+
+    # Other operations are not
+    t.age = 50
+    assert t.age == 50
+
+    # But you can use an empty update to validate
+    try:
+        # Will fail because Junior can't be older than 25 years
+        t.update()
+        assert 'It should raised' is False
+    except formencode.Invalid, e:
+        print unicode(e), e.error_dict
+
+    # You can use the alias validate() too
+    try:
+        # Will fail because Junior can't be older than 25 years
+        t.validate()
+        assert 'It should raised' is False
+    except formencode.Invalid, e:
+        print unicode(e), e.error_dict
+
+