Re: XRI validators

Gustavo Narea <me-essWiSEiJ2uHaexy/[email protected]> Thu, 28 Aug 2008 22:15:23 +0200
Newsgroups gmane.comp.python.formencode
Message-ID <[email protected]>
Hi,

I've attached the patch for the XRI and OpenId validators.

Cheers!

On Thursday August 28, 2008 16:49:15 Ian Bicking wrote:
> Sure, send away.
>
> Gustavo Narea wrote:
> > Hello,
> >
> > I have written an FormEncode-based XRI validator (with support for both
> > i-names and i-numbers) and I think it may be worth including it in the
> > next release of FormEncode.
> >
> > If you want it, I'll send you the patch against your main development
> > branch.

-- 
Gustavo Narea.
http://gustavonarea.net/

Get rid of unethical constraints! Switch to Freedomware:
http://softwareliberty.com/

-------------------------------------------------------------------------
This SF.Net email is sponsored by the Moblin Your Move Developer's challenge
Build the coolest Linux based applications with Moblin SDK & win great prizes
Grand prize is a trip for two to an Open Source event anywhere in the world
http://moblin-contest.org/redirect.php?banner_id=100&url=/

_______________________________________________
FormEncode-discuss mailing list
FormEncode-discuss-5NWGOfrQmneRv+LV9MX5uipxlwaOVQ5f@public.gmane.org
https://lists.sourceforge.net/lists/listinfo/formencode-discuss
openid_xri_formencode.patch (text/x-patch, 15.9 KB)
Index: formencode/validators.py
===================================================================
--- formencode/validators.py	(revision 3562)
+++ formencode/validators.py	(working copy)
@@ -1515,6 +1515,214 @@
                     self.message('status', state, status=res.status),
                     state, url)
 
+    
+class XRI(FancyValidator):
+    r"""
+    Validator for XRIs.
+    
+    It supports both i-names and i-numbers, of the first version of the XRI
+    standard.
+    
+    ::
+        
+        >>> inames = XRI(xri_type="i-name")
+        >>> inames.to_python("   =John.Smith ")
+        '=John.Smith'
+        >>> inames.to_python("@Free.Software.Foundation")
+        '@Free.Software.Foundation'
+        >>> inames.to_python("Python.Software.Foundation")
+        Traceback (most recent call last):
+            ...
+        Invalid: The type of i-name is not defined; it may be either individual or organizational
+        >>> inames.to_python("http://example.org")
+        Traceback (most recent call last):
+            ...
+        Invalid: The type of i-name is not defined; it may be either individual or organizational
+        >>> inames.to_python("=!2C43.1A9F.B6F6.E8E6")
+        Traceback (most recent call last):
+            ...
+        Invalid: "!2C43.1A9F.B6F6.E8E6" is an invalid i-name
+        >>> iname_with_schema = XRI(True, xri_type="i-name")
+        >>> iname_with_schema.to_python("=Richard.Stallman")
+        'xri://=Richard.Stallman'
+        >>> inames.to_python("=John Smith")
+        Traceback (most recent call last):
+            ...
+        formencode.api.Invalid: "John Smith" is an invalid i-name
+        >>> inumbers = XRI(xri_type="i-number")
+        >>> inumbers.to_python("!!1000!de21.4536.2cb2.8074")
+        '!!1000!de21.4536.2cb2.8074'
+        >>> inumbers.to_python("@!1000.9554.fabd.129c!2847.df3c")
+        '@!1000.9554.fabd.129c!2847.df3c'
+    
+    """
+    
+    iname_valid_pattern = re.compile(r"""
+    ^
+    [\w]+                  # A global alphanumeric i-name
+    (\.[\w]+)*             # An i-name with dots
+    (\*[\w]+(\.[\w]+)*)*   # A community i-name
+    $
+    """, re.VERBOSE|re.UNICODE)
+    
+    
+    iname_invalid_start = re.compile(r"^[\d\.-]", re.UNICODE)
+    """@cvar: These characters must not be at the beggining of the i-name"""
+    
+    inumber_pattern = re.compile(r"""
+    ^
+    (
+    [=@]!       # It's a personal or organization i-number
+    |
+    !!          # It's a network i-number
+    )
+    [\dA-F]{1,4}(\.[\dA-F]{1,4}){0,3}       # A global i-number
+    (![\dA-F]{1,4}(\.[\dA-F]{1,4}){0,3})*   # Zero or more sub i-numbers
+    $
+    """, re.VERBOSE|re.IGNORECASE)
+    
+    messages = {
+        'noType': _("The type of i-name is not defined; it may be either individual or organizational"),
+        'repeatedChar': _("Dots and dashes may not be repeated consecutively"),
+        'badIname': _('"%(iname)s" is an invalid i-name'),
+        'badInameStart': _("i-names may not start with numbers nor punctuation "
+                           "marks"),
+        'badInumber': _('"%(inumber)s" is an invalid i-number'),
+        'badType': _("The XRI must be a string (not a %(type)s: %(value)r)"),
+        'badXri': _('"%(xri_type)s" is not a valid type of XRI')
+        }
+    
+    def __init__(self, add_xri=False, xri_type="i-name", **kwargs):
+        """Create an XRI validator.
+        
+        @param add_xri: Should the schema be added if not present? Officially
+            it's optional.
+        @type add_xri: C{bool}
+        @param xri_type: What type of XRI should be validated? Possible values:
+            C{i-name} or C{i-number}.
+        @type xri_type: C{str}
+        
+        """
+        self.add_xri = add_xri
+        assert xri_type in ('i-name', 'i-number'), \
+                           ('xri_type must be "i-name" or "i-number"')
+        self.xri_type = xri_type
+        super(XRI, self).__init__(**kwargs)
+    
+    def _to_python(self, value, state):
+        """Prepend the 'xri://' schema if necessary and then remove trailing
+        spaces"""
+        value = value.strip()
+        if self.add_xri and not value.startswith("xri://"):
+            value = "xri://" + value
+        return value
+    
+    def validate_python(self, value, state=None):
+        """Validate an XRI
+        
+        @raise Invalid: If at least one of the following conditions in met:
+            - C{value} is not a string.
+            - The XRI is not a personal, organizational or network one.
+            - The relevant validator (i-name or i-number) considers the XRI
+                is not valid.
+        
+        """
+        if not (isinstance(value, str) or isinstance(value, unicode)):
+            raise Invalid(self.message("badType", state, type=str(type(value)),
+                                       value=value),
+                          value, state)
+        
+        # Let's remove the schema, if any
+        if value.startswith("xri://"):
+            value = value[6:]
+        
+        if not value[0] in ('@', '=') and not (self.xri_type == "i-number" \
+        and value[0] == '!'):
+            raise Invalid(self.message("noType", state), value, state)
+        
+        if self.xri_type == "i-name":
+            self._validate_iname(value, state)
+        else:
+            self._validate_inumber(value, state)
+    
+    def _validate_iname(self, iname, state):
+        """Validate an i-name"""
+        # The type is not required here:
+        iname = iname[1:]
+        if ".." in iname or "--" in iname:
+            raise Invalid(self.message("repeatedChar", state), iname, state)
+        if self.iname_invalid_start.match(iname):
+            raise Invalid(self.message("badInameStart", state), iname, state)
+        if not self.iname_valid_pattern.match(iname) or "_" in iname:
+            raise Invalid(self.message("badIname", state, iname=iname), iname,
+                          state)
+    
+    def _validate_inumber(self, inumber, state):
+        """Validate an i-number"""
+        if not self.__class__.inumber_pattern.match(inumber):
+            raise Invalid(self.message("badInumber", state, inumber=inumber,
+                                       value=inumber),
+                          inumber, state)
+
+
+class OpenId(FancyValidator):
+    r"""
+    OpenId validator.
+    
+    ::
+        >>> v = OpenId(add_schema=True)
+        >>> v.to_python(' example.net ')
+        'http://example.net'
+        >>> v.to_python('@TurboGears')
+        'xri://@TurboGears'
+        >>> w = OpenId(add_schema=False)
+        >>> w.to_python(' example.net ')
+        Traceback (most recent call last):
+        ...
+        Invalid: "example.net" is not a valid OpenId (it is neither an URL nor an XRI)
+        >>> w.to_python('!!1000')
+        '!!1000'
+        >>> w.to_python('[email protected]')
+        Traceback (most recent call last):
+        ...
+        Invalid: "[email protected]" is not a valid OpenId (it is neither an URL nor an XRI)
+    
+    """
+    
+    messages = {
+        'badId': _('"%(id)s" is not a valid OpenId (it is neither an URL nor an XRI)')
+        }
+    
+    def __init__(self, add_schema=False, **kwargs):
+        """Create an OpenId validator.
+        
+        @param add_schema: Should the schema be added if not present?
+        @type add_schema: C{bool}
+        
+        """
+        self.url_validator = URL(add_http=add_schema)
+        self.iname_validator = XRI(add_schema, xri_type="i-name")
+        self.inumber_validator = XRI(add_schema, xri_type="i-number")
+    
+    def _to_python(self, value, state):
+        value = value.strip()
+        try:
+            return self.url_validator.to_python(value, state)
+        except Invalid:
+            try:
+                return self.iname_validator.to_python(value, state)
+            except Invalid:
+                try:
+                    return self.inumber_validator.to_python(value, state)
+                except Invalid:
+                    pass
+        # It's not an OpenId!
+        raise Invalid(self.message("badId", state, id=value), value, state)
+    
+    def validate_python(self, value, state):
+        self._to_python(value, state)
+
+
 def StateProvince(*kw, **kwargs):
     warnings.warn("please use formencode.national.USStateProvince", DeprecationWarning, stacklevel=2)
     from formencode.national import USStateProvince
Index: tests/test_validators.py
===================================================================
--- tests/test_validators.py	(revision 3562)
+++ tests/test_validators.py	(working copy)
@@ -1,4 +1,8 @@
-from formencode.validators import String, UnicodeString, Invalid, Int
+# -*- coding: utf-8 -*-
+import unittest
+
+from formencode.validators import String, UnicodeString, Invalid, Int, XRI, \
+                                  OpenId
 from formencode.validators import DateConverter
 from formencode.variabledecode import NestedVariables
 from formencode.schema import Schema
@@ -127,3 +131,171 @@
     else:
         raise Exception("Shouldn't be valid data", values, start_values)
 
+
+
+
+
+class TestXRIValidator(unittest.TestCase):
+    """Generic tests for the XRI validator"""
+    
+    def test_creation_valid_params(self):
+        """The creation of an XRI validator with valid parameters must
+        succeed"""
+        XRI()
+        XRI(True, "i-name")
+        XRI(True, "i-number")
+        XRI(False, "i-name")
+        XRI(False, "i-number")
+    
+    def test_creation_invalid_xri(self):
+        """Only "i-name" and "i-number" are valid XRIs"""
+        self.assertRaises(AssertionError, XRI, True, 'i-something')
+    
+    def test_valid_simple_individual_iname_without_type(self):
+        """XRIs must start with either an equals sign or an at sign"""
+        validator = XRI(True, "i-name")
+        self.assertRaises(Invalid, validator.validate_python, 'Gustavo')
+    
+    def test_valid_iname_with_schema(self):
+        """XRIs may have their schema in the beggining"""
+        validator = XRI()
+        self.assertEqual(validator.to_python('xri://=Gustavo'),
+                         'xri://=Gustavo')
+    
+    def test_schema_is_added_if_asked(self):
+        """The schema must be added to an XRI if explicitly asked"""
+        validator = XRI(True)
+        self.assertEqual(validator.to_python('=Gustavo'),
+                         'xri://=Gustavo')
+    
+    def test_schema_not_added_if_not_asked(self):
+        """The schema must not be added to an XRI unless explicitly asked"""
+        validator = XRI()
+        self.assertEqual(validator.to_python('=Gustavo'), '=Gustavo')
+    
+    def test_spaces_are_trimmed(self):
+        """Spaces at the beggining or end of the XRI are removed"""
+        validator = XRI()
+        self.assertEqual(validator.to_python('    =Gustavo  '), '=Gustavo')
+
+
+class TestINameValidator(unittest.TestCase):
+    """Tests for the XRI i-names validator"""
+    
+    def setUp(self):
+        self.validator = XRI(xri_type="i-name")
+    
+    def test_valid_global_individual_iname(self):
+        """Global & valid individual i-names must pass validation"""
+        self.validator.validate_python('=Gustavo')
+    
+    def test_valid_global_organizational_iname(self):
+        """Global & valid organizational i-names must pass validation"""
+        self.validator.validate_python('@Canonical')
+    
+    def test_invalid_iname(self):
+        """Non-string i-names are rejected"""
+        self.assertRaises(Invalid, self.validator.validate_python, None)
+    
+    def test_exclamation_in_inames(self):
+        """Exclamation marks at the beggining of XRIs is something specific
+        to i-numbers and must be rejected in i-names"""
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "!!1000!de21.4536.2cb2.8074")
+    
+    def test_repeated_characters(self):
+        """Dots and dashes must not be consecutively repeated in i-names"""
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "=Gustavo--Narea")
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "=Gustavo..Narea")
+    
+    def test_punctuation_marks_at_beggining(self):
+        """Punctuation marks at the beggining of i-names are forbidden"""
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "=.Gustavo")
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "=-Gustavo.Narea")
+    
+    def test_numerals_at_beggining(self):
+        """Numerals at the beggining of i-names are forbidden"""
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "=1Gustavo")
+    
+    def test_non_english_inames(self):
+        """i-names with non-English characters are valid"""
+        self.validator.validate_python(u'=Gustavo.Narea.García')
+        self.validator.validate_python(u'@名前.例')
+    
+    def test_inames_plus_paths(self):
+        """i-names with paths are valid but not supported"""
+        self.assertRaises(Invalid, self.validator.validate_python,
+                          "=Gustavo/(+email)")
+    
+    def test_communities(self):
+        """i-names may have so-called 'communities'"""
+        self.validator.validate_python(u'=María*Yolanda*Liliana*Gustavo')
+        self.validator.validate_python(u'=Gustavo*Andreina')
+        self.validator.validate_python(u'@IBM*Lenovo')
+        
+        
+class TestINumberValidator(unittest.TestCase):
+    """Tests for the XRI i-number validator"""
+    
+    def setUp(self):
+        self.validator = XRI(xri_type="i-number")
+    
+    def test_valid_global_personal_inumber(self):
+        """Global & valid personal i-numbers must pass validation"""
+        self.validator.validate_python('=!1000.a1b2.93d2.8c73')
+    
+    def test_valid_global_organizational_inumber(self):
+        """Global & valid organizational i-numbers must pass validation"""
+        self.validator.validate_python('@!1000.a1b2.93d2.8c73')
+    
+    def test_valid_global_network_inumber(self):
+        """Global & valid network i-numbers must pass validation"""
+        self.validator.validate_python('!!1000')
+    
+    def test_valid_community_personal_inumbers(self):
+        """Community & valid personal i-numbers must pass validation"""
+        self.validator.validate_python('=!1000.a1b2.93d2.8c73!3ae2!1490')
+    
+    def test_valid_community_organizational_inumber(self):
+        """Community & valid organizational i-numbers must pass validation"""
+        self.validator.validate_python('@!1000.9554.fabd.129c!2847.df3c')
+    
+    def test_valid_community_network_inumber(self):
+        """Community & valid network i-numbers must pass validation"""
+        self.validator.validate_python('!!1000!de21.4536.2cb2.8074')
+
+
+class TestOpenIdValidator(unittest.TestCase):
+    """Tests for the OpenId validator"""
+    
+    def setUp(self):
+        self.validator = OpenId(add_schema=False)
+    
+    def test_url(self):
+        self.assertEqual(self.validator.to_python('http://example.org'),
+                         'http://example.org')
+    
+    def test_iname(self):
+        self.assertEqual(self.validator.to_python('=Gustavo'), '=Gustavo')
+    
+    def test_inumber(self):
+        self.assertEqual(self.validator.to_python('!!1000'), '!!1000')
+    
+    def test_email(self):
+        """Email addresses are not valid OpenIds!"""
+        self.assertRaises(Invalid, self.validator.to_python,
+                          "[email protected]")
+    
+    def test_prepending_schema(self):
+        validator = OpenId(add_schema=True)
+        self.assertEqual(validator.to_python("example.org"),
+                         "http://example.org")
+        self.assertEqual(validator.to_python("=Gustavo"),
+                         "xri://=Gustavo")
+        self.assertEqual(validator.to_python("!!1000"),
+                         "xri://!!1000")
signature.asc (application/pgp-signature, 189 B)
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.6 (GNU/Linux)

iD8DBQBItwdgPPeetaRFqPERAtliAKCdUFdvX4rqnHeQXajB5Z3mJBcXEgCbBNW9
vVa8q66atYmC+Te1/qwZqx8=
=QJAM
-----END PGP SIGNATURE-----