From ddc99b35528d377af24dfb6af27a9643ba98bda8 Mon Sep 17 00:00:00 2001 From: mdipierro Date: Sun, 10 Feb 2019 22:15:12 -0800 Subject: [PATCH] validators are back in web2py for now --- gluon/packages/dal | 2 +- gluon/tests/test_validators.py | 1207 ++++++++++ gluon/validators.py | 3899 +++++++++++++++++++++++++++++++- 3 files changed, 5104 insertions(+), 4 deletions(-) create mode 100644 gluon/tests/test_validators.py diff --git a/gluon/packages/dal b/gluon/packages/dal index 28f0b194..43686a98 160000 --- a/gluon/packages/dal +++ b/gluon/packages/dal @@ -1 +1 @@ -Subproject commit 28f0b194549e1e5bcd74d03ac46ecea58ec0744a +Subproject commit 43686a98bbe85dcff4fc32a87887af33b9c1ff25 diff --git a/gluon/tests/test_validators.py b/gluon/tests/test_validators.py new file mode 100644 index 00000000..8afccc5a --- /dev/null +++ b/gluon/tests/test_validators.py @@ -0,0 +1,1207 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Unit tests for http.py """ + +import unittest +import datetime +import decimal +import re + +from gluon.validators import * +from gluon._compat import PY2, to_bytes + +class TestValidators(unittest.TestCase): + + def myassertRegex(self, *args, **kwargs): + if PY2: + return getattr(self, 'assertRegexpMatches')(*args, **kwargs) + return getattr(self, 'assertRegex')(*args, **kwargs) + + def test_MISC(self): + """ Test miscelaneous utility functions and some general behavior guarantees """ + from gluon.validators import translate, options_sorter, Validator, UTC + self.assertEqual(translate(None), None) + self.assertEqual(options_sorter(('a', 'a'), ('a', 'a')), -1) + self.assertEqual(options_sorter(('A', 'A'), ('a', 'a')), -1) + self.assertEqual(options_sorter(('b', 'b'), ('a', 'a')), 1) + self.assertRaises(NotImplementedError, Validator(), 1) + utc = UTC() + dt = datetime.datetime.now() + self.assertEqual(utc.utcoffset(dt), UTC.ZERO) + self.assertEqual(utc.dst(dt), UTC.ZERO) + self.assertEqual(utc.tzname(dt), 'UTC') + + def test_IS_MATCH(self): + rtn = IS_MATCH('.+')('hello') + self.assertEqual(rtn, ('hello', None)) + rtn = IS_MATCH('hell')('hello') + self.assertEqual(rtn, ('hello', None)) + rtn = IS_MATCH('hell.*', strict=False)('hello') + self.assertEqual(rtn, ('hello', None)) + rtn = IS_MATCH('hello')('shello') + self.assertEqual(rtn, ('shello', 'Invalid expression')) + rtn = IS_MATCH('hello', search=True)('shello') + self.assertEqual(rtn, ('shello', None)) + rtn = IS_MATCH('hello', search=True, strict=False)('shellox') + self.assertEqual(rtn, ('shellox', None)) + rtn = IS_MATCH('.*hello.*', search=True, strict=False)('shellox') + self.assertEqual(rtn, ('shellox', None)) + rtn = IS_MATCH('.+')('') + self.assertEqual(rtn, ('', 'Invalid expression')) + rtn = IS_MATCH('hell', strict=True)('hellas') + self.assertEqual(rtn, ('hellas', 'Invalid expression')) + rtn = IS_MATCH('hell$', strict=True)('hellas') + self.assertEqual(rtn, ('hellas', 'Invalid expression')) + rtn = IS_MATCH('^.hell$', strict=True)('shell') + self.assertEqual(rtn, ('shell', None)) + rtn = IS_MATCH(u'hell', is_unicode=True)('àòè') + if PY2: + self.assertEqual(rtn, ('\xc3\xa0\xc3\xb2\xc3\xa8', 'Invalid expression')) + else: + self.assertEqual(rtn, ('àòè', 'Invalid expression')) + rtn = IS_MATCH(u'hell', is_unicode=True)(u'hell') + self.assertEqual(rtn, (u'hell', None)) + rtn = IS_MATCH('hell', is_unicode=True)(u'hell') + self.assertEqual(rtn, (u'hell', None)) + # regr test for #1044 + rtn = IS_MATCH('hello')(u'\xff') + self.assertEqual(rtn, (u'\xff', 'Invalid expression')) + + def test_IS_EQUAL_TO(self): + rtn = IS_EQUAL_TO('aaa')('aaa') + self.assertEqual(rtn, ('aaa', None)) + rtn = IS_EQUAL_TO('aaa')('aab') + self.assertEqual(rtn, ('aab', 'No match')) + + def test_IS_EXPR(self): + rtn = IS_EXPR('int(value) < 2')('1') + self.assertEqual(rtn, ('1', None)) + rtn = IS_EXPR('int(value) < 2')('2') + self.assertEqual(rtn, ('2', 'Invalid expression')) + rtn = IS_EXPR(lambda value: int(value))('1') + self.assertEqual(rtn, ('1', 1)) + rtn = IS_EXPR(lambda value: int(value) < 2 and 'invalid' or None)('2') + self.assertEqual(rtn, ('2', None)) + + def test_IS_LENGTH(self): + rtn = IS_LENGTH()('') + self.assertEqual(rtn, ('', None)) + rtn = IS_LENGTH()('1234567890') + self.assertEqual(rtn, ('1234567890', None)) + rtn = IS_LENGTH(maxsize=5, minsize=0)('1234567890') # too long + self.assertEqual(rtn, ('1234567890', 'Enter from 0 to 5 characters')) + rtn = IS_LENGTH(maxsize=50, minsize=20)('1234567890') # too short + self.assertEqual(rtn, ('1234567890', 'Enter from 20 to 50 characters')) + rtn = IS_LENGTH()(None) + self.assertEqual(rtn, (None, None)) + rtn = IS_LENGTH(minsize=0)(None) + self.assertEqual(rtn, (None, None)) + rtn = IS_LENGTH(minsize=1)(None) + self.assertEqual(rtn, (None, 'Enter from 1 to 255 characters')) + rtn = IS_LENGTH(minsize=1)([]) + self.assertEqual(rtn, ([], 'Enter from 1 to 255 characters')) + rtn = IS_LENGTH(minsize=1)([1, 2]) + self.assertEqual(rtn, ([1, 2], None)) + rtn = IS_LENGTH(minsize=1)([1]) + self.assertEqual(rtn, ([1], None)) + # test non utf-8 str + cpstr = u'lálá'.encode('cp1252') + rtn = IS_LENGTH(minsize=4)(cpstr) + self.assertEqual(rtn, (cpstr, None)) + rtn = IS_LENGTH(maxsize=4)(cpstr) + self.assertEqual(rtn, (cpstr, None)) + rtn = IS_LENGTH(minsize=0, maxsize=3)(cpstr) + self.assertEqual(rtn, (cpstr, 'Enter from 0 to 3 characters')) + # test unicode + rtn = IS_LENGTH(2)(u'°2') + if PY2: + self.assertEqual(rtn, ('\xc2\xb02', None)) + else: + self.assertEqual(rtn, (u'°2', None)) + rtn = IS_LENGTH(2)(u'°12') + if PY2: + self.assertEqual(rtn, (u'\xb012', 'Enter from 0 to 2 characters')) + else: + self.assertEqual(rtn, (u'°12', 'Enter from 0 to 2 characters')) + # test automatic str() + rtn = IS_LENGTH(minsize=1)(1) + self.assertEqual(rtn, ('1', None)) + rtn = IS_LENGTH(minsize=2)(1) + self.assertEqual(rtn, (1, 'Enter from 2 to 255 characters')) + # test FieldStorage + import cgi + from io import BytesIO + a = cgi.FieldStorage() + a.file = BytesIO(b'abc') + rtn = IS_LENGTH(minsize=4)(a) + self.assertEqual(rtn, (a, 'Enter from 4 to 255 characters')) + urlencode_data = b"key2=value2x&key3=value3&key4=value4" + urlencode_environ = { + 'CONTENT_LENGTH': str(len(urlencode_data)), + 'CONTENT_TYPE': 'application/x-www-form-urlencoded', + 'QUERY_STRING': 'key1=value1&key2=value2y', + 'REQUEST_METHOD': 'POST', + } + fake_stdin = BytesIO(urlencode_data) + fake_stdin.seek(0) + a = cgi.FieldStorage(fp=fake_stdin, environ=urlencode_environ) + rtn = IS_LENGTH(minsize=6)(a) + self.assertEqual(rtn, (a, 'Enter from 6 to 255 characters')) + a = cgi.FieldStorage() + rtn = IS_LENGTH(minsize=6)(a) + self.assertEqual(rtn, (a, 'Enter from 6 to 255 characters')) + rtn = IS_LENGTH(6)(a) + self.assertEqual(rtn, (a, None)) + + def test_IS_JSON(self): + rtn = IS_JSON()('{"a": 100}') + self.assertEqual(rtn, ({u'a': 100}, None)) + rtn = IS_JSON()('spam1234') + self.assertEqual(rtn, ('spam1234', 'Invalid json')) + rtn = IS_JSON(native_json=True)('{"a": 100}') + self.assertEqual(rtn, ('{"a": 100}', None)) + rtn = IS_JSON().formatter(None) + self.assertEqual(rtn, None) + rtn = IS_JSON().formatter({'a': 100}) + self.assertEqual(rtn, '{"a": 100}') + rtn = IS_JSON(native_json=True).formatter({'a': 100}) + self.assertEqual(rtn, {'a': 100}) + + def test_IS_IN_SET(self): + rtn = IS_IN_SET(['max', 'john'])('max') + self.assertEqual(rtn, ('max', None)) + rtn = IS_IN_SET(['max', 'john'])('massimo') + self.assertEqual(rtn, ('massimo', 'Value not allowed')) + rtn = IS_IN_SET(['max', 'john'], multiple=True)(('max', 'john')) + self.assertEqual(rtn, (('max', 'john'), None)) + rtn = IS_IN_SET(['max', 'john'], multiple=True)(('bill', 'john')) + self.assertEqual(rtn, (('bill', 'john'), 'Value not allowed')) + rtn = IS_IN_SET(('id1', 'id2'), ['first label', 'second label'])('id1') # Traditional way + self.assertEqual(rtn, ('id1', None)) + rtn = IS_IN_SET({'id1': 'first label', 'id2': 'second label'})('id1') + self.assertEqual(rtn, ('id1', None)) + rtn = IS_IN_SET(['id1', 'id2'], error_message='oops', multiple=True)(None) + self.assertEqual(rtn, ([], None)) + rtn = IS_IN_SET(['id1', 'id2'], error_message='oops', multiple=True)('') + self.assertEqual(rtn, ([], None)) + rtn = IS_IN_SET(['id1', 'id2'], error_message='oops', multiple=True)('id1') + self.assertEqual(rtn, (['id1'], None)) + rtn = IS_IN_SET(['id1', 'id2'], error_message='oops', multiple=(1, 2))(None) + self.assertEqual(rtn, ([], 'oops')) + import itertools + rtn = IS_IN_SET(itertools.chain(['1', '3', '5'], ['2', '4', '6']))('1') + self.assertEqual(rtn, ('1', None)) + rtn = IS_IN_SET([('id1', 'first label'), ('id2', 'second label')])('id1') # Redundant way + self.assertEqual(rtn, ('id1', None)) + rtn = IS_IN_SET([('id1', 'first label'), ('id2', 'second label')]).options(zero=False) + self.assertEqual(rtn, [('id1', 'first label'), ('id2', 'second label')]) + rtn = IS_IN_SET(['id1', 'id2']).options(zero=False) + self.assertEqual(rtn, [('id1', 'id1'), ('id2', 'id2')]) + rtn = IS_IN_SET(['id2', 'id1'], sort=True).options(zero=False) + self.assertEqual(rtn, [('id1', 'id1'), ('id2', 'id2')]) + + def test_IS_IN_DB(self): + from gluon.dal import DAL, Field + db = DAL('sqlite:memory') + db.define_table('person', Field('name')) + george_id = db.person.insert(name='george') + costanza_id = db.person.insert(name='costanza') + rtn = IS_IN_DB(db, 'person.id', '%(name)s')(george_id) + self.assertEqual(rtn, (george_id, None)) + rtn = IS_IN_DB(db, 'person.name', '%(name)s')('george') + self.assertEqual(rtn, ('george', None)) + rtn = IS_IN_DB(db, db.person, '%(name)s')(george_id) + self.assertEqual(rtn, (george_id, None)) + rtn = IS_IN_DB(db(db.person.id > 0), db.person, '%(name)s')(george_id) + self.assertEqual(rtn, (george_id, None)) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', error_message='oops')(george_id + costanza_id) + self.assertEqual(rtn, (george_id + costanza_id, 'oops')) + rtn = IS_IN_DB(db, db.person.id, '%(name)s')(george_id) + self.assertEqual(rtn, (george_id, None)) + rtn = IS_IN_DB(db, db.person.id, '%(name)s', error_message='oops')(george_id + costanza_id) + self.assertEqual(rtn, (george_id + costanza_id, 'oops')) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', multiple=True)([george_id, costanza_id]) + self.assertEqual(rtn, ([george_id, costanza_id], None)) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', multiple=True, error_message='oops')("I'm not even an id") + self.assertEqual(rtn, (["I'm not even an id"], 'oops')) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', multiple=True, delimiter=',')('%d,%d' % (george_id, costanza_id)) + self.assertEqual(rtn, (('%d,%d' % (george_id, costanza_id)).split(','), None)) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', multiple=(1, 3), delimiter=',')('%d,%d' % (george_id, costanza_id)) + self.assertEqual(rtn, (('%d,%d' % (george_id, costanza_id)).split(','), None)) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', multiple=(1, 2), delimiter=',', error_message='oops')('%d,%d' % (george_id, costanza_id)) + self.assertEqual(rtn, (('%d,%d' % (george_id, costanza_id)).split(','), 'oops')) + rtn = IS_IN_DB(db, db.person.id, '%(name)s', error_message='oops').options(zero=False) + self.assertEqual(sorted(rtn), [('%d' % george_id, 'george'), ('%d' % costanza_id, 'costanza')]) + rtn = IS_IN_DB(db, db.person.id, db.person.name, error_message='oops', sort=True).options(zero=True) + self.assertEqual(rtn, [('', ''), ('%d' % costanza_id, 'costanza'), ('%d' % george_id, 'george')]) + # Test None + rtn = IS_IN_DB(db, 'person.id', '%(name)s', error_message='oops')(None) + self.assertEqual(rtn, (None, 'oops')) + rtn = IS_IN_DB(db, 'person.name', '%(name)s', error_message='oops')(None) + self.assertEqual(rtn, (None, 'oops')) + # Test using the set it made for options + vldtr = IS_IN_DB(db, 'person.name', '%(name)s', error_message='oops') + vldtr.options() + rtn = vldtr('george') + self.assertEqual(rtn, ('george', None)) + rtn = vldtr('jerry') + self.assertEqual(rtn, ('jerry', 'oops')) + vldtr = IS_IN_DB(db, 'person.name', '%(name)s', error_message='oops', multiple=True) + vldtr.options() + rtn = vldtr(['george', 'costanza']) + self.assertEqual(rtn, (['george', 'costanza'], None)) + # Test it works with self reference + db.define_table('category', + Field('parent_id', 'reference category', requires=IS_EMPTY_OR(IS_IN_DB(db, 'category.id', '%(name)s'))), + Field('name') + ) + ret = db.category.validate_and_insert(name='seinfeld') + self.assertFalse(list(ret.errors)) + ret = db.category.validate_and_insert(name='characters', parent_id=ret.id) + self.assertFalse(list(ret.errors)) + rtn = IS_IN_DB(db, 'category.id', '%(name)s')(ret.id) + self.assertEqual(rtn, (ret.id, None)) + # Test _and + vldtr = IS_IN_DB(db, 'person.name', '%(name)s', error_message='oops', _and=IS_LENGTH(maxsize=7, error_message='bad')) + rtn = vldtr('george') + self.assertEqual(rtn, ('george', None)) + rtn = vldtr('costanza') + self.assertEqual(rtn, ('costanza', 'bad')) + rtn = vldtr('jerry') + self.assertEqual(rtn, ('jerry', 'oops')) + vldtr.options() # test theset with _and + rtn = vldtr('jerry') + self.assertEqual(rtn, ('jerry', 'oops')) + # Test auto_add + rtn = IS_IN_DB(db, 'person.id', '%(name)s', error_message='oops')('jerry') + self.assertEqual(rtn, ('jerry', 'oops')) + rtn = IS_IN_DB(db, 'person.id', '%(name)s', auto_add=True)('jerry') + self.assertEqual(rtn, (3, None)) + # Test it works with reference table + db.define_table('ref_table', + Field('name'), + Field('person_id', 'reference person') + ) + ret = db.ref_table.validate_and_insert(name='test reference table') + self.assertFalse(list(ret.errors)) + ret = db.ref_table.validate_and_insert(name='test reference table', person_id=george_id) + self.assertFalse(list(ret.errors)) + rtn = IS_IN_DB(db, 'ref_table.person_id', '%(name)s')(george_id) + self.assertEqual(rtn, (george_id, None)) + # Test it works with reference table.field and keyed table + db.define_table('person_keyed', + Field('name'), + primarykey=['name']) + db.person_keyed.insert(name='george') + db.person_keyed.insert(name='costanza') + rtn = IS_IN_DB(db, 'person_keyed.name')('george') + self.assertEqual(rtn, ('george', None)) + db.define_table('ref_table_field', + Field('name'), + Field('person_name', 'reference person_keyed.name') + ) + ret = db.ref_table_field.validate_and_insert(name='test reference table.field') + self.assertFalse(list(ret.errors)) + ret = db.ref_table_field.validate_and_insert(name='test reference table.field', person_name='george') + self.assertFalse(list(ret.errors)) + vldtr = IS_IN_DB(db, 'ref_table_field.person_name', '%(name)s') + vldtr.options() + rtn = vldtr('george') + self.assertEqual(rtn, ('george', None)) + # Test it works with list:reference table + db.define_table('list_ref_table', + Field('name'), + Field('person_list', 'list:reference person')) + ret = db.list_ref_table.validate_and_insert(name='test list:reference table') + self.assertFalse(list(ret.errors)) + ret = db.list_ref_table.validate_and_insert(name='test list:reference table', person_list=[george_id,costanza_id]) + self.assertFalse(list(ret.errors)) + vldtr = IS_IN_DB(db, 'list_ref_table.person_list') + vldtr.options() + rtn = vldtr([george_id,costanza_id]) + self.assertEqual(rtn, ([george_id,costanza_id], None)) + # Test it works with list:reference table.field and keyed table + #db.define_table('list_ref_table_field', + # Field('name'), + # Field('person_list', 'list:reference person_keyed.name')) + #ret = db.list_ref_table_field.validate_and_insert(name='test list:reference table.field') + #self.assertFalse(list(ret.errors)) + #ret = db.list_ref_table_field.validate_and_insert(name='test list:reference table.field', person_list=['george','costanza']) + #self.assertFalse(list(ret.errors)) + #vldtr = IS_IN_DB(db, 'list_ref_table_field.person_list') + #vldtr.options() + #rtn = vldtr(['george','costanza']) + #self.assertEqual(rtn, (['george','costanza'], None)) + db.person.drop() + db.category.drop() + db.person_keyed.drop() + db.ref_table.drop() + db.ref_table_field.drop() + db.list_ref_table.drop() + #db.list_ref_table_field.drop() + + def test_IS_NOT_IN_DB(self): + from gluon.dal import DAL, Field + db = DAL('sqlite:memory') + db.define_table('person', Field('name'), Field('nickname')) + db.person.insert(name='george') + db.person.insert(name='costanza', nickname='T Bone') + rtn = IS_NOT_IN_DB(db, 'person.name', error_message='oops')('george') + self.assertEqual(rtn, ('george', 'oops')) + rtn = IS_NOT_IN_DB(db, 'person.name', error_message='oops', allowed_override=['george'])('george') + self.assertEqual(rtn, ('george', None)) + rtn = IS_NOT_IN_DB(db, 'person.name', error_message='oops')(' ') + self.assertEqual(rtn, (' ', 'oops')) + rtn = IS_NOT_IN_DB(db, 'person.name')('jerry') + self.assertEqual(rtn, ('jerry', None)) + rtn = IS_NOT_IN_DB(db, 'person.name')(u'jerry') + self.assertEqual(rtn, ('jerry', None)) + rtn = IS_NOT_IN_DB(db(db.person.id > 0), 'person.name')(u'jerry') + self.assertEqual(rtn, ('jerry', None)) + rtn = IS_NOT_IN_DB(db, db.person, error_message='oops')(1) + self.assertEqual(rtn, ('1', 'oops')) + vldtr = IS_NOT_IN_DB(db, 'person.name', error_message='oops') + vldtr.set_self_id({'name': 'costanza', 'nickname': 'T Bone'}) + rtn = vldtr('george') + self.assertEqual(rtn, ('george', 'oops')) + rtn = vldtr('costanza') + self.assertEqual(rtn, ('costanza', None)) + + db.person.drop() + + def test_IS_INT_IN_RANGE(self): + rtn = IS_INT_IN_RANGE(1, 5)('4') + self.assertEqual(rtn, (4, None)) + rtn = IS_INT_IN_RANGE(1, 5)(4) + self.assertEqual(rtn, (4, None)) + rtn = IS_INT_IN_RANGE(1, 5)(1) + self.assertEqual(rtn, (1, None)) + rtn = IS_INT_IN_RANGE(1, 5)(5) + self.assertEqual(rtn, (5, 'Enter an integer between 1 and 4')) + rtn = IS_INT_IN_RANGE(1, 5)(5) + self.assertEqual(rtn, (5, 'Enter an integer between 1 and 4')) + rtn = IS_INT_IN_RANGE(1, 5)(3.5) + self.assertEqual(rtn, (3.5, 'Enter an integer between 1 and 4')) + rtn = IS_INT_IN_RANGE(None, 5)('4') + self.assertEqual(rtn, (4, None)) + rtn = IS_INT_IN_RANGE(None, 5)('6') + self.assertEqual(rtn, ('6', 'Enter an integer less than or equal to 4')) + rtn = IS_INT_IN_RANGE(1, None)('4') + self.assertEqual(rtn, (4, None)) + rtn = IS_INT_IN_RANGE(1, None)('0') + self.assertEqual(rtn, ('0', 'Enter an integer greater than or equal to 1')) + rtn = IS_INT_IN_RANGE()(6) + self.assertEqual(rtn, (6, None)) + rtn = IS_INT_IN_RANGE()('abc') + self.assertEqual(rtn, ('abc', 'Enter an integer')) + + def test_IS_FLOAT_IN_RANGE(self): + # with None + rtn = IS_FLOAT_IN_RANGE(1, 5)(None) + self.assertEqual(rtn, (None, 'Enter a number between 1 and 5')) + rtn = IS_FLOAT_IN_RANGE(1, 5)('4') + self.assertEqual(rtn, (4.0, None)) + rtn = IS_FLOAT_IN_RANGE(1, 5)(4) + self.assertEqual(rtn, (4.0, None)) + rtn = IS_FLOAT_IN_RANGE(1, 5)(1) + self.assertEqual(rtn, (1.0, None)) + rtn = IS_FLOAT_IN_RANGE(1, 5)(5.25) + self.assertEqual(rtn, (5.25, 'Enter a number between 1 and 5')) + rtn = IS_FLOAT_IN_RANGE(1, 5)(6.0) + self.assertEqual(rtn, (6.0, 'Enter a number between 1 and 5')) + rtn = IS_FLOAT_IN_RANGE(1, 5)(3.5) + self.assertEqual(rtn, (3.5, None)) + rtn = IS_FLOAT_IN_RANGE(1, None)(3.5) + self.assertEqual(rtn, (3.5, None)) + rtn = IS_FLOAT_IN_RANGE(None, 5)(3.5) + self.assertEqual(rtn, (3.5, None)) + rtn = IS_FLOAT_IN_RANGE(1, None)(0.5) + self.assertEqual(rtn, (0.5, 'Enter a number greater than or equal to 1')) + rtn = IS_FLOAT_IN_RANGE(None, 5)(6.5) + self.assertEqual(rtn, (6.5, 'Enter a number less than or equal to 5')) + rtn = IS_FLOAT_IN_RANGE()(6.5) + self.assertEqual(rtn, (6.5, None)) + rtn = IS_FLOAT_IN_RANGE()('abc') + self.assertEqual(rtn, ('abc', 'Enter a number')) + rtn = IS_FLOAT_IN_RANGE()('6,5') + self.assertEqual(rtn, ('6,5', 'Enter a number')) + rtn = IS_FLOAT_IN_RANGE(dot=',')('6.5') + self.assertEqual(rtn, (6.5, None)) + # With .formatter(None) + rtn = IS_FLOAT_IN_RANGE(dot=',').formatter(None) + self.assertEqual(rtn, None) + rtn = IS_FLOAT_IN_RANGE(dot=',').formatter(0.25) + self.assertEqual(rtn, '0,25') + # To trigger str2dec "if not '.' in s:" line + rtn = IS_FLOAT_IN_RANGE(dot=',').formatter(1) + self.assertEqual(rtn, '1,00') + + def test_IS_DECIMAL_IN_RANGE(self): + # with None + rtn = IS_DECIMAL_IN_RANGE(1, 5)(None) + self.assertEqual(rtn, (None, 'Enter a number between 1 and 5')) + rtn = IS_DECIMAL_IN_RANGE(1, 5)('4') + self.assertEqual(rtn, (decimal.Decimal('4'), None)) + rtn = IS_DECIMAL_IN_RANGE(1, 5)(4) + self.assertEqual(rtn, (decimal.Decimal('4'), None)) + rtn = IS_DECIMAL_IN_RANGE(1, 5)(1) + self.assertEqual(rtn, (decimal.Decimal('1'), None)) + rtn = IS_DECIMAL_IN_RANGE(1, 5)(5.25) + self.assertEqual(rtn, (5.25, 'Enter a number between 1 and 5')) + rtn = IS_DECIMAL_IN_RANGE(5.25, 6)(5.25) + self.assertEqual(rtn, (decimal.Decimal('5.25'), None)) + rtn = IS_DECIMAL_IN_RANGE(5.25, 6)('5.25') + self.assertEqual(rtn, (decimal.Decimal('5.25'), None)) + rtn = IS_DECIMAL_IN_RANGE(1, 5)(6.0) + self.assertEqual(rtn, (6.0, 'Enter a number between 1 and 5')) + rtn = IS_DECIMAL_IN_RANGE(1, 5)(3.5) + self.assertEqual(rtn, (decimal.Decimal('3.5'), None)) + rtn = IS_DECIMAL_IN_RANGE(1.5, 5.5)(3.5) + self.assertEqual(rtn, (decimal.Decimal('3.5'), None)) + rtn = IS_DECIMAL_IN_RANGE(1.5, 5.5)(6.5) + self.assertEqual(rtn, (6.5, 'Enter a number between 1.5 and 5.5')) + rtn = IS_DECIMAL_IN_RANGE(1.5, None)(6.5) + self.assertEqual(rtn, (decimal.Decimal('6.5'), None)) + rtn = IS_DECIMAL_IN_RANGE(1.5, None)(0.5) + self.assertEqual(rtn, (0.5, 'Enter a number greater than or equal to 1.5')) + rtn = IS_DECIMAL_IN_RANGE(None, 5.5)(4.5) + self.assertEqual(rtn, (decimal.Decimal('4.5'), None)) + rtn = IS_DECIMAL_IN_RANGE(None, 5.5)(6.5) + self.assertEqual(rtn, (6.5, 'Enter a number less than or equal to 5.5')) + rtn = IS_DECIMAL_IN_RANGE()(6.5) + self.assertEqual(rtn, (decimal.Decimal('6.5'), None)) + rtn = IS_DECIMAL_IN_RANGE(0, 99)(123.123) + self.assertEqual(rtn, (123.123, 'Enter a number between 0 and 99')) + rtn = IS_DECIMAL_IN_RANGE(0, 99)('123.123') + self.assertEqual(rtn, ('123.123', 'Enter a number between 0 and 99')) + rtn = IS_DECIMAL_IN_RANGE(0, 99)('12.34') + self.assertEqual(rtn, (decimal.Decimal('12.34'), None)) + rtn = IS_DECIMAL_IN_RANGE()('abc') + self.assertEqual(rtn, ('abc', 'Enter a number')) + rtn = IS_DECIMAL_IN_RANGE()('6,5') + self.assertEqual(rtn, ('6,5', 'Enter a number')) + rtn = IS_DECIMAL_IN_RANGE(dot=',')('6.5') + self.assertEqual(rtn, (decimal.Decimal('6.5'), None)) + rtn = IS_DECIMAL_IN_RANGE(1, 5)(decimal.Decimal('4')) + self.assertEqual(rtn, (decimal.Decimal('4'), None)) + # With .formatter(None) + rtn = IS_DECIMAL_IN_RANGE(dot=',').formatter(None) + self.assertEqual(rtn, None) + rtn = IS_DECIMAL_IN_RANGE(dot=',').formatter(0.25) + self.assertEqual(rtn, '0,25') + + def test_IS_NOT_EMPTY(self): + rtn = IS_NOT_EMPTY()(1) + self.assertEqual(rtn, (1, None)) + rtn = IS_NOT_EMPTY()(0) + self.assertEqual(rtn, (0, None)) + rtn = IS_NOT_EMPTY()('x') + self.assertEqual(rtn, ('x', None)) + rtn = IS_NOT_EMPTY()(' x ') + self.assertEqual(rtn, (' x ', None)) + rtn = IS_NOT_EMPTY()(None) + self.assertEqual(rtn, (None, 'Enter a value')) + rtn = IS_NOT_EMPTY()('') + self.assertEqual(rtn, ('', 'Enter a value')) + rtn = IS_NOT_EMPTY()(b'') + self.assertEqual(rtn, (b'', 'Enter a value')) + rtn = IS_NOT_EMPTY()(' ') + self.assertEqual(rtn, (' ', 'Enter a value')) + rtn = IS_NOT_EMPTY()(' \n\t') + self.assertEqual(rtn, (' \n\t', 'Enter a value')) + rtn = IS_NOT_EMPTY()([]) + self.assertEqual(rtn, ([], 'Enter a value')) + rtn = IS_NOT_EMPTY(empty_regex='def')('def') + self.assertEqual(rtn, ('def', 'Enter a value')) + rtn = IS_NOT_EMPTY(empty_regex='de[fg]')('deg') + self.assertEqual(rtn, ('deg', 'Enter a value')) + rtn = IS_NOT_EMPTY(empty_regex='def')('abc') + self.assertEqual(rtn, ('abc', None)) + + def test_IS_ALPHANUMERIC(self): + rtn = IS_ALPHANUMERIC()('1') + self.assertEqual(rtn, ('1', None)) + rtn = IS_ALPHANUMERIC()('') + self.assertEqual(rtn, ('', None)) + rtn = IS_ALPHANUMERIC()('A_a') + self.assertEqual(rtn, ('A_a', None)) + rtn = IS_ALPHANUMERIC()('!') + self.assertEqual(rtn, ('!', 'Enter only letters, numbers, and underscore')) + + def test_IS_EMAIL(self): + rtn = IS_EMAIL()('a@b.com') + self.assertEqual(rtn, ('a@b.com', None)) + rtn = IS_EMAIL()('abc@def.com') + self.assertEqual(rtn, ('abc@def.com', None)) + rtn = IS_EMAIL()('abc@3def.com') + self.assertEqual(rtn, ('abc@3def.com', None)) + rtn = IS_EMAIL()('abc@def.us') + self.assertEqual(rtn, ('abc@def.us', None)) + rtn = IS_EMAIL()('abc@d_-f.us') + self.assertEqual(rtn, ('abc@d_-f.us', None)) + rtn = IS_EMAIL()('@def.com') # missing name + self.assertEqual(rtn, ('@def.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('"abc@def".com') # quoted name + self.assertEqual(rtn, ('"abc@def".com', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc+def.com') # no @ + self.assertEqual(rtn, ('abc+def.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc@def.x') # one-char TLD + self.assertEqual(rtn, ('abc@def.x', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc@def.12') # numeric TLD + self.assertEqual(rtn, ('abc@def.12', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc@def..com') # double-dot in domain + self.assertEqual(rtn, ('abc@def..com', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc@.def.com') # dot starts domain + self.assertEqual(rtn, ('abc@.def.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc@def.c_m') # underscore in TLD + self.assertEqual(rtn, ('abc@def.c_m', 'Enter a valid email address')) + rtn = IS_EMAIL()('NotAnEmail') # missing @ + self.assertEqual(rtn, ('NotAnEmail', 'Enter a valid email address')) + rtn = IS_EMAIL()('abc@NotAnEmail') # missing TLD + self.assertEqual(rtn, ('abc@NotAnEmail', 'Enter a valid email address')) + rtn = IS_EMAIL()('customer/department@example.com') + self.assertEqual(rtn, ('customer/department@example.com', None)) + rtn = IS_EMAIL()('$A12345@example.com') + self.assertEqual(rtn, ('$A12345@example.com', None)) + rtn = IS_EMAIL()('!def!xyz%abc@example.com') + self.assertEqual(rtn, ('!def!xyz%abc@example.com', None)) + rtn = IS_EMAIL()('_Yosemite.Sam@example.com') + self.assertEqual(rtn, ('_Yosemite.Sam@example.com', None)) + rtn = IS_EMAIL()('~@example.com') + self.assertEqual(rtn, ('~@example.com', None)) + rtn = IS_EMAIL()('.wooly@example.com') # dot starts name + self.assertEqual(rtn, ('.wooly@example.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('wo..oly@example.com') # adjacent dots in name + self.assertEqual(rtn, ('wo..oly@example.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('pootietang.@example.com') # dot ends name + self.assertEqual(rtn, ('pootietang.@example.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('.@example.com') # name is bare dot + self.assertEqual(rtn, ('.@example.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('Ima.Fool@example.com') + self.assertEqual(rtn, ('Ima.Fool@example.com', None)) + rtn = IS_EMAIL()('Ima Fool@example.com') # space in name + self.assertEqual(rtn, ('Ima Fool@example.com', 'Enter a valid email address')) + rtn = IS_EMAIL()('localguy@localhost') # localhost as domain + self.assertEqual(rtn, ('localguy@localhost', None)) + # test for banned + rtn = IS_EMAIL(banned='^.*\.com(|\..*)$')('localguy@localhost') # localhost as domain + self.assertEqual(rtn, ('localguy@localhost', None)) + rtn = IS_EMAIL(banned='^.*\.com(|\..*)$')('abc@example.com') + self.assertEqual(rtn, ('abc@example.com', 'Enter a valid email address')) + # test for forced + rtn = IS_EMAIL(forced='^.*\.edu(|\..*)$')('localguy@localhost') + self.assertEqual(rtn, ('localguy@localhost', 'Enter a valid email address')) + rtn = IS_EMAIL(forced='^.*\.edu(|\..*)$')('localguy@example.edu') + self.assertEqual(rtn, ('localguy@example.edu', None)) + # test for not a string at all + rtn = IS_EMAIL(error_message='oops')(42) + self.assertEqual(rtn, (42, 'oops')) + + # test for Internationalized Domain Names, see https://docs.python.org/2/library/codecs.html#module-encodings.idna + rtn = IS_EMAIL()('web2py@Alliancefrançaise.nu') + self.assertEqual(rtn, ('web2py@Alliancefrançaise.nu', None)) + + + def test_IS_LIST_OF_EMAILS(self): + emails = ['localguy@localhost', '_Yosemite.Sam@example.com'] + rtn = IS_LIST_OF_EMAILS()(','.join(emails)) + self.assertEqual(rtn, (','.join(emails), None)) + rtn = IS_LIST_OF_EMAILS()(';'.join(emails)) + self.assertEqual(rtn, (';'.join(emails), None)) + rtn = IS_LIST_OF_EMAILS()(' '.join(emails)) + self.assertEqual(rtn, (' '.join(emails), None)) + emails.append('a') + rtn = IS_LIST_OF_EMAILS()(';'.join(emails)) + self.assertEqual(rtn, ('localguy@localhost;_Yosemite.Sam@example.com;a', 'Invalid emails: a')) + rtn = IS_LIST_OF_EMAILS().formatter(['test@example.com', 'dude@example.com']) + self.assertEqual(rtn, 'test@example.com, dude@example.com') + + def test_IS_URL(self): + rtn = IS_URL()('http://example.com') + self.assertEqual(rtn, ('http://example.com', None)) + rtn = IS_URL(error_message='oops')('http://example,com') + self.assertEqual(rtn, ('http://example,com', 'oops')) + rtn = IS_URL(error_message='oops')('http://www.example.com:8800/a/b/c/d/e/f/g/h') + self.assertEqual(rtn, ('http://www.example.com:8800/a/b/c/d/e/f/g/h', None)) + rtn = IS_URL(error_message='oops', prepend_scheme='http')('example.com') + self.assertEqual(rtn, ('http://example.com', None)) + rtn = IS_URL()('http://example.com?q=george&p=22') + self.assertEqual(rtn, ('http://example.com?q=george&p=22', None)) + rtn = IS_URL(mode='generic', prepend_scheme=None)('example.com') + self.assertEqual(rtn, ('example.com', None)) + + def test_IS_TIME(self): + rtn = IS_TIME()('21:30') + self.assertEqual(rtn, (datetime.time(21, 30), None)) + rtn = IS_TIME()('21-30') + self.assertEqual(rtn, (datetime.time(21, 30), None)) + rtn = IS_TIME()('21.30') + self.assertEqual(rtn, (datetime.time(21, 30), None)) + rtn = IS_TIME()('21:30:59') + self.assertEqual(rtn, (datetime.time(21, 30, 59), None)) + rtn = IS_TIME()('5:30') + self.assertEqual(rtn, (datetime.time(5, 30), None)) + rtn = IS_TIME()('5:30 am') + self.assertEqual(rtn, (datetime.time(5, 30), None)) + rtn = IS_TIME()('5:30 pm') + self.assertEqual(rtn, (datetime.time(17, 30), None)) + rtn = IS_TIME()('5:30 whatever') + self.assertEqual(rtn, ('5:30 whatever', 'Enter time as hh:mm:ss (seconds, am, pm optional)')) + rtn = IS_TIME()('5:30 20') + self.assertEqual(rtn, ('5:30 20', 'Enter time as hh:mm:ss (seconds, am, pm optional)')) + rtn = IS_TIME()('24:30') + self.assertEqual(rtn, ('24:30', 'Enter time as hh:mm:ss (seconds, am, pm optional)')) + rtn = IS_TIME()('21:60') + self.assertEqual(rtn, ('21:60', 'Enter time as hh:mm:ss (seconds, am, pm optional)')) + rtn = IS_TIME()('21:30::') + self.assertEqual(rtn, ('21:30::', 'Enter time as hh:mm:ss (seconds, am, pm optional)')) + rtn = IS_TIME()('') + self.assertEqual(rtn, ('', 'Enter time as hh:mm:ss (seconds, am, pm optional)')) + + def test_IS_DATE(self): + v = IS_DATE(format="%m/%d/%Y", error_message="oops") + rtn = v('03/03/2008') + self.assertEqual(rtn, (datetime.date(2008, 3, 3), None)) + rtn = v('31/03/2008') + self.assertEqual(rtn, ('31/03/2008', 'oops')) + rtn = IS_DATE(format="%m/%d/%Y", error_message="oops").formatter(datetime.date(1834, 12, 14)) + self.assertEqual(rtn, '12/14/1834') + + def test_IS_DATETIME(self): + v = IS_DATETIME(format="%m/%d/%Y %H:%M", error_message="oops") + rtn = v('03/03/2008 12:40') + self.assertEqual(rtn, (datetime.datetime(2008, 3, 3, 12, 40), None)) + rtn = v('31/03/2008 29:40') + self.assertEqual(rtn, ('31/03/2008 29:40', 'oops')) + # Test timezone is removed and value is properly converted + # + # https://github.com/web2py/web2py/issues/1094 + + class DummyTimezone(datetime.tzinfo): + + ONE = datetime.timedelta(hours=1) + + def utcoffset(self, dt): + return DummyTimezone.ONE + + def tzname(self, dt): + return "UTC+1" + + def dst(self, dt): + return DummyTimezone.ONE + + def localize(self, dt, is_dst=False): + return dt.replace(tzinfo=self) + v = IS_DATETIME(format="%Y-%m-%d %H:%M", error_message="oops", timezone=DummyTimezone()) + rtn = v('1982-12-14 08:00') + self.assertEqual(rtn, (datetime.datetime(1982, 12, 14, 7, 0), None)) + + def test_IS_DATE_IN_RANGE(self): + v = IS_DATE_IN_RANGE(minimum=datetime.date(2008, 1, 1), + maximum=datetime.date(2009, 12, 31), + format="%m/%d/%Y", error_message="oops") + + rtn = v('03/03/2008') + self.assertEqual(rtn, (datetime.date(2008, 3, 3), None)) + rtn = v('03/03/2010') + self.assertEqual(rtn, ('03/03/2010', 'oops')) + rtn = v(datetime.date(2008, 3, 3)) + self.assertEqual(rtn, (datetime.date(2008, 3, 3), None)) + rtn = v(datetime.date(2010, 3, 3)) + self.assertEqual(rtn, (datetime.date(2010, 3, 3), 'oops')) + v = IS_DATE_IN_RANGE(maximum=datetime.date(2009, 12, 31), + format="%m/%d/%Y") + rtn = v('03/03/2010') + self.assertEqual(rtn, ('03/03/2010', 'Enter date on or before 12/31/2009')) + v = IS_DATE_IN_RANGE(minimum=datetime.date(2008, 1, 1), + format="%m/%d/%Y") + rtn = v('03/03/2007') + self.assertEqual(rtn, ('03/03/2007', 'Enter date on or after 01/01/2008')) + v = IS_DATE_IN_RANGE(minimum=datetime.date(2008, 1, 1), + maximum=datetime.date(2009, 12, 31), + format="%m/%d/%Y") + rtn = v('03/03/2007') + self.assertEqual(rtn, ('03/03/2007', 'Enter date in range 01/01/2008 12/31/2009')) + + def test_IS_DATETIME_IN_RANGE(self): + v = IS_DATETIME_IN_RANGE( + minimum=datetime.datetime(2008, 1, 1, 12, 20), + maximum=datetime.datetime(2009, 12, 31, 12, 20), + format="%m/%d/%Y %H:%M", error_message="oops") + rtn = v('03/03/2008 12:40') + self.assertEqual(rtn, (datetime.datetime(2008, 3, 3, 12, 40), None)) + rtn = v('03/03/2010 10:34') + self.assertEqual(rtn, ('03/03/2010 10:34', 'oops')) + rtn = v(datetime.datetime(2008, 3, 3, 0, 0)) + self.assertEqual(rtn, (datetime.datetime(2008, 3, 3, 0, 0), None)) + rtn = v(datetime.datetime(2010, 3, 3, 0, 0)) + self.assertEqual(rtn, (datetime.datetime(2010, 3, 3, 0, 0), 'oops')) + v = IS_DATETIME_IN_RANGE(maximum=datetime.datetime(2009, 12, 31, 12, 20), + format='%m/%d/%Y %H:%M:%S') + rtn = v('03/03/2010 12:20:00') + self.assertEqual(rtn, ('03/03/2010 12:20:00', 'Enter date and time on or before 12/31/2009 12:20:00')) + v = IS_DATETIME_IN_RANGE(minimum=datetime.datetime(2008, 1, 1, 12, 20), + format='%m/%d/%Y %H:%M:%S') + rtn = v('03/03/2007 12:20:00') + self.assertEqual(rtn, ('03/03/2007 12:20:00', 'Enter date and time on or after 01/01/2008 12:20:00')) + v = IS_DATETIME_IN_RANGE(minimum=datetime.datetime(2008, 1, 1, 12, 20), + maximum=datetime.datetime(2009, 12, 31, 12, 20), + format='%m/%d/%Y %H:%M:%S') + rtn = v('03/03/2007 12:20:00') + self.assertEqual(rtn, ('03/03/2007 12:20:00', 'Enter date and time in range 01/01/2008 12:20:00 12/31/2009 12:20:00')) + v = IS_DATETIME_IN_RANGE(maximum=datetime.datetime(2009, 12, 31, 12, 20), + format='%Y-%m-%d %H:%M:%S', error_message='oops') + rtn = v('clearly not a date') + self.assertEqual(rtn, ('clearly not a date', 'oops')) + + def test_IS_LIST_OF(self): + values = [0, 1, 2, 3, 4] + rtn = IS_LIST_OF(IS_INT_IN_RANGE(0, 10))(values) + self.assertEqual(rtn, (values, None)) + values.append(11) + rtn = IS_LIST_OF(IS_INT_IN_RANGE(0, 10))(values) + self.assertEqual(rtn, (values, 'Enter an integer between 0 and 9')) + rtn = IS_LIST_OF(IS_INT_IN_RANGE(0, 10))(1) + self.assertEqual(rtn, ([1], None)) + rtn = IS_LIST_OF(IS_INT_IN_RANGE(0, 10), minimum=10)([1, 2]) + self.assertEqual(rtn, ([1, 2], 'Minimum length is 10')) + rtn = IS_LIST_OF(IS_INT_IN_RANGE(0, 10), maximum=2)([1, 2, 3]) + self.assertEqual(rtn, ([1, 2, 3], 'Maximum length is 2')) + # regression test for issue 742 + rtn = IS_LIST_OF(minimum=1)('') + self.assertEqual(rtn, ([], 'Minimum length is 1')) + + def test_IS_LOWER(self): + rtn = IS_LOWER()('ABC') + self.assertEqual(rtn, ('abc', None)) + rtn = IS_LOWER()(b'ABC') + self.assertEqual(rtn, (b'abc', None)) + rtn = IS_LOWER()('Ñ') + self.assertEqual(rtn, ('ñ', None)) + + def test_IS_UPPER(self): + rtn = IS_UPPER()('abc') + self.assertEqual(rtn, ('ABC', None)) + rtn = IS_UPPER()(b'abc') + self.assertEqual(rtn, (b'ABC', None)) + rtn = IS_UPPER()('ñ') + self.assertEqual(rtn, ('Ñ', None)) + + def test_IS_SLUG(self): + rtn = IS_SLUG()('abc123') + self.assertEqual(rtn, ('abc123', None)) + rtn = IS_SLUG()('ABC123') + self.assertEqual(rtn, ('abc123', None)) + rtn = IS_SLUG()('abc-123') + self.assertEqual(rtn, ('abc-123', None)) + rtn = IS_SLUG()('abc--123') + self.assertEqual(rtn, ('abc-123', None)) + rtn = IS_SLUG()('abc 123') + self.assertEqual(rtn, ('abc-123', None)) + rtn = IS_SLUG()('abc\t_123') + self.assertEqual(rtn, ('abc-123', None)) + rtn = IS_SLUG()('-abc-') + self.assertEqual(rtn, ('abc', None)) + rtn = IS_SLUG()('--a--b--_ -c--') + self.assertEqual(rtn, ('a-b-c', None)) + rtn = IS_SLUG()('abc&123') + self.assertEqual(rtn, ('abc123', None)) + rtn = IS_SLUG()('abc&123&def') + self.assertEqual(rtn, ('abc123def', None)) + rtn = IS_SLUG()('ñ') + self.assertEqual(rtn, ('n', None)) + rtn = IS_SLUG(maxlen=4)('abc123') + self.assertEqual(rtn, ('abc1', None)) + rtn = IS_SLUG()('abc_123') + self.assertEqual(rtn, ('abc-123', None)) + rtn = IS_SLUG(keep_underscores=False)('abc_123') + self.assertEqual(rtn, ('abc-123', None)) + rtn = IS_SLUG(keep_underscores=True)('abc_123') + self.assertEqual(rtn, ('abc_123', None)) + rtn = IS_SLUG(check=False)('abc') + self.assertEqual(rtn, ('abc', None)) + rtn = IS_SLUG(check=True)('abc') + self.assertEqual(rtn, ('abc', None)) + rtn = IS_SLUG(check=False)('a bc') + self.assertEqual(rtn, ('a-bc', None)) + rtn = IS_SLUG(check=True)('a bc') + self.assertEqual(rtn, ('a bc', 'Must be slug')) + + def test_ANY_OF(self): + rtn = ANY_OF([IS_EMAIL(), IS_ALPHANUMERIC()])('a@b.co') + self.assertEqual(rtn, ('a@b.co', None)) + rtn = ANY_OF([IS_EMAIL(), IS_ALPHANUMERIC()])('abco') + self.assertEqual(rtn, ('abco', None)) + rtn = ANY_OF([IS_EMAIL(), IS_ALPHANUMERIC()])('@ab.co') + self.assertEqual(rtn, ('@ab.co', 'Enter only letters, numbers, and underscore')) + rtn = ANY_OF([IS_ALPHANUMERIC(), IS_EMAIL()])('@ab.co') + self.assertEqual(rtn, ('@ab.co', 'Enter a valid email address')) + rtn = ANY_OF([IS_DATE(), IS_EMAIL()])('a@b.co') + self.assertEqual(rtn, ('a@b.co', None)) + rtn = ANY_OF([IS_DATE(), IS_EMAIL()])('1982-12-14') + self.assertEqual(rtn, (datetime.date(1982, 12, 14), None)) + rtn = ANY_OF([IS_DATE(format='%m/%d/%Y'), IS_EMAIL()]).formatter(datetime.date(1834, 12, 14)) + self.assertEqual(rtn, '12/14/1834') + + def test_IS_EMPTY_OR(self): + rtn = IS_EMPTY_OR(IS_EMAIL())('abc@def.com') + self.assertEqual(rtn, ('abc@def.com', None)) + rtn = IS_EMPTY_OR(IS_EMAIL())(' ') + self.assertEqual(rtn, (None, None)) + rtn = IS_EMPTY_OR(IS_EMAIL(), null='abc')(' ') + self.assertEqual(rtn, ('abc', None)) + rtn = IS_EMPTY_OR(IS_EMAIL(), null='abc', empty_regex='def')('def') + self.assertEqual(rtn, ('abc', None)) + rtn = IS_EMPTY_OR(IS_EMAIL())('abc') + self.assertEqual(rtn, ('abc', 'Enter a valid email address')) + rtn = IS_EMPTY_OR(IS_EMAIL())(' abc ') + self.assertEqual(rtn, (' abc ', 'Enter a valid email address')) + rtn = IS_EMPTY_OR(IS_IN_SET([('id1', 'first label'), ('id2', 'second label')], zero='zero')).options(zero=False) + self.assertEqual(rtn, [('', ''), ('id1', 'first label'), ('id2', 'second label')]) + rtn = IS_EMPTY_OR(IS_IN_SET([('id1', 'first label'), ('id2', 'second label')], zero='zero')).options() + self.assertEqual(rtn, [('', 'zero'), ('id1', 'first label'), ('id2', 'second label')]) + rtn = IS_EMPTY_OR((IS_LOWER(), IS_EMAIL()))('AAA') + self.assertEqual(rtn, ('aaa', 'Enter a valid email address')) + rtn = IS_EMPTY_OR([IS_LOWER(), IS_EMAIL()])('AAA') + self.assertEqual(rtn, ('aaa', 'Enter a valid email address')) + + def test_CLEANUP(self): + rtn = CLEANUP()('helloò') + self.assertEqual(rtn, ('hello', None)) + + def test_CRYPT(self): + rtn = str(CRYPT(digest_alg='md5', salt=True)('test')[0]) + self.myassertRegex(rtn, r'^md5\$.{16}\$.{32}$') + rtn = str(CRYPT(digest_alg='sha1', salt=True)('test')[0]) + self.myassertRegex(rtn, r'^sha1\$.{16}\$.{40}$') + rtn = str(CRYPT(digest_alg='sha256', salt=True)('test')[0]) + self.myassertRegex(rtn, r'^sha256\$.{16}\$.{64}$') + rtn = str(CRYPT(digest_alg='sha384', salt=True)('test')[0]) + self.myassertRegex(rtn, r'^sha384\$.{16}\$.{96}$') + rtn = str(CRYPT(digest_alg='sha512', salt=True)('test')[0]) + self.myassertRegex(rtn, r'^sha512\$.{16}\$.{128}$') + alg = 'pbkdf2(1000,20,sha512)' + rtn = str(CRYPT(digest_alg=alg, salt=True)('test')[0]) + self.myassertRegex(rtn, r'^pbkdf2\(1000,20,sha512\)\$.{16}\$.{40}$') + rtn = str(CRYPT(digest_alg='md5', key='mykey', salt=True)('test')[0]) + self.myassertRegex(rtn, r'^md5\$.{16}\$.{32}$') + a = str(CRYPT(digest_alg='sha1', salt=False)('test')[0]) + self.assertEqual(CRYPT(digest_alg='sha1', salt=False)('test')[0], a) + self.assertEqual(CRYPT(digest_alg='sha1', salt=False)('test')[0], a[6:]) + self.assertEqual(CRYPT(digest_alg='md5', salt=False)('test')[0], a) + self.assertEqual(CRYPT(digest_alg='md5', salt=False)('test')[0], a[6:]) + + def test_IS_STRONG(self): + rtn = IS_STRONG(es=True)('Abcd1234') + self.assertEqual(rtn, ('Abcd1234', + 'Must include at least 1 of the following: ~!@#$%^&*()_+-=?<>,.:;{}[]|')) + rtn = IS_STRONG(es=True)('Abcd1234!') + self.assertEqual(rtn, ('Abcd1234!', None)) + rtn = IS_STRONG(es=True, entropy=1)('a') + self.assertEqual(rtn, ('a', None)) + rtn = IS_STRONG(es=True, entropy=1, min=2)('a') + self.assertEqual(rtn, ('a', 'Minimum length is 2')) + rtn = IS_STRONG(es=True, entropy=100)('abc123') + self.assertEqual(rtn, ('abc123', 'Entropy (32.35) less than required (100)')) + rtn = IS_STRONG(es=True, entropy=100)('and') + self.assertEqual(rtn, ('and', 'Entropy (14.57) less than required (100)')) + rtn = IS_STRONG(es=True, entropy=100)('aaa') + self.assertEqual(rtn, ('aaa', 'Entropy (14.42) less than required (100)')) + rtn = IS_STRONG(es=True, entropy=100)('a1d') + self.assertEqual(rtn, ('a1d', 'Entropy (15.97) less than required (100)')) + rtn = IS_STRONG(es=True, entropy=100)('añd') + if PY2: + self.assertEqual(rtn, ('a\xc3\xb1d', 'Entropy (18.13) less than required (100)')) + else: + self.assertEqual(rtn, ('añd', 'Entropy (18.13) less than required (100)')) + rtn = IS_STRONG()('********') + self.assertEqual(rtn, ('********', None)) + rtn = IS_STRONG(es=True, max=4)('abcde') + self.assertEqual(rtn, + ('abcde', + '|'.join(['Minimum length is 8', + 'Maximum length is 4', + 'Must include at least 1 of the following: ~!@#$%^&*()_+-=?<>,.:;{}[]|', + 'Must include at least 1 uppercase', + 'Must include at least 1 number'])) + ) + rtn = IS_STRONG(es=True)('abcde') + self.assertEqual(rtn, + ('abcde', + '|'.join(['Minimum length is 8', + 'Must include at least 1 of the following: ~!@#$%^&*()_+-=?<>,.:;{}[]|', + 'Must include at least 1 uppercase', + 'Must include at least 1 number'])) + ) + rtn = IS_STRONG(upper=0, lower=0, number=0, es=True)('Abcde1') + self.assertEqual(rtn, + ('Abcde1', + '|'.join(['Minimum length is 8', + 'Must include at least 1 of the following: ~!@#$%^&*()_+-=?<>,.:;{}[]|', + 'May not include any uppercase letters', + 'May not include any lowercase letters', + 'May not include any numbers'])) + ) + + def test_IS_IMAGE(self): + class DummyImageFile(object): + + def __init__(self, filename, ext, width, height): + from io import BytesIO + import struct + self.filename = filename + '.' + ext + self.file = BytesIO() + if ext == 'bmp': + self.file.write(b'BM') + self.file.write(b' ' * 16) + self.file.write(struct.pack(' +| License: LGPLv3 (http://www.gnu.org/licenses/lgpl.html) +| Thanks to ga2arch for help with IS_IN_DB and IS_NOT_IN_DB on GAE + +Validators +----------- +""" +import os +import re +import datetime +import time +import cgi +import json +import struct +import decimal +import unicodedata + +from gluon._compat import StringIO, integer_types, basestring, unicodeT, urllib_unquote, unichr, to_bytes, PY2, \ + to_unicode, to_native, string_types, urlparse +from gluon.utils import simple_hash, web2py_uuid, DIGEST_ALG_BY_SIZE +from pydal.objects import Field, FieldVirtual, FieldMethod +from functools import reduce + +regex_isint = re.compile('^[+-]?\d+$') + +JSONErrors = (NameError, TypeError, ValueError, AttributeError, + KeyError) + __all__ = [ 'ANY_OF', 'CLEANUP', @@ -9,7 +42,6 @@ __all__ = [ 'IS_DATETIME', 'IS_DECIMAL_IN_RANGE', 'IS_EMAIL', - 'IS_GENERIC_URL', 'IS_LIST_OF_EMAILS', 'IS_EMPTY_OR', 'IS_EXPR', @@ -21,7 +53,6 @@ __all__ = [ 'IS_IPV4', 'IS_IPV6', 'IS_IPADDRESS', - 'IS_HTTP_URL', 'IS_LENGTH', 'IS_LIST_OF', 'IS_LOWER', @@ -39,4 +70,3866 @@ __all__ = [ 'IS_JSON', ] -from pydal.validators import * +try: + from gluon.globals import current + have_current = True +except ImportError: + have_current = False + + +def translate(text): + if text is None: + return None + elif isinstance(text, (str, unicodeT)) and have_current: + if hasattr(current, 'T'): + return str(current.T(text)) + return str(text) + + +def options_sorter(x, y): + return (str(x[1]).upper() > str(y[1]).upper() and 1) or -1 + + +class Validator(object): + """ + Root for all validators, mainly for documentation purposes. + + Validators are classes used to validate input fields (including forms + generated from database tables). + + Here is an example of using a validator with a FORM:: + + INPUT(_name='a', requires=IS_INT_IN_RANGE(0, 10)) + + Here is an example of how to require a validator for a table field:: + + db.define_table('person', Field('name')) + db.person.name.requires=IS_NOT_EMPTY() + + Validators are always assigned using the requires attribute of a field. A + field can have a single validator or multiple validators. Multiple + validators are made part of a list:: + + db.person.name.requires=[IS_NOT_EMPTY(), IS_NOT_IN_DB(db, 'person.id')] + + Validators are called by the function accepts on a FORM or other HTML + helper object that contains a form. They are always called in the order in + which they are listed. + + Built-in validators have constructors that take the optional argument error + message which allows you to change the default error message. + Here is an example of a validator on a database table:: + + db.person.name.requires=IS_NOT_EMPTY(error_message=T('Fill this')) + + where we have used the translation operator T to allow for + internationalization. + + Notice that default error messages are not translated. + """ + + def formatter(self, value): + """ + For some validators returns a formatted version (matching the validator) + of value. Otherwise just returns the value. + """ + return value + + def __call__(self, value): + raise NotImplementedError + + +class IS_MATCH(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_MATCH('.+')) + + The argument of IS_MATCH is a regular expression:: + + >>> IS_MATCH('.+')('hello') + ('hello', None) + + >>> IS_MATCH('hell')('hello') + ('hello', None) + + >>> IS_MATCH('hell.*', strict=False)('hello') + ('hello', None) + + >>> IS_MATCH('hello')('shello') + ('shello', 'invalid expression') + + >>> IS_MATCH('hello', search=True)('shello') + ('shello', None) + + >>> IS_MATCH('hello', search=True, strict=False)('shellox') + ('shellox', None) + + >>> IS_MATCH('.*hello.*', search=True, strict=False)('shellox') + ('shellox', None) + + >>> IS_MATCH('.+')('') + ('', 'invalid expression') + + """ + + def __init__(self, expression, error_message='Invalid expression', + strict=False, search=False, extract=False, + is_unicode=False): + + if strict or not search: + if not expression.startswith('^'): + expression = '^(%s)' % expression + if strict: + if not expression.endswith('$'): + expression = '(%s)$' % expression + if is_unicode: + if not isinstance(expression, unicodeT): + expression = expression.decode('utf8') + self.regex = re.compile(expression, re.UNICODE) + else: + self.regex = re.compile(expression) + self.error_message = error_message + self.extract = extract + self.is_unicode = is_unicode or (not(PY2)) + + def __call__(self, value): + if not(PY2): # PY3 convert bytes to unicode + value = to_unicode(value) + + if self.is_unicode or not(PY2): + if not isinstance(value, unicodeT): + match = self.regex.search(str(value).decode('utf8')) + else: + match = self.regex.search(value) + else: + if not isinstance(value, unicodeT): + match = self.regex.search(str(value)) + else: + match = self.regex.search(value.encode('utf8')) + if match is not None: + return (self.extract and match.group() or value, None) + return (value, translate(self.error_message)) + + +class IS_EQUAL_TO(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='password') + INPUT(_type='text', _name='password2', + requires=IS_EQUAL_TO(request.vars.password)) + + The argument of IS_EQUAL_TO is a string:: + + >>> IS_EQUAL_TO('aaa')('aaa') + ('aaa', None) + + >>> IS_EQUAL_TO('aaa')('aab') + ('aab', 'no match') + + """ + + def __init__(self, expression, error_message='No match'): + self.expression = expression + self.error_message = error_message + + def __call__(self, value): + if value == self.expression: + return (value, None) + return (value, translate(self.error_message)) + + +class IS_EXPR(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', + requires=IS_EXPR('5 < int(value) < 10')) + + The argument of IS_EXPR must be python condition:: + + >>> IS_EXPR('int(value) < 2')('1') + ('1', None) + + >>> IS_EXPR('int(value) < 2')('2') + ('2', 'invalid expression') + + """ + + def __init__(self, expression, error_message='Invalid expression', environment=None): + self.expression = expression + self.error_message = error_message + self.environment = environment or {} + + def __call__(self, value): + if callable(self.expression): + return (value, self.expression(value)) + # for backward compatibility + self.environment.update(value=value) + exec('__ret__=' + self.expression, self.environment) + if self.environment['__ret__']: + return (value, None) + return (value, translate(self.error_message)) + + +class IS_LENGTH(Validator): + """ + Checks if length of field's value fits between given boundaries. Works + for both text and file inputs. + + Args: + maxsize: maximum allowed length / size + minsize: minimum allowed length / size + + Examples: + Check if text string is shorter than 33 characters:: + + INPUT(_type='text', _name='name', requires=IS_LENGTH(32)) + + Check if password string is longer than 5 characters:: + + INPUT(_type='password', _name='name', requires=IS_LENGTH(minsize=6)) + + Check if uploaded file has size between 1KB and 1MB:: + + INPUT(_type='file', _name='name', requires=IS_LENGTH(1048576, 1024)) + + Other examples:: + + >>> IS_LENGTH()('') + ('', None) + >>> IS_LENGTH()('1234567890') + ('1234567890', None) + >>> IS_LENGTH(maxsize=5, minsize=0)('1234567890') # too long + ('1234567890', 'enter from 0 to 5 characters') + >>> IS_LENGTH(maxsize=50, minsize=20)('1234567890') # too short + ('1234567890', 'enter from 20 to 50 characters') + """ + + def __init__(self, maxsize=255, minsize=0, + error_message='Enter from %(min)g to %(max)g characters'): + self.maxsize = maxsize + self.minsize = minsize + self.error_message = error_message + + def __call__(self, value): + if value is None: + length = 0 + if self.minsize <= length <= self.maxsize: + return (value, None) + elif isinstance(value, cgi.FieldStorage): + if value.file: + value.file.seek(0, os.SEEK_END) + length = value.file.tell() + value.file.seek(0, os.SEEK_SET) + elif hasattr(value, 'value'): + val = value.value + if val: + length = len(val) + else: + length = 0 + if self.minsize <= length <= self.maxsize: + return (value, None) + elif isinstance(value, str): + try: + lvalue = len(to_unicode(value)) + except: + lvalue = len(value) + if self.minsize <= lvalue <= self.maxsize: + return (value, None) + elif isinstance(value, unicodeT): + if self.minsize <= len(value) <= self.maxsize: + return (value.encode('utf8'), None) + elif isinstance(value, (bytes, bytearray)): + if self.minsize <= len(value) <= self.maxsize: + return (value, None) + elif isinstance(value, (tuple, list)): + if self.minsize <= len(value) <= self.maxsize: + return (value, None) + elif self.minsize <= len(str(value)) <= self.maxsize: + return (str(value), None) + return (value, translate(self.error_message) + % dict(min=self.minsize, max=self.maxsize)) + + +class IS_JSON(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', + requires=IS_JSON(error_message="This is not a valid json input") + + >>> IS_JSON()('{"a": 100}') + ({u'a': 100}, None) + + >>> IS_JSON()('spam1234') + ('spam1234', 'invalid json') + """ + + def __init__(self, error_message='Invalid json', native_json=False): + self.native_json = native_json + self.error_message = error_message + + def __call__(self, value): + try: + if self.native_json: + json.loads(value) # raises error in case of malformed json + return (value, None) # the serialized value is not passed + else: + return (json.loads(value), None) + except JSONErrors: + return (value, translate(self.error_message)) + + def formatter(self, value): + if value is None: + return None + if self.native_json: + return value + else: + return json.dumps(value) + + +class IS_IN_SET(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', + requires=IS_IN_SET(['max', 'john'],zero='')) + + The argument of IS_IN_SET must be a list or set:: + + >>> IS_IN_SET(['max', 'john'])('max') + ('max', None) + >>> IS_IN_SET(['max', 'john'])('massimo') + ('massimo', 'value not allowed') + >>> IS_IN_SET(['max', 'john'], multiple=True)(('max', 'john')) + (('max', 'john'), None) + >>> IS_IN_SET(['max', 'john'], multiple=True)(('bill', 'john')) + (('bill', 'john'), 'value not allowed') + >>> IS_IN_SET(('id1','id2'), ['first label','second label'])('id1') # Traditional way + ('id1', None) + >>> IS_IN_SET({'id1':'first label', 'id2':'second label'})('id1') + ('id1', None) + >>> import itertools + >>> IS_IN_SET(itertools.chain(['1','3','5'],['2','4','6']))('1') + ('1', None) + >>> IS_IN_SET([('id1','first label'), ('id2','second label')])('id1') # Redundant way + ('id1', None) + + """ + + def __init__( + self, + theset, + labels=None, + error_message='Value not allowed', + multiple=False, + zero='', + sort=False, + ): + self.multiple = multiple + if isinstance(theset, dict): + self.theset = [str(item) for item in theset] + self.labels = list(theset.values()) + elif theset and isinstance(theset, (tuple, list)) \ + and isinstance(theset[0], (tuple, list)) and len(theset[0]) == 2: + self.theset = [str(item) for item, label in theset] + self.labels = [str(label) for item, label in theset] + else: + self.theset = [str(item) for item in theset] + self.labels = labels + self.error_message = error_message + self.zero = zero + self.sort = sort + + def options(self, zero=True): + if not self.labels: + items = [(k, k) for (i, k) in enumerate(self.theset)] + else: + items = [(k, list(self.labels)[i]) for (i, k) in enumerate(self.theset)] + if self.sort: + items.sort(key=lambda o: str(o[1]).upper()) + if zero and self.zero is not None and not self.multiple: + items.insert(0, ('', self.zero)) + return items + + def __call__(self, value): + if self.multiple: + # if below was values = re.compile("[\w\-:]+").findall(str(value)) + if not value: + values = [] + elif isinstance(value, (tuple, list)): + values = value + else: + values = [value] + else: + values = [value] + thestrset = [str(x) for x in self.theset] + failures = [x for x in values if not str(x) in thestrset] + if failures and self.theset: + return (value, translate(self.error_message)) + if self.multiple: + if isinstance(self.multiple, (tuple, list)) and \ + not self.multiple[0] <= len(values) < self.multiple[1]: + return (values, translate(self.error_message)) + return (values, None) + return (value, None) + + +regex1 = re.compile('\w+\.\w+') +regex2 = re.compile('%\(([^\)]+)\)\d*(?:\.\d+)?[a-zA-Z]') + + +class IS_IN_DB(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', + requires=IS_IN_DB(db, db.mytable.myfield, zero='')) + + used for reference fields, rendered as a dropbox + """ + + def __init__( + self, + dbset, + field, + label=None, + error_message='Value not in database', + orderby=None, + groupby=None, + distinct=None, + cache=None, + multiple=False, + zero='', + sort=False, + _and=None, + left=None, + delimiter=None, + auto_add=False, + ): + from pydal.objects import Table + if hasattr(dbset, 'define_table'): + self.dbset = dbset() + else: + self.dbset = dbset + + if isinstance(field, Table): + field = field._id + elif isinstance(field, str): + items = field.split('.') + if len(items) == 1: + field = items[0] + '.id' + + (ktable, kfield) = str(field).split('.') + if not label: + label = '%%(%s)s' % kfield + if isinstance(label, str): + if regex1.match(str(label)): + label = '%%(%s)s' % str(label).split('.')[-1] + fieldnames = regex2.findall(label) + if kfield not in fieldnames: + fieldnames.append(kfield) # kfield must be last + elif isinstance(label, Field): + fieldnames = [label.name, kfield] # kfield must be last + label = '%%(%s)s' % label.name + elif callable(label): + fieldnames = '*' + else: + raise NotImplementedError + + self.fieldnames = fieldnames # fields requires to build the formatting + self.label = label + self.ktable = ktable + self.kfield = kfield + self.error_message = error_message + self.theset = None + self.orderby = orderby + self.groupby = groupby + self.distinct = distinct + self.cache = cache + self.multiple = multiple + self.zero = zero + self.sort = sort + self._and = _and + self.left = left + self.delimiter = delimiter + self.auto_add = auto_add + + def set_self_id(self, id): + if self._and: + self._and.record_id = id + + def build_set(self): + table = self.dbset.db[self.ktable] + if self.fieldnames == '*': + fields = [f for f in table] + else: + fields = [table[k] for k in self.fieldnames] + ignore = (FieldVirtual, FieldMethod) + fields = [f for f in fields if not isinstance(f, ignore)] + if self.dbset.db._dbname != 'gae': + orderby = self.orderby or reduce(lambda a, b: a | b, fields) + groupby = self.groupby + distinct = self.distinct + left = self.left + dd = dict(orderby=orderby, groupby=groupby, + distinct=distinct, cache=self.cache, + cacheable=True, left=left) + records = self.dbset(table).select(*fields, **dd) + else: + orderby = self.orderby or \ + reduce(lambda a, b: a | b, ( + f for f in fields if not f.name == 'id')) + dd = dict(orderby=orderby, cache=self.cache, cacheable=True) + records = self.dbset(table).select(table.ALL, **dd) + self.theset = [str(r[self.kfield]) for r in records] + if isinstance(self.label, str): + self.labels = [self.label % r for r in records] + else: + self.labels = [self.label(r) for r in records] + + def options(self, zero=True): + self.build_set() + items = [(k, self.labels[i]) for (i, k) in enumerate(self.theset)] + if self.sort: + items.sort(key=lambda o: str(o[1]).upper()) + if zero and self.zero is not None and not self.multiple: + items.insert(0, ('', self.zero)) + return items + + def maybe_add(self, table, fieldname, value): + d = {fieldname: value} + record = table(**d) + if record: + return record.id + else: + return table.insert(**d) + + def __call__(self, value): + table = self.dbset.db[self.ktable] + field = table[self.kfield] + + if self.multiple: + if self._and: + raise NotImplementedError + if isinstance(value, list): + values = value + elif self.delimiter: + values = value.split(self.delimiter) # because of autocomplete + elif value: + values = [value] + else: + values = [] + + if field.type in ('id', 'integer'): + new_values = [] + for value in values: + if not (isinstance(value, integer_types) or value.isdigit()): + if self.auto_add: + value = str(self.maybe_add(table, self.fieldnames[0], value)) + else: + return (values, translate(self.error_message)) + new_values.append(value) + values = new_values + + if isinstance(self.multiple, (tuple, list)) and \ + not self.multiple[0] <= len(values) < self.multiple[1]: + return (values, translate(self.error_message)) + if self.theset: + if not [v for v in values if v not in self.theset]: + return (values, None) + else: + def count(values, s=self.dbset, f=field): + return s(f.belongs(list(map(int, values)))).count() + + if self.dbset.db._adapter.dbengine == "google:datastore": + range_ids = range(0, len(values), 30) + total = sum(count(values[i:i + 30]) for i in range_ids) + if total == len(values): + return (values, None) + elif count(values) == len(values): + return (values, None) + else: + if field.type in ('id', 'integer'): + if isinstance(value, integer_types) or (isinstance(value, string_types) and value.isdigit()): + value = int(value) + elif self.auto_add: + value = self.maybe_add(table, self.fieldnames[0], value) + else: + return (value, translate(self.error_message)) + + try: + value = int(value) + except TypeError: + return (value, translate(self.error_message)) + + if self.theset: + if str(value) in self.theset: + if self._and: + return self._and(value) + else: + return (value, None) + else: + if self.dbset(field == value).count(): + if self._and: + return self._and(value) + else: + return (value, None) + return (value, translate(self.error_message)) + + +class IS_NOT_IN_DB(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_NOT_IN_DB(db, db.table)) + + makes the field unique + """ + + def __init__( + self, + dbset, + field, + error_message='Value already in database or empty', + allowed_override=[], + ignore_common_filters=False, + ): + + from pydal.objects import Table + if isinstance(field, Table): + field = field._id + + if hasattr(dbset, 'define_table'): + self.dbset = dbset() + else: + self.dbset = dbset + self.field = field + self.error_message = error_message + self.record_id = 0 + self.allowed_override = allowed_override + self.ignore_common_filters = ignore_common_filters + + def set_self_id(self, id): + self.record_id = id + + def __call__(self, value): + value = to_native(str(value)) + if not value.strip(): + return (value, translate(self.error_message)) + if value in self.allowed_override: + return (value, None) + (tablename, fieldname) = str(self.field).split('.') + table = self.dbset.db[tablename] + field = table[fieldname] + subset = self.dbset(field == value, + ignore_common_filters=self.ignore_common_filters) + id = self.record_id + if isinstance(id, dict): + fields = [table[f] for f in id] + row = subset.select(*fields, **dict(limitby=(0, 1), orderby_on_limitby=False)).first() + if row and any(str(row[f]) != str(id[f]) for f in id): + return (value, translate(self.error_message)) + else: + row = subset.select(table._id, field, limitby=(0, 1), orderby_on_limitby=False).first() + if row and str(row[table._id]) != str(id): + return (value, translate(self.error_message)) + return (value, None) + + +def range_error_message(error_message, what_to_enter, minimum, maximum): + """build the error message for the number range validators""" + if error_message is None: + error_message = 'Enter ' + what_to_enter + if minimum is not None and maximum is not None: + error_message += ' between %(min)g and %(max)g' + elif minimum is not None: + error_message += ' greater than or equal to %(min)g' + elif maximum is not None: + error_message += ' less than or equal to %(max)g' + if type(maximum) in integer_types: + maximum -= 1 + return translate(error_message) % dict(min=minimum, max=maximum) + + +class IS_INT_IN_RANGE(Validator): + """ + Determines that the argument is (or can be represented as) an int, + and that it falls within the specified range. The range is interpreted + in the Pythonic way, so the test is: min <= value < max. + + The minimum and maximum limits can be None, meaning no lower or upper limit, + respectively. + + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_INT_IN_RANGE(0, 10)) + + >>> IS_INT_IN_RANGE(1,5)('4') + (4, None) + >>> IS_INT_IN_RANGE(1,5)(4) + (4, None) + >>> IS_INT_IN_RANGE(1,5)(1) + (1, None) + >>> IS_INT_IN_RANGE(1,5)(5) + (5, 'enter an integer between 1 and 4') + >>> IS_INT_IN_RANGE(1,5)(5) + (5, 'enter an integer between 1 and 4') + >>> IS_INT_IN_RANGE(1,5)(3.5) + (3.5, 'enter an integer between 1 and 4') + >>> IS_INT_IN_RANGE(None,5)('4') + (4, None) + >>> IS_INT_IN_RANGE(None,5)('6') + ('6', 'enter an integer less than or equal to 4') + >>> IS_INT_IN_RANGE(1,None)('4') + (4, None) + >>> IS_INT_IN_RANGE(1,None)('0') + ('0', 'enter an integer greater than or equal to 1') + >>> IS_INT_IN_RANGE()(6) + (6, None) + >>> IS_INT_IN_RANGE()('abc') + ('abc', 'enter an integer') + """ + + def __init__( + self, + minimum=None, + maximum=None, + error_message=None, + ): + self.minimum = int(minimum) if minimum is not None else None + self.maximum = int(maximum) if maximum is not None else None + self.error_message = range_error_message( + error_message, 'an integer', self.minimum, self.maximum) + + def __call__(self, value): + if regex_isint.match(str(value)): + v = int(value) + if ((self.minimum is None or v >= self.minimum) and + (self.maximum is None or v < self.maximum)): + return (v, None) + return (value, self.error_message) + + +def str2dec(number): + s = str(number) + if '.' not in s: + s += '.00' + else: + s += '0' * (2 - len(s.split('.')[1])) + return s + + +class IS_FLOAT_IN_RANGE(Validator): + """ + Determines that the argument is (or can be represented as) a float, + and that it falls within the specified inclusive range. + The comparison is made with native arithmetic. + + The minimum and maximum limits can be None, meaning no lower or upper limit, + respectively. + + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_FLOAT_IN_RANGE(0, 10)) + + >>> IS_FLOAT_IN_RANGE(1,5)('4') + (4.0, None) + >>> IS_FLOAT_IN_RANGE(1,5)(4) + (4.0, None) + >>> IS_FLOAT_IN_RANGE(1,5)(1) + (1.0, None) + >>> IS_FLOAT_IN_RANGE(1,5)(5.25) + (5.25, 'enter a number between 1 and 5') + >>> IS_FLOAT_IN_RANGE(1,5)(6.0) + (6.0, 'enter a number between 1 and 5') + >>> IS_FLOAT_IN_RANGE(1,5)(3.5) + (3.5, None) + >>> IS_FLOAT_IN_RANGE(1,None)(3.5) + (3.5, None) + >>> IS_FLOAT_IN_RANGE(None,5)(3.5) + (3.5, None) + >>> IS_FLOAT_IN_RANGE(1,None)(0.5) + (0.5, 'enter a number greater than or equal to 1') + >>> IS_FLOAT_IN_RANGE(None,5)(6.5) + (6.5, 'enter a number less than or equal to 5') + >>> IS_FLOAT_IN_RANGE()(6.5) + (6.5, None) + >>> IS_FLOAT_IN_RANGE()('abc') + ('abc', 'enter a number') + """ + + def __init__( + self, + minimum=None, + maximum=None, + error_message=None, + dot='.' + ): + self.minimum = float(minimum) if minimum is not None else None + self.maximum = float(maximum) if maximum is not None else None + self.dot = str(dot) + self.error_message = range_error_message( + error_message, 'a number', self.minimum, self.maximum) + + def __call__(self, value): + try: + if self.dot == '.': + v = float(value) + else: + v = float(str(value).replace(self.dot, '.')) + if ((self.minimum is None or v >= self.minimum) and + (self.maximum is None or v <= self.maximum)): + return (v, None) + except (ValueError, TypeError): + pass + return (value, self.error_message) + + def formatter(self, value): + if value is None: + return None + return str2dec(value).replace('.', self.dot) + + +class IS_DECIMAL_IN_RANGE(Validator): + """ + Determines that the argument is (or can be represented as) a Python Decimal, + and that it falls within the specified inclusive range. + The comparison is made with Python Decimal arithmetic. + + The minimum and maximum limits can be None, meaning no lower or upper limit, + respectively. + + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_DECIMAL_IN_RANGE(0, 10)) + + >>> IS_DECIMAL_IN_RANGE(1,5)('4') + (Decimal('4'), None) + >>> IS_DECIMAL_IN_RANGE(1,5)(4) + (Decimal('4'), None) + >>> IS_DECIMAL_IN_RANGE(1,5)(1) + (Decimal('1'), None) + >>> IS_DECIMAL_IN_RANGE(1,5)(5.25) + (5.25, 'enter a number between 1 and 5') + >>> IS_DECIMAL_IN_RANGE(5.25,6)(5.25) + (Decimal('5.25'), None) + >>> IS_DECIMAL_IN_RANGE(5.25,6)('5.25') + (Decimal('5.25'), None) + >>> IS_DECIMAL_IN_RANGE(1,5)(6.0) + (6.0, 'enter a number between 1 and 5') + >>> IS_DECIMAL_IN_RANGE(1,5)(3.5) + (Decimal('3.5'), None) + >>> IS_DECIMAL_IN_RANGE(1.5,5.5)(3.5) + (Decimal('3.5'), None) + >>> IS_DECIMAL_IN_RANGE(1.5,5.5)(6.5) + (6.5, 'enter a number between 1.5 and 5.5') + >>> IS_DECIMAL_IN_RANGE(1.5,None)(6.5) + (Decimal('6.5'), None) + >>> IS_DECIMAL_IN_RANGE(1.5,None)(0.5) + (0.5, 'enter a number greater than or equal to 1.5') + >>> IS_DECIMAL_IN_RANGE(None,5.5)(4.5) + (Decimal('4.5'), None) + >>> IS_DECIMAL_IN_RANGE(None,5.5)(6.5) + (6.5, 'enter a number less than or equal to 5.5') + >>> IS_DECIMAL_IN_RANGE()(6.5) + (Decimal('6.5'), None) + >>> IS_DECIMAL_IN_RANGE(0,99)(123.123) + (123.123, 'enter a number between 0 and 99') + >>> IS_DECIMAL_IN_RANGE(0,99)('123.123') + ('123.123', 'enter a number between 0 and 99') + >>> IS_DECIMAL_IN_RANGE(0,99)('12.34') + (Decimal('12.34'), None) + >>> IS_DECIMAL_IN_RANGE()('abc') + ('abc', 'enter a number') + """ + + def __init__( + self, + minimum=None, + maximum=None, + error_message=None, + dot='.' + ): + self.minimum = decimal.Decimal(str(minimum)) if minimum is not None else None + self.maximum = decimal.Decimal(str(maximum)) if maximum is not None else None + self.dot = str(dot) + self.error_message = range_error_message( + error_message, 'a number', self.minimum, self.maximum) + + def __call__(self, value): + try: + if isinstance(value, decimal.Decimal): + v = value + else: + v = decimal.Decimal(str(value).replace(self.dot, '.')) + if ((self.minimum is None or v >= self.minimum) and + (self.maximum is None or v <= self.maximum)): + return (v, None) + except (ValueError, TypeError, decimal.InvalidOperation): + pass + return (value, self.error_message) + + def formatter(self, value): + if value is None: + return None + return str2dec(value).replace('.', self.dot) + + +def is_empty(value, empty_regex=None): + _value = value + """test empty field""" + if isinstance(value, (str, unicodeT)): + value = value.strip() + if empty_regex is not None and empty_regex.match(value): + value = '' + if value is None or value == '' or value == b'' or value == []: + return (_value, True) + return (_value, False) + + +class IS_NOT_EMPTY(Validator): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_NOT_EMPTY()) + + >>> IS_NOT_EMPTY()(1) + (1, None) + >>> IS_NOT_EMPTY()(0) + (0, None) + >>> IS_NOT_EMPTY()('x') + ('x', None) + >>> IS_NOT_EMPTY()(' x ') + ('x', None) + >>> IS_NOT_EMPTY()(None) + (None, 'enter a value') + >>> IS_NOT_EMPTY()('') + ('', 'enter a value') + >>> IS_NOT_EMPTY()(' ') + ('', 'enter a value') + >>> IS_NOT_EMPTY()(' \\n\\t') + ('', 'enter a value') + >>> IS_NOT_EMPTY()([]) + ([], 'enter a value') + >>> IS_NOT_EMPTY(empty_regex='def')('def') + ('', 'enter a value') + >>> IS_NOT_EMPTY(empty_regex='de[fg]')('deg') + ('', 'enter a value') + >>> IS_NOT_EMPTY(empty_regex='def')('abc') + ('abc', None) + """ + + def __init__(self, error_message='Enter a value', empty_regex=None): + self.error_message = error_message + if empty_regex is not None: + self.empty_regex = re.compile(empty_regex) + else: + self.empty_regex = None + + def __call__(self, value): + value, empty = is_empty(value, empty_regex=self.empty_regex) + if empty: + return (value, translate(self.error_message)) + return (value, None) + + +class IS_ALPHANUMERIC(IS_MATCH): + """ + Example: + Used as:: + + INPUT(_type='text', _name='name', requires=IS_ALPHANUMERIC()) + + >>> IS_ALPHANUMERIC()('1') + ('1', None) + >>> IS_ALPHANUMERIC()('') + ('', None) + >>> IS_ALPHANUMERIC()('A_a') + ('A_a', None) + >>> IS_ALPHANUMERIC()('!') + ('!', 'enter only letters, numbers, and underscore') + """ + + def __init__(self, error_message='Enter only letters, numbers, and underscore'): + IS_MATCH.__init__(self, '^[\w]*$', error_message) + + +class IS_EMAIL(Validator): + """ + Checks if field's value is a valid email address. Can be set to disallow + or force addresses from certain domain(s). + + Email regex adapted from + http://haacked.com/archive/2007/08/21/i-knew-how-to-validate-an-email-address-until-i.aspx, + generally following the RFCs, except that we disallow quoted strings + and permit underscores and leading numerics in subdomain labels + + Args: + banned: regex text for disallowed address domains + forced: regex text for required address domains + + Both arguments can also be custom objects with a match(value) method. + + Example: + Check for valid email address:: + + INPUT(_type='text', _name='name', + requires=IS_EMAIL()) + + Check for valid email address that can't be from a .com domain:: + + INPUT(_type='text', _name='name', + requires=IS_EMAIL(banned='^.*\.com(|\..*)$')) + + Check for valid email address that must be from a .edu domain:: + + INPUT(_type='text', _name='name', + requires=IS_EMAIL(forced='^.*\.edu(|\..*)$')) + + >>> IS_EMAIL()('a@b.com') + ('a@b.com', None) + >>> IS_EMAIL()('abc@def.com') + ('abc@def.com', None) + >>> IS_EMAIL()('abc@3def.com') + ('abc@3def.com', None) + >>> IS_EMAIL()('abc@def.us') + ('abc@def.us', None) + >>> IS_EMAIL()('abc@d_-f.us') + ('abc@d_-f.us', None) + >>> IS_EMAIL()('@def.com') # missing name + ('@def.com', 'enter a valid email address') + >>> IS_EMAIL()('"abc@def".com') # quoted name + ('"abc@def".com', 'enter a valid email address') + >>> IS_EMAIL()('abc+def.com') # no @ + ('abc+def.com', 'enter a valid email address') + >>> IS_EMAIL()('abc@def.x') # one-char TLD + ('abc@def.x', 'enter a valid email address') + >>> IS_EMAIL()('abc@def.12') # numeric TLD + ('abc@def.12', 'enter a valid email address') + >>> IS_EMAIL()('abc@def..com') # double-dot in domain + ('abc@def..com', 'enter a valid email address') + >>> IS_EMAIL()('abc@.def.com') # dot starts domain + ('abc@.def.com', 'enter a valid email address') + >>> IS_EMAIL()('abc@def.c_m') # underscore in TLD + ('abc@def.c_m', 'enter a valid email address') + >>> IS_EMAIL()('NotAnEmail') # missing @ + ('NotAnEmail', 'enter a valid email address') + >>> IS_EMAIL()('abc@NotAnEmail') # missing TLD + ('abc@NotAnEmail', 'enter a valid email address') + >>> IS_EMAIL()('customer/department@example.com') + ('customer/department@example.com', None) + >>> IS_EMAIL()('$A12345@example.com') + ('$A12345@example.com', None) + >>> IS_EMAIL()('!def!xyz%abc@example.com') + ('!def!xyz%abc@example.com', None) + >>> IS_EMAIL()('_Yosemite.Sam@example.com') + ('_Yosemite.Sam@example.com', None) + >>> IS_EMAIL()('~@example.com') + ('~@example.com', None) + >>> IS_EMAIL()('.wooly@example.com') # dot starts name + ('.wooly@example.com', 'enter a valid email address') + >>> IS_EMAIL()('wo..oly@example.com') # adjacent dots in name + ('wo..oly@example.com', 'enter a valid email address') + >>> IS_EMAIL()('pootietang.@example.com') # dot ends name + ('pootietang.@example.com', 'enter a valid email address') + >>> IS_EMAIL()('.@example.com') # name is bare dot + ('.@example.com', 'enter a valid email address') + >>> IS_EMAIL()('Ima.Fool@example.com') + ('Ima.Fool@example.com', None) + >>> IS_EMAIL()('Ima Fool@example.com') # space in name + ('Ima Fool@example.com', 'enter a valid email address') + >>> IS_EMAIL()('localguy@localhost') # localhost as domain + ('localguy@localhost', None) + + """ + + body_regex = re.compile(''' + ^(?!\.) # name may not begin with a dot + ( + [-a-z0-9!\#$%&'*+/=?^_`{|}~] # all legal characters except dot + | + (? obtained on 2008-Nov-10 + +official_url_schemes = [ + 'aaa', + 'aaas', + 'acap', + 'cap', + 'cid', + 'crid', + 'data', + 'dav', + 'dict', + 'dns', + 'fax', + 'file', + 'ftp', + 'go', + 'gopher', + 'h323', + 'http', + 'https', + 'icap', + 'im', + 'imap', + 'info', + 'ipp', + 'iris', + 'iris.beep', + 'iris.xpc', + 'iris.xpcs', + 'iris.lws', + 'ldap', + 'mailto', + 'mid', + 'modem', + 'msrp', + 'msrps', + 'mtqp', + 'mupdate', + 'news', + 'nfs', + 'nntp', + 'opaquelocktoken', + 'pop', + 'pres', + 'prospero', + 'rtsp', + 'service', + 'shttp', + 'sip', + 'sips', + 'snmp', + 'soap.beep', + 'soap.beeps', + 'tag', + 'tel', + 'telnet', + 'tftp', + 'thismessage', + 'tip', + 'tv', + 'urn', + 'vemmi', + 'wais', + 'xmlrpc.beep', + 'xmlrpc.beep', + 'xmpp', + 'z39.50r', + 'z39.50s', +] +unofficial_url_schemes = [ + 'about', + 'adiumxtra', + 'aim', + 'afp', + 'aw', + 'callto', + 'chrome', + 'cvs', + 'ed2k', + 'feed', + 'fish', + 'gg', + 'gizmoproject', + 'iax2', + 'irc', + 'ircs', + 'itms', + 'jar', + 'javascript', + 'keyparc', + 'lastfm', + 'ldaps', + 'magnet', + 'mms', + 'msnim', + 'mvn', + 'notes', + 'nsfw', + 'psyc', + 'paparazzi:http', + 'rmi', + 'rsync', + 'secondlife', + 'sgn', + 'skype', + 'ssh', + 'sftp', + 'smb', + 'sms', + 'soldat', + 'steam', + 'svn', + 'teamspeak', + 'unreal', + 'ut2004', + 'ventrilo', + 'view-source', + 'webcal', + 'wyciwyg', + 'xfire', + 'xri', + 'ymsgr', +] +all_url_schemes = [None] + official_url_schemes + unofficial_url_schemes +http_schemes = [None, 'http', 'https'] + +# Defined in RFC 3490, Section 3.1, Requirement #1 +# Use this regex to split the authority component of a unicode URL into +# its component labels +label_split_regex = re.compile(u'[\u002e\u3002\uff0e\uff61]') + + +def escape_unicode(string): + """ + Converts a unicode string into US-ASCII, using a simple conversion scheme. + Each unicode character that does not have a US-ASCII equivalent is + converted into a URL escaped form based on its hexadecimal value. + For example, the unicode character '\u4e86' will become the string '%4e%86' + + Args: + string: unicode string, the unicode string to convert into an + escaped US-ASCII form + + Returns: + string: the US-ASCII escaped form of the inputted string + + @author: Jonathan Benn + """ + returnValue = StringIO() + + for character in string: + code = ord(character) + if code > 0x7F: + hexCode = hex(code) + returnValue.write('%' + hexCode[2:4] + '%' + hexCode[4:6]) + else: + returnValue.write(character) + + return returnValue.getvalue() + + +def unicode_to_ascii_authority(authority): + """ + Follows the steps in RFC 3490, Section 4 to convert a unicode authority + string into its ASCII equivalent. + For example, u'www.Alliancefran\xe7aise.nu' will be converted into + 'www.xn--alliancefranaise-npb.nu' + + Args: + authority: unicode string, the URL authority component to convert, + e.g. u'www.Alliancefran\xe7aise.nu' + + Returns: + string: the US-ASCII character equivalent to the inputed authority, + e.g. 'www.xn--alliancefranaise-npb.nu' + + Raises: + Exception: if the function is not able to convert the inputed + authority + + @author: Jonathan Benn + """ + # RFC 3490, Section 4, Step 1 + # The encodings.idna Python module assumes that AllowUnassigned == True + + # RFC 3490, Section 4, Step 2 + labels = label_split_regex.split(authority) + + # RFC 3490, Section 4, Step 3 + # The encodings.idna Python module assumes that UseSTD3ASCIIRules == False + + # RFC 3490, Section 4, Step 4 + # We use the ToASCII operation because we are about to put the authority + # into an IDN-unaware slot + asciiLabels = [] + import encodings.idna + for label in labels: + if label: + asciiLabels.append(to_native(encodings.idna.ToASCII(label))) + else: + # encodings.idna.ToASCII does not accept an empty string, but + # it is necessary for us to allow for empty labels so that we + # don't modify the URL + asciiLabels.append('') + # RFC 3490, Section 4, Step 5 + return str(reduce(lambda x, y: x + unichr(0x002E) + y, asciiLabels)) + + +def unicode_to_ascii_url(url, prepend_scheme): + """ + Converts the inputed unicode url into a US-ASCII equivalent. This function + goes a little beyond RFC 3490, which is limited in scope to the domain name + (authority) only. Here, the functionality is expanded to what was observed + on Wikipedia on 2009-Jan-22: + + Component Can Use Unicode? + --------- ---------------- + scheme No + authority Yes + path Yes + query Yes + fragment No + + The authority component gets converted to punycode, but occurrences of + unicode in other components get converted into a pair of URI escapes (we + assume 4-byte unicode). E.g. the unicode character U+4E2D will be + converted into '%4E%2D'. Testing with Firefox v3.0.5 has shown that it can + understand this kind of URI encoding. + + Args: + url: unicode string, the URL to convert from unicode into US-ASCII + prepend_scheme: string, a protocol scheme to prepend to the URL if + we're having trouble parsing it. + e.g. "http". Input None to disable this functionality + + Returns: + string: a US-ASCII equivalent of the inputed url + + @author: Jonathan Benn + """ + # convert the authority component of the URL into an ASCII punycode string, + # but encode the rest using the regular URI character encoding + components = urlparse.urlparse(url) + prepended = False + # If no authority was found + if not components.netloc: + # Try appending a scheme to see if that fixes the problem + scheme_to_prepend = prepend_scheme or 'http' + components = urlparse.urlparse(to_unicode(scheme_to_prepend) + u'://' + url) + prepended = True + + # if we still can't find the authority + if not components.netloc: + raise Exception('No authority component found, ' + + 'could not decode unicode to US-ASCII') + + # We're here if we found an authority, let's rebuild the URL + scheme = components.scheme + authority = components.netloc + path = components.path + query = components.query + fragment = components.fragment + + if prepended: + scheme = '' + + unparsed = urlparse.urlunparse((scheme, + unicode_to_ascii_authority(authority), + escape_unicode(path), + '', + escape_unicode(query), + str(fragment))) + if unparsed.startswith('//'): + unparsed = unparsed[2:] # Remove the // urlunparse puts in the beginning + return unparsed + + +class IS_GENERIC_URL(Validator): + """ + Rejects a URL string if any of the following is true: + * The string is empty or None + * The string uses characters that are not allowed in a URL + * The URL scheme specified (if one is specified) is not valid + + Based on RFC 2396: http://www.faqs.org/rfcs/rfc2396.html + + This function only checks the URL's syntax. It does not check that the URL + points to a real document, for example, or that it otherwise makes sense + semantically. This function does automatically prepend 'http://' in front + of a URL if and only if that's necessary to successfully parse the URL. + Please note that a scheme will be prepended only for rare cases + (e.g. 'google.ca:80') + + The list of allowed schemes is customizable with the allowed_schemes + parameter. If you exclude None from the list, then abbreviated URLs + (lacking a scheme such as 'http') will be rejected. + + The default prepended scheme is customizable with the prepend_scheme + parameter. If you set prepend_scheme to None then prepending will be + disabled. URLs that require prepending to parse will still be accepted, + but the return value will not be modified. + + @author: Jonathan Benn + + >>> IS_GENERIC_URL()('http://user@abc.com') + ('http://user@abc.com', None) + + Args: + error_message: a string, the error message to give the end user + if the URL does not validate + allowed_schemes: a list containing strings or None. Each element + is a scheme the inputed URL is allowed to use + prepend_scheme: a string, this scheme is prepended if it's + necessary to make the URL valid + + """ + + def __init__( + self, + error_message='Enter a valid URL', + allowed_schemes=None, + prepend_scheme=None, + ): + + self.error_message = error_message + if allowed_schemes is None: + self.allowed_schemes = all_url_schemes + else: + self.allowed_schemes = allowed_schemes + self.prepend_scheme = prepend_scheme + if self.prepend_scheme not in self.allowed_schemes: + raise SyntaxError("prepend_scheme='%s' is not in allowed_schemes=%s" + % (self.prepend_scheme, self.allowed_schemes)) + + GENERIC_URL = re.compile(r"%[^0-9A-Fa-f]{2}|%[^0-9A-Fa-f][0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]|%$|%[0-9A-Fa-f]$|%[^0-9A-Fa-f]$") + GENERIC_URL_VALID = re.compile(r"[A-Za-z0-9;/?:@&=+$,\-_\.!~*'\(\)%]+$") + URL_FRAGMENT_VALID = re.compile(r"[|A-Za-z0-9;/?:@&=+$,\-_\.!~*'\(\)%]+$") + + def __call__(self, value): + """ + Args: + value: a string, the URL to validate + + Returns: + a tuple, where tuple[0] is the inputed value (possible + prepended with prepend_scheme), and tuple[1] is either + None (success!) or the string error_message + """ + + # if we dont have anything or the URL misuses the '%' character + + if not value or self.GENERIC_URL.search(value): + return (value, translate(self.error_message)) + + if '#' in value: + url, fragment_part = value.split('#') + else: + url, fragment_part = value, '' + # if the URL is only composed of valid characters + if self.GENERIC_URL_VALID.match(url) and (not fragment_part or self.URL_FRAGMENT_VALID.match(fragment_part)): + # Then parse the URL into its components and check on + try: + components = urlparse.urlparse(urllib_unquote(value))._asdict() + except ValueError: + return (value, translate(self.error_message)) + + # Clean up the scheme before we check it + scheme = components['scheme'] + if len(scheme) == 0: + scheme = None + else: + scheme = components['scheme'].lower() + # If the scheme doesn't really exists + if scheme not in self.allowed_schemes or not scheme and ':' in components['path']: + # for the possible case of abbreviated URLs with + # ports, check to see if adding a valid scheme fixes + # the problem (but only do this if it doesn't have + # one already!) + if '://' not in value and None in self.allowed_schemes: + schemeToUse = self.prepend_scheme or 'http' + prependTest = self.__call__( + schemeToUse + '://' + value) + # if the prepend test succeeded + if prependTest[1] is None: + # if prepending in the output is enabled + if self.prepend_scheme: + return prependTest + else: + return (value, None) + else: + return (value, None) + # else the URL is not valid + return (value, translate(self.error_message)) + +# Sources (obtained 2017-Nov-11): +# http://data.iana.org/TLD/tlds-alpha-by-domain.txt +# see scripts/parse_top_level_domains.py for an easy update + +official_top_level_domains = [ + # a + 'aaa', 'aarp', 'abarth', 'abb', 'abbott', 'abbvie', 'abc', + 'able', 'abogado', 'abudhabi', 'ac', 'academy', 'accenture', + 'accountant', 'accountants', 'aco', 'active', 'actor', 'ad', + 'adac', 'ads', 'adult', 'ae', 'aeg', 'aero', 'aetna', 'af', + 'afamilycompany', 'afl', 'africa', 'ag', 'agakhan', 'agency', + 'ai', 'aig', 'aigo', 'airbus', 'airforce', 'airtel', 'akdn', + 'al', 'alfaromeo', 'alibaba', 'alipay', 'allfinanz', 'allstate', + 'ally', 'alsace', 'alstom', 'am', 'americanexpress', + 'americanfamily', 'amex', 'amfam', 'amica', 'amsterdam', + 'analytics', 'android', 'anquan', 'anz', 'ao', 'aol', + 'apartments', 'app', 'apple', 'aq', 'aquarelle', 'ar', 'arab', + 'aramco', 'archi', 'army', 'arpa', 'art', 'arte', 'as', 'asda', + 'asia', 'associates', 'at', 'athleta', 'attorney', 'au', + 'auction', 'audi', 'audible', 'audio', 'auspost', 'author', + 'auto', 'autos', 'avianca', 'aw', 'aws', 'ax', 'axa', 'az', + 'azure', + # b + 'ba', 'baby', 'baidu', 'banamex', 'bananarepublic', 'band', + 'bank', 'bar', 'barcelona', 'barclaycard', 'barclays', + 'barefoot', 'bargains', 'baseball', 'basketball', 'bauhaus', + 'bayern', 'bb', 'bbc', 'bbt', 'bbva', 'bcg', 'bcn', 'bd', 'be', + 'beats', 'beauty', 'beer', 'bentley', 'berlin', 'best', + 'bestbuy', 'bet', 'bf', 'bg', 'bh', 'bharti', 'bi', 'bible', + 'bid', 'bike', 'bing', 'bingo', 'bio', 'biz', 'bj', 'black', + 'blackfriday', 'blanco', 'blockbuster', 'blog', 'bloomberg', + 'blue', 'bm', 'bms', 'bmw', 'bn', 'bnl', 'bnpparibas', 'bo', + 'boats', 'boehringer', 'bofa', 'bom', 'bond', 'boo', 'book', + 'booking', 'boots', 'bosch', 'bostik', 'boston', 'bot', + 'boutique', 'box', 'br', 'bradesco', 'bridgestone', 'broadway', + 'broker', 'brother', 'brussels', 'bs', 'bt', 'budapest', + 'bugatti', 'build', 'builders', 'business', 'buy', 'buzz', 'bv', + 'bw', 'by', 'bz', 'bzh', + # c + 'ca', 'cab', 'cafe', 'cal', 'call', 'calvinklein', 'cam', + 'camera', 'camp', 'cancerresearch', 'canon', 'capetown', + 'capital', 'capitalone', 'car', 'caravan', 'cards', 'care', + 'career', 'careers', 'cars', 'cartier', 'casa', 'case', 'caseih', + 'cash', 'casino', 'cat', 'catering', 'catholic', 'cba', 'cbn', + 'cbre', 'cbs', 'cc', 'cd', 'ceb', 'center', 'ceo', 'cern', 'cf', + 'cfa', 'cfd', 'cg', 'ch', 'chanel', 'channel', 'chase', 'chat', + 'cheap', 'chintai', 'christmas', 'chrome', 'chrysler', 'church', + 'ci', 'cipriani', 'circle', 'cisco', 'citadel', 'citi', 'citic', + 'city', 'cityeats', 'ck', 'cl', 'claims', 'cleaning', 'click', + 'clinic', 'clinique', 'clothing', 'cloud', 'club', 'clubmed', + 'cm', 'cn', 'co', 'coach', 'codes', 'coffee', 'college', + 'cologne', 'com', 'comcast', 'commbank', 'community', 'company', + 'compare', 'computer', 'comsec', 'condos', 'construction', + 'consulting', 'contact', 'contractors', 'cooking', + 'cookingchannel', 'cool', 'coop', 'corsica', 'country', 'coupon', + 'coupons', 'courses', 'cr', 'credit', 'creditcard', + 'creditunion', 'cricket', 'crown', 'crs', 'cruise', 'cruises', + 'csc', 'cu', 'cuisinella', 'cv', 'cw', 'cx', 'cy', 'cymru', + 'cyou', 'cz', + # d + 'dabur', 'dad', 'dance', 'data', 'date', 'dating', 'datsun', + 'day', 'dclk', 'dds', 'de', 'deal', 'dealer', 'deals', 'degree', + 'delivery', 'dell', 'deloitte', 'delta', 'democrat', 'dental', + 'dentist', 'desi', 'design', 'dev', 'dhl', 'diamonds', 'diet', + 'digital', 'direct', 'directory', 'discount', 'discover', 'dish', + 'diy', 'dj', 'dk', 'dm', 'dnp', 'do', 'docs', 'doctor', 'dodge', + 'dog', 'doha', 'domains', 'dot', 'download', 'drive', 'dtv', + 'dubai', 'duck', 'dunlop', 'duns', 'dupont', 'durban', 'dvag', + 'dvr', 'dz', + # e + 'earth', 'eat', 'ec', 'eco', 'edeka', 'edu', 'education', 'ee', + 'eg', 'email', 'emerck', 'energy', 'engineer', 'engineering', + 'enterprises', 'epost', 'epson', 'equipment', 'er', 'ericsson', + 'erni', 'es', 'esq', 'estate', 'esurance', 'et', 'etisalat', + 'eu', 'eurovision', 'eus', 'events', 'everbank', 'exchange', + 'expert', 'exposed', 'express', 'extraspace', + # f + 'fage', 'fail', 'fairwinds', 'faith', 'family', 'fan', 'fans', + 'farm', 'farmers', 'fashion', 'fast', 'fedex', 'feedback', + 'ferrari', 'ferrero', 'fi', 'fiat', 'fidelity', 'fido', 'film', + 'final', 'finance', 'financial', 'fire', 'firestone', 'firmdale', + 'fish', 'fishing', 'fit', 'fitness', 'fj', 'fk', 'flickr', + 'flights', 'flir', 'florist', 'flowers', 'fly', 'fm', 'fo', + 'foo', 'food', 'foodnetwork', 'football', 'ford', 'forex', + 'forsale', 'forum', 'foundation', 'fox', 'fr', 'free', + 'fresenius', 'frl', 'frogans', 'frontdoor', 'frontier', 'ftr', + 'fujitsu', 'fujixerox', 'fun', 'fund', 'furniture', 'futbol', + 'fyi', + # g + 'ga', 'gal', 'gallery', 'gallo', 'gallup', 'game', 'games', + 'gap', 'garden', 'gb', 'gbiz', 'gd', 'gdn', 'ge', 'gea', 'gent', + 'genting', 'george', 'gf', 'gg', 'ggee', 'gh', 'gi', 'gift', + 'gifts', 'gives', 'giving', 'gl', 'glade', 'glass', 'gle', + 'global', 'globo', 'gm', 'gmail', 'gmbh', 'gmo', 'gmx', 'gn', + 'godaddy', 'gold', 'goldpoint', 'golf', 'goo', 'goodhands', + 'goodyear', 'goog', 'google', 'gop', 'got', 'gov', 'gp', 'gq', + 'gr', 'grainger', 'graphics', 'gratis', 'green', 'gripe', + 'grocery', 'group', 'gs', 'gt', 'gu', 'guardian', 'gucci', + 'guge', 'guide', 'guitars', 'guru', 'gw', 'gy', + # h + 'hair', 'hamburg', 'hangout', 'haus', 'hbo', 'hdfc', 'hdfcbank', + 'health', 'healthcare', 'help', 'helsinki', 'here', 'hermes', + 'hgtv', 'hiphop', 'hisamitsu', 'hitachi', 'hiv', 'hk', 'hkt', + 'hm', 'hn', 'hockey', 'holdings', 'holiday', 'homedepot', + 'homegoods', 'homes', 'homesense', 'honda', 'honeywell', 'horse', + 'hospital', 'host', 'hosting', 'hot', 'hoteles', 'hotels', + 'hotmail', 'house', 'how', 'hr', 'hsbc', 'ht', 'hu', 'hughes', + 'hyatt', 'hyundai', + # i + 'ibm', 'icbc', 'ice', 'icu', 'id', 'ie', 'ieee', 'ifm', 'ikano', + 'il', 'im', 'imamat', 'imdb', 'immo', 'immobilien', 'in', + 'industries', 'infiniti', 'info', 'ing', 'ink', 'institute', + 'insurance', 'insure', 'int', 'intel', 'international', 'intuit', + 'investments', 'io', 'ipiranga', 'iq', 'ir', 'irish', 'is', + 'iselect', 'ismaili', 'ist', 'istanbul', 'it', 'itau', 'itv', + 'iveco', 'iwc', + # j + 'jaguar', 'java', 'jcb', 'jcp', 'je', 'jeep', 'jetzt', 'jewelry', + 'jio', 'jlc', 'jll', 'jm', 'jmp', 'jnj', 'jo', 'jobs', 'joburg', + 'jot', 'joy', 'jp', 'jpmorgan', 'jprs', 'juegos', 'juniper', + # k + 'kaufen', 'kddi', 'ke', 'kerryhotels', 'kerrylogistics', + 'kerryproperties', 'kfh', 'kg', 'kh', 'ki', 'kia', 'kim', + 'kinder', 'kindle', 'kitchen', 'kiwi', 'km', 'kn', 'koeln', + 'komatsu', 'kosher', 'kp', 'kpmg', 'kpn', 'kr', 'krd', 'kred', + 'kuokgroup', 'kw', 'ky', 'kyoto', 'kz', + # l + 'la', 'lacaixa', 'ladbrokes', 'lamborghini', 'lamer', + 'lancaster', 'lancia', 'lancome', 'land', 'landrover', 'lanxess', + 'lasalle', 'lat', 'latino', 'latrobe', 'law', 'lawyer', 'lb', + 'lc', 'lds', 'lease', 'leclerc', 'lefrak', 'legal', 'lego', + 'lexus', 'lgbt', 'li', 'liaison', 'lidl', 'life', + 'lifeinsurance', 'lifestyle', 'lighting', 'like', 'lilly', + 'limited', 'limo', 'lincoln', 'linde', 'link', 'lipsy', 'live', + 'living', 'lixil', 'lk', 'loan', 'loans', 'localhost', 'locker', + 'locus', 'loft', 'lol', 'london', 'lotte', 'lotto', 'love', + 'lpl', 'lplfinancial', 'lr', 'ls', 'lt', 'ltd', 'ltda', 'lu', + 'lundbeck', 'lupin', 'luxe', 'luxury', 'lv', 'ly', + # m + 'ma', 'macys', 'madrid', 'maif', 'maison', 'makeup', 'man', + 'management', 'mango', 'map', 'market', 'marketing', 'markets', + 'marriott', 'marshalls', 'maserati', 'mattel', 'mba', 'mc', + 'mckinsey', 'md', 'me', 'med', 'media', 'meet', 'melbourne', + 'meme', 'memorial', 'men', 'menu', 'meo', 'merckmsd', 'metlife', + 'mg', 'mh', 'miami', 'microsoft', 'mil', 'mini', 'mint', 'mit', + 'mitsubishi', 'mk', 'ml', 'mlb', 'mls', 'mm', 'mma', 'mn', 'mo', + 'mobi', 'mobile', 'mobily', 'moda', 'moe', 'moi', 'mom', + 'monash', 'money', 'monster', 'mopar', 'mormon', 'mortgage', + 'moscow', 'moto', 'motorcycles', 'mov', 'movie', 'movistar', + 'mp', 'mq', 'mr', 'ms', 'msd', 'mt', 'mtn', 'mtr', 'mu', + 'museum', 'mutual', 'mv', 'mw', 'mx', 'my', 'mz', + # n + 'na', 'nab', 'nadex', 'nagoya', 'name', 'nationwide', 'natura', + 'navy', 'nba', 'nc', 'ne', 'nec', 'net', 'netbank', 'netflix', + 'network', 'neustar', 'new', 'newholland', 'news', 'next', + 'nextdirect', 'nexus', 'nf', 'nfl', 'ng', 'ngo', 'nhk', 'ni', + 'nico', 'nike', 'nikon', 'ninja', 'nissan', 'nissay', 'nl', 'no', + 'nokia', 'northwesternmutual', 'norton', 'now', 'nowruz', + 'nowtv', 'np', 'nr', 'nra', 'nrw', 'ntt', 'nu', 'nyc', 'nz', + # o + 'obi', 'observer', 'off', 'office', 'okinawa', 'olayan', + 'olayangroup', 'oldnavy', 'ollo', 'om', 'omega', 'one', 'ong', + 'onl', 'online', 'onyourside', 'ooo', 'open', 'oracle', 'orange', + 'org', 'organic', 'origins', 'osaka', 'otsuka', 'ott', 'ovh', + # p + 'pa', 'page', 'panasonic', 'panerai', 'paris', 'pars', + 'partners', 'parts', 'party', 'passagens', 'pay', 'pccw', 'pe', + 'pet', 'pf', 'pfizer', 'pg', 'ph', 'pharmacy', 'phd', 'philips', + 'phone', 'photo', 'photography', 'photos', 'physio', 'piaget', + 'pics', 'pictet', 'pictures', 'pid', 'pin', 'ping', 'pink', + 'pioneer', 'pizza', 'pk', 'pl', 'place', 'play', 'playstation', + 'plumbing', 'plus', 'pm', 'pn', 'pnc', 'pohl', 'poker', + 'politie', 'porn', 'post', 'pr', 'pramerica', 'praxi', 'press', + 'prime', 'pro', 'prod', 'productions', 'prof', 'progressive', + 'promo', 'properties', 'property', 'protection', 'pru', + 'prudential', 'ps', 'pt', 'pub', 'pw', 'pwc', 'py', + # q + 'qa', 'qpon', 'quebec', 'quest', 'qvc', + # r + 'racing', 'radio', 'raid', 're', 'read', 'realestate', 'realtor', + 'realty', 'recipes', 'red', 'redstone', 'redumbrella', 'rehab', + 'reise', 'reisen', 'reit', 'reliance', 'ren', 'rent', 'rentals', + 'repair', 'report', 'republican', 'rest', 'restaurant', 'review', + 'reviews', 'rexroth', 'rich', 'richardli', 'ricoh', + 'rightathome', 'ril', 'rio', 'rip', 'rmit', 'ro', 'rocher', + 'rocks', 'rodeo', 'rogers', 'room', 'rs', 'rsvp', 'ru', 'rugby', + 'ruhr', 'run', 'rw', 'rwe', 'ryukyu', + # s + 'sa', 'saarland', 'safe', 'safety', 'sakura', 'sale', 'salon', + 'samsclub', 'samsung', 'sandvik', 'sandvikcoromant', 'sanofi', + 'sap', 'sapo', 'sarl', 'sas', 'save', 'saxo', 'sb', 'sbi', 'sbs', + 'sc', 'sca', 'scb', 'schaeffler', 'schmidt', 'scholarships', + 'school', 'schule', 'schwarz', 'science', 'scjohnson', 'scor', + 'scot', 'sd', 'se', 'search', 'seat', 'secure', 'security', + 'seek', 'select', 'sener', 'services', 'ses', 'seven', 'sew', + 'sex', 'sexy', 'sfr', 'sg', 'sh', 'shangrila', 'sharp', 'shaw', + 'shell', 'shia', 'shiksha', 'shoes', 'shop', 'shopping', + 'shouji', 'show', 'showtime', 'shriram', 'si', 'silk', 'sina', + 'singles', 'site', 'sj', 'sk', 'ski', 'skin', 'sky', 'skype', + 'sl', 'sling', 'sm', 'smart', 'smile', 'sn', 'sncf', 'so', + 'soccer', 'social', 'softbank', 'software', 'sohu', 'solar', + 'solutions', 'song', 'sony', 'soy', 'space', 'spiegel', 'spot', + 'spreadbetting', 'sr', 'srl', 'srt', 'st', 'stada', 'staples', + 'star', 'starhub', 'statebank', 'statefarm', 'statoil', 'stc', + 'stcgroup', 'stockholm', 'storage', 'store', 'stream', 'studio', + 'study', 'style', 'su', 'sucks', 'supplies', 'supply', 'support', + 'surf', 'surgery', 'suzuki', 'sv', 'swatch', 'swiftcover', + 'swiss', 'sx', 'sy', 'sydney', 'symantec', 'systems', 'sz', + # t + 'tab', 'taipei', 'talk', 'taobao', 'target', 'tatamotors', + 'tatar', 'tattoo', 'tax', 'taxi', 'tc', 'tci', 'td', 'tdk', + 'team', 'tech', 'technology', 'tel', 'telecity', 'telefonica', + 'temasek', 'tennis', 'teva', 'tf', 'tg', 'th', 'thd', 'theater', + 'theatre', 'tiaa', 'tickets', 'tienda', 'tiffany', 'tips', + 'tires', 'tirol', 'tj', 'tjmaxx', 'tjx', 'tk', 'tkmaxx', 'tl', + 'tm', 'tmall', 'tn', 'to', 'today', 'tokyo', 'tools', 'top', + 'toray', 'toshiba', 'total', 'tours', 'town', 'toyota', 'toys', + 'tr', 'trade', 'trading', 'training', 'travel', 'travelchannel', + 'travelers', 'travelersinsurance', 'trust', 'trv', 'tt', 'tube', + 'tui', 'tunes', 'tushu', 'tv', 'tvs', 'tw', 'tz', + # u + 'ua', 'ubank', 'ubs', 'uconnect', 'ug', 'uk', 'unicom', + 'university', 'uno', 'uol', 'ups', 'us', 'uy', 'uz', + # v + 'va', 'vacations', 'vana', 'vanguard', 'vc', 've', 'vegas', + 'ventures', 'verisign', 'versicherung', 'vet', 'vg', 'vi', + 'viajes', 'video', 'vig', 'viking', 'villas', 'vin', 'vip', + 'virgin', 'visa', 'vision', 'vista', 'vistaprint', 'viva', + 'vivo', 'vlaanderen', 'vn', 'vodka', 'volkswagen', 'volvo', + 'vote', 'voting', 'voto', 'voyage', 'vu', 'vuelos', + # w + 'wales', 'walmart', 'walter', 'wang', 'wanggou', 'warman', + 'watch', 'watches', 'weather', 'weatherchannel', 'webcam', + 'weber', 'website', 'wed', 'wedding', 'weibo', 'weir', 'wf', + 'whoswho', 'wien', 'wiki', 'williamhill', 'win', 'windows', + 'wine', 'winners', 'wme', 'wolterskluwer', 'woodside', 'work', + 'works', 'world', 'wow', 'ws', 'wtc', 'wtf', + # x + 'xbox', 'xerox', 'xfinity', 'xihuan', 'xin', 'xn--11b4c3d', + 'xn--1ck2e1b', 'xn--1qqw23a', 'xn--2scrj9c', 'xn--30rr7y', + 'xn--3bst00m', 'xn--3ds443g', 'xn--3e0b707e', 'xn--3hcrj9c', + 'xn--3oq18vl8pn36a', 'xn--3pxu8k', 'xn--42c2d9a', 'xn--45br5cyl', + 'xn--45brj9c', 'xn--45q11c', 'xn--4gbrim', 'xn--54b7fta0cc', + 'xn--55qw42g', 'xn--55qx5d', 'xn--5su34j936bgsg', 'xn--5tzm5g', + 'xn--6frz82g', 'xn--6qq986b3xl', 'xn--80adxhks', 'xn--80ao21a', + 'xn--80aqecdr1a', 'xn--80asehdb', 'xn--80aswg', 'xn--8y0a063a', + 'xn--90a3ac', 'xn--90ae', 'xn--90ais', 'xn--9dbq2a', + 'xn--9et52u', 'xn--9krt00a', 'xn--b4w605ferd', + 'xn--bck1b9a5dre4c', 'xn--c1avg', 'xn--c2br7g', 'xn--cck2b3b', + 'xn--cg4bki', 'xn--clchc0ea0b2g2a9gcd', 'xn--czr694b', + 'xn--czrs0t', 'xn--czru2d', 'xn--d1acj3b', 'xn--d1alf', + 'xn--e1a4c', 'xn--eckvdtc9d', 'xn--efvy88h', 'xn--estv75g', + 'xn--fct429k', 'xn--fhbei', 'xn--fiq228c5hs', 'xn--fiq64b', + 'xn--fiqs8s', 'xn--fiqz9s', 'xn--fjq720a', 'xn--flw351e', + 'xn--fpcrj9c3d', 'xn--fzc2c9e2c', 'xn--fzys8d69uvgm', + 'xn--g2xx48c', 'xn--gckr3f0f', 'xn--gecrj9c', 'xn--gk3at1e', + 'xn--h2breg3eve', 'xn--h2brj9c', 'xn--h2brj9c8c', 'xn--hxt814e', + 'xn--i1b6b1a6a2e', 'xn--imr513n', 'xn--io0a7i', 'xn--j1aef', + 'xn--j1amh', 'xn--j6w193g', 'xn--jlq61u9w7b', 'xn--jvr189m', + 'xn--kcrx77d1x4a', 'xn--kprw13d', 'xn--kpry57d', 'xn--kpu716f', + 'xn--kput3i', 'xn--l1acc', 'xn--lgbbat1ad8j', 'xn--mgb9awbf', + 'xn--mgba3a3ejt', 'xn--mgba3a4f16a', 'xn--mgba7c0bbn0a', + 'xn--mgbaakc7dvf', 'xn--mgbaam7a8h', 'xn--mgbab2bd', + 'xn--mgbai9azgqp6j', 'xn--mgbayh7gpa', 'xn--mgbb9fbpob', + 'xn--mgbbh1a', 'xn--mgbbh1a71e', 'xn--mgbc0a9azcg', + 'xn--mgbca7dzdo', 'xn--mgberp4a5d4ar', 'xn--mgbgu82a', + 'xn--mgbi4ecexp', 'xn--mgbpl2fh', 'xn--mgbt3dhd', 'xn--mgbtx2b', + 'xn--mgbx4cd0ab', 'xn--mix891f', 'xn--mk1bu44c', 'xn--mxtq1m', + 'xn--ngbc5azd', 'xn--ngbe9e0a', 'xn--ngbrx', 'xn--node', + 'xn--nqv7f', 'xn--nqv7fs00ema', 'xn--nyqy26a', 'xn--o3cw4h', + 'xn--ogbpf8fl', 'xn--p1acf', 'xn--p1ai', 'xn--pbt977c', + 'xn--pgbs0dh', 'xn--pssy2u', 'xn--q9jyb4c', 'xn--qcka1pmc', + 'xn--qxam', 'xn--rhqv96g', 'xn--rovu88b', 'xn--rvc1e0am3e', + 'xn--s9brj9c', 'xn--ses554g', 'xn--t60b56a', 'xn--tckwe', + 'xn--tiq49xqyj', 'xn--unup4y', 'xn--vermgensberater-ctb', + 'xn--vermgensberatung-pwb', 'xn--vhquv', 'xn--vuq861b', + 'xn--w4r85el8fhu5dnra', 'xn--w4rs40l', 'xn--wgbh1c', + 'xn--wgbl6a', 'xn--xhq521b', 'xn--xkc2al3hye2a', + 'xn--xkc2dl3a5ee0h', 'xn--y9a3aq', 'xn--yfro4i67o', + 'xn--ygbi2ammx', 'xn--zfr164b', 'xperia', 'xxx', 'xyz', + # y + 'yachts', 'yahoo', 'yamaxun', 'yandex', 'ye', 'yodobashi', + 'yoga', 'yokohama', 'you', 'youtube', 'yt', 'yun', + # z + 'za', 'zappos', 'zara', 'zero', 'zip', 'zippo', 'zm', 'zone', + 'zuerich', 'zw' +] + + +class IS_HTTP_URL(Validator): + """ + Rejects a URL string if any of the following is true: + * The string is empty or None + * The string uses characters that are not allowed in a URL + * The string breaks any of the HTTP syntactic rules + * The URL scheme specified (if one is specified) is not 'http' or 'https' + * The top-level domain (if a host name is specified) does not exist + + Based on RFC 2616: http://www.faqs.org/rfcs/rfc2616.html + + This function only checks the URL's syntax. It does not check that the URL + points to a real document, for example, or that it otherwise makes sense + semantically. This function does automatically prepend 'http://' in front + of a URL in the case of an abbreviated URL (e.g. 'google.ca'). + + The list of allowed schemes is customizable with the allowed_schemes + parameter. If you exclude None from the list, then abbreviated URLs + (lacking a scheme such as 'http') will be rejected. + + The default prepended scheme is customizable with the prepend_scheme + parameter. If you set prepend_scheme to None then prepending will be + disabled. URLs that require prepending to parse will still be accepted, + but the return value will not be modified. + + @author: Jonathan Benn + + >>> IS_HTTP_URL()('http://1.2.3.4') + ('http://1.2.3.4', None) + >>> IS_HTTP_URL()('http://abc.com') + ('http://abc.com', None) + >>> IS_HTTP_URL()('https://abc.com') + ('https://abc.com', None) + >>> IS_HTTP_URL()('httpx://abc.com') + ('httpx://abc.com', 'enter a valid URL') + >>> IS_HTTP_URL()('http://abc.com:80') + ('http://abc.com:80', None) + >>> IS_HTTP_URL()('http://user@abc.com') + ('http://user@abc.com', None) + >>> IS_HTTP_URL()('http://user@1.2.3.4') + ('http://user@1.2.3.4', None) + + Args: + error_message: a string, the error message to give the end user + if the URL does not validate + allowed_schemes: a list containing strings or None. Each element + is a scheme the inputed URL is allowed to use + prepend_scheme: a string, this scheme is prepended if it's + necessary to make the URL valid + """ + + GENERIC_VALID_IP = re.compile( + "([\w.!~*'|;:&=+$,-]+@)?\d+\.\d+\.\d+\.\d+(:\d*)*$") + GENERIC_VALID_DOMAIN = re.compile("([\w.!~*'|;:&=+$,-]+@)?(([A-Za-z0-9]+[A-Za-z0-9\-]*[A-Za-z0-9]+\.)*([A-Za-z0-9]+\.)*)*([A-Za-z]+[A-Za-z0-9\-]*[A-Za-z0-9]+)\.?(:\d*)*$") + + def __init__( + self, + error_message='Enter a valid URL', + allowed_schemes=None, + prepend_scheme='http', + allowed_tlds=None + ): + + self.error_message = error_message + if allowed_schemes is None: + self.allowed_schemes = http_schemes + else: + self.allowed_schemes = allowed_schemes + if allowed_tlds is None: + self.allowed_tlds = official_top_level_domains + else: + self.allowed_tlds = allowed_tlds + self.prepend_scheme = prepend_scheme + + for i in self.allowed_schemes: + if i not in http_schemes: + raise SyntaxError("allowed_scheme value '%s' is not in %s" % + (i, http_schemes)) + + if self.prepend_scheme not in self.allowed_schemes: + raise SyntaxError("prepend_scheme='%s' is not in allowed_schemes=%s" % + (self.prepend_scheme, self.allowed_schemes)) + + def __call__(self, value): + """ + Args: + value: a string, the URL to validate + + Returns: + a tuple, where tuple[0] is the inputed value + (possible prepended with prepend_scheme), and tuple[1] is either + None (success!) or the string error_message + """ + try: + # if the URL passes generic validation + x = IS_GENERIC_URL(error_message=self.error_message, + allowed_schemes=self.allowed_schemes, + prepend_scheme=self.prepend_scheme) + if x(value)[1] is None: + components = urlparse.urlparse(value) + authority = components.netloc + # if there is an authority component + if authority: + # if authority is a valid IP address + if self.GENERIC_VALID_IP.match(authority): + # Then this HTTP URL is valid + return (value, None) + else: + # else if authority is a valid domain name + domainMatch = self.GENERIC_VALID_DOMAIN.match( + authority) + if domainMatch: + # if the top-level domain really exists + if domainMatch.group(5).lower()\ + in self.allowed_tlds: + # Then this HTTP URL is valid + return (value, None) + else: + # else this is a relative/abbreviated URL, which will parse + # into the URL's path component + path = components.path + # relative case: if this is a valid path (if it starts with + # a slash) + if path.startswith('/'): + # Then this HTTP URL is valid + return (value, None) + else: + # abbreviated case: if we haven't already, prepend a + # scheme and see if it fixes the problem + if '://' not in value and None in self.allowed_schemes: + schemeToUse = self.prepend_scheme or 'http' + prependTest = self.__call__(schemeToUse + + '://' + value) + # if the prepend test succeeded + if prependTest[1] is None: + # if prepending in the output is enabled + if self.prepend_scheme: + return prependTest + else: + # else return the original, non-prepended + # value + return (value, None) + except: + pass + # else the HTTP URL is not valid + return (value, translate(self.error_message)) + + +class IS_URL(Validator): + """ + Rejects a URL string if any of the following is true: + + * The string is empty or None + * The string uses characters that are not allowed in a URL + * The string breaks any of the HTTP syntactic rules + * The URL scheme specified (if one is specified) is not 'http' or 'https' + * The top-level domain (if a host name is specified) does not exist + + (These rules are based on RFC 2616: http://www.faqs.org/rfcs/rfc2616.html) + + This function only checks the URL's syntax. It does not check that the URL + points to a real document, for example, or that it otherwise makes sense + semantically. This function does automatically prepend 'http://' in front + of a URL in the case of an abbreviated URL (e.g. 'google.ca'). + + If the parameter mode='generic' is used, then this function's behavior + changes. It then rejects a URL string if any of the following is true: + + * The string is empty or None + * The string uses characters that are not allowed in a URL + * The URL scheme specified (if one is specified) is not valid + + (These rules are based on RFC 2396: http://www.faqs.org/rfcs/rfc2396.html) + + The list of allowed schemes is customizable with the allowed_schemes + parameter. If you exclude None from the list, then abbreviated URLs + (lacking a scheme such as 'http') will be rejected. + + The default prepended scheme is customizable with the prepend_scheme + parameter. If you set prepend_scheme to None then prepending will be + disabled. URLs that require prepending to parse will still be accepted, + but the return value will not be modified. + + IS_URL is compatible with the Internationalized Domain Name (IDN) standard + specified in RFC 3490 (http://tools.ietf.org/html/rfc3490). As a result, + URLs can be regular strings or unicode strings. + If the URL's domain component (e.g. google.ca) contains non-US-ASCII + letters, then the domain will be converted into Punycode (defined in + RFC 3492, http://tools.ietf.org/html/rfc3492). IS_URL goes a bit beyond + the standards, and allows non-US-ASCII characters to be present in the path + and query components of the URL as well. These non-US-ASCII characters will + be escaped using the standard '%20' type syntax. e.g. the unicode + character with hex code 0x4e86 will become '%4e%86' + + Args: + error_message: a string, the error message to give the end user + if the URL does not validate + allowed_schemes: a list containing strings or None. Each element + is a scheme the inputed URL is allowed to use + prepend_scheme: a string, this scheme is prepended if it's + necessary to make the URL valid + + Code Examples:: + + INPUT(_type='text', _name='name', requires=IS_URL()) + >>> IS_URL()('abc.com') + ('http://abc.com', None) + + INPUT(_type='text', _name='name', requires=IS_URL(mode='generic')) + >>> IS_URL(mode='generic')('abc.com') + ('abc.com', None) + + INPUT(_type='text', _name='name', + requires=IS_URL(allowed_schemes=['https'], prepend_scheme='https')) + >>> IS_URL(allowed_schemes=['https'], prepend_scheme='https')('https://abc.com') + ('https://abc.com', None) + + INPUT(_type='text', _name='name', + requires=IS_URL(prepend_scheme='https')) + >>> IS_URL(prepend_scheme='https')('abc.com') + ('https://abc.com', None) + + INPUT(_type='text', _name='name', + requires=IS_URL(mode='generic', allowed_schemes=['ftps', 'https'], + prepend_scheme='https')) + >>> IS_URL(mode='generic', allowed_schemes=['ftps', 'https'], prepend_scheme='https')('https://abc.com') + ('https://abc.com', None) + >>> IS_URL(mode='generic', allowed_schemes=['ftps', 'https', None], prepend_scheme='https')('abc.com') + ('abc.com', None) + + @author: Jonathan Benn + """ + + def __init__( + self, + error_message='Enter a valid URL', + mode='http', + allowed_schemes=None, + prepend_scheme='http', + allowed_tlds=None + ): + + self.error_message = error_message + self.mode = mode.lower() + if self.mode not in ['generic', 'http']: + raise SyntaxError("invalid mode '%s' in IS_URL" % self.mode) + self.allowed_schemes = allowed_schemes + if allowed_tlds is None: + self.allowed_tlds = official_top_level_domains + else: + self.allowed_tlds = allowed_tlds + + if self.allowed_schemes: + if prepend_scheme not in self.allowed_schemes: + raise SyntaxError("prepend_scheme='%s' is not in allowed_schemes=%s" + % (prepend_scheme, self.allowed_schemes)) + + # if allowed_schemes is None, then we will defer testing + # prepend_scheme's validity to a sub-method + + self.prepend_scheme = prepend_scheme + + def __call__(self, value): + """ + Args: + value: a unicode or regular string, the URL to validate + + Returns: + a (string, string) tuple, where tuple[0] is the modified + input value and tuple[1] is either None (success!) or the + string error_message. The input value will never be modified in the + case of an error. However, if there is success then the input URL + may be modified to (1) prepend a scheme, and/or (2) convert a + non-compliant unicode URL into a compliant US-ASCII version. + """ + if self.mode == 'generic': + subMethod = IS_GENERIC_URL(error_message=self.error_message, + allowed_schemes=self.allowed_schemes, + prepend_scheme=self.prepend_scheme) + elif self.mode == 'http': + subMethod = IS_HTTP_URL(error_message=self.error_message, + allowed_schemes=self.allowed_schemes, + prepend_scheme=self.prepend_scheme, + allowed_tlds=self.allowed_tlds) + else: + raise SyntaxError("invalid mode '%s' in IS_URL" % self.mode) + + if not isinstance(value, unicodeT): + return subMethod(value) + else: + try: + asciiValue = unicode_to_ascii_url(value, self.prepend_scheme) + except Exception as e: + # If we are not able to convert the unicode url into a + # US-ASCII URL, then the URL is not valid + return (value, translate(self.error_message)) + methodResult = subMethod(asciiValue) + # if the validation of the US-ASCII version of the value failed + if not methodResult[1] is None: + # then return the original input value, not the US-ASCII version + return (value, methodResult[1]) + else: + return methodResult + + +regex_time = re.compile( + '((?P[0-9]+))([^0-9 ]+(?P[0-9 ]+))?([^0-9ap ]+(?P[0-9]*))?((?P[ap]m))?') + + +class IS_TIME(Validator): + """ + Example: + Use as:: + + INPUT(_type='text', _name='name', requires=IS_TIME()) + + understands the following formats + hh:mm:ss [am/pm] + hh:mm [am/pm] + hh [am/pm] + + [am/pm] is optional, ':' can be replaced by any other non-space non-digit:: + + >>> IS_TIME()('21:30') + (datetime.time(21, 30), None) + >>> IS_TIME()('21-30') + (datetime.time(21, 30), None) + >>> IS_TIME()('21.30') + (datetime.time(21, 30), None) + >>> IS_TIME()('21:30:59') + (datetime.time(21, 30, 59), None) + >>> IS_TIME()('5:30') + (datetime.time(5, 30), None) + >>> IS_TIME()('5:30 am') + (datetime.time(5, 30), None) + >>> IS_TIME()('5:30 pm') + (datetime.time(17, 30), None) + >>> IS_TIME()('5:30 whatever') + ('5:30 whatever', 'enter time as hh:mm:ss (seconds, am, pm optional)') + >>> IS_TIME()('5:30 20') + ('5:30 20', 'enter time as hh:mm:ss (seconds, am, pm optional)') + >>> IS_TIME()('24:30') + ('24:30', 'enter time as hh:mm:ss (seconds, am, pm optional)') + >>> IS_TIME()('21:60') + ('21:60', 'enter time as hh:mm:ss (seconds, am, pm optional)') + >>> IS_TIME()('21:30::') + ('21:30::', 'enter time as hh:mm:ss (seconds, am, pm optional)') + >>> IS_TIME()('') + ('', 'enter time as hh:mm:ss (seconds, am, pm optional)')ù + + """ + + def __init__(self, error_message='Enter time as hh:mm:ss (seconds, am, pm optional)'): + self.error_message = error_message + + def __call__(self, value): + try: + ivalue = value + value = regex_time.match(value.lower()) + (h, m, s) = (int(value.group('h')), 0, 0) + if not value.group('m') is None: + m = int(value.group('m')) + if not value.group('s') is None: + s = int(value.group('s')) + if value.group('d') == 'pm' and 0 < h < 12: + h += 12 + if value.group('d') == 'am' and h == 12: + h = 0 + if not (h in range(24) and m in range(60) and s + in range(60)): + raise ValueError('Hours or minutes or seconds are outside of allowed range') + value = datetime.time(h, m, s) + return (value, None) + except AttributeError: + pass + except ValueError: + pass + return (ivalue, translate(self.error_message)) + + +# A UTC class. +class UTC(datetime.tzinfo): + """UTC""" + ZERO = datetime.timedelta(0) + + def utcoffset(self, dt): + return UTC.ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return UTC.ZERO +utc = UTC() + + +class IS_DATE(Validator): + """ + Examples: + Use as:: + + INPUT(_type='text', _name='name', requires=IS_DATE()) + + date has to be in the ISO8960 format YYYY-MM-DD + """ + + def __init__(self, format='%Y-%m-%d', + error_message='Enter date as %(format)s'): + self.format = translate(format) + self.error_message = str(error_message) + self.extremes = {} + + def __call__(self, value): + ovalue = value + if isinstance(value, datetime.date): + return (value, None) + try: + (y, m, d, hh, mm, ss, t0, t1, t2) = \ + time.strptime(value, str(self.format)) + value = datetime.date(y, m, d) + return (value, None) + except: + self.extremes.update(IS_DATETIME.nice(self.format)) + return (ovalue, translate(self.error_message) % self.extremes) + + def formatter(self, value): + if value is None: + return None + format = self.format + year = value.year + y = '%.4i' % year + format = format.replace('%y', y[-2:]) + format = format.replace('%Y', y) + if year < 1900: + year = 2000 + d = datetime.date(year, value.month, value.day) + return d.strftime(format) + + +class IS_DATETIME(Validator): + """ + Examples: + Use as:: + + INPUT(_type='text', _name='name', requires=IS_DATETIME()) + + datetime has to be in the ISO8960 format YYYY-MM-DD hh:mm:ss + timezome must be None or a pytz.timezone("America/Chicago") object + """ + + isodatetime = '%Y-%m-%d %H:%M:%S' + + @staticmethod + def nice(format): + code = (('%Y', '1963'), + ('%y', '63'), + ('%d', '28'), + ('%m', '08'), + ('%b', 'Aug'), + ('%B', 'August'), + ('%H', '14'), + ('%I', '02'), + ('%p', 'PM'), + ('%M', '30'), + ('%S', '59')) + for (a, b) in code: + format = format.replace(a, b) + return dict(format=format) + + def __init__(self, format='%Y-%m-%d %H:%M:%S', + error_message='Enter date and time as %(format)s', + timezone=None): + self.format = translate(format) + self.error_message = str(error_message) + self.extremes = {} + self.timezone = timezone + + def __call__(self, value): + ovalue = value + if isinstance(value, datetime.datetime): + return (value, None) + try: + (y, m, d, hh, mm, ss, t0, t1, t2) = \ + time.strptime(value, str(self.format)) + value = datetime.datetime(y, m, d, hh, mm, ss) + if self.timezone is not None: + # TODO: https://github.com/web2py/web2py/issues/1094 (temporary solution) + value = self.timezone.localize(value).astimezone(utc).replace(tzinfo=None) + return (value, None) + except: + self.extremes.update(IS_DATETIME.nice(self.format)) + return (ovalue, translate(self.error_message) % self.extremes) + + def formatter(self, value): + if value is None: + return None + format = self.format + year = value.year + y = '%.4i' % year + format = format.replace('%y', y[-2:]) + format = format.replace('%Y', y) + if year < 1900: + year = 2000 + d = datetime.datetime(year, value.month, value.day, + value.hour, value.minute, value.second) + if self.timezone is not None: + d = d.replace(tzinfo=utc).astimezone(self.timezone) + return d.strftime(format) + + +class IS_DATE_IN_RANGE(IS_DATE): + """ + Examples: + Use as:: + + >>> v = IS_DATE_IN_RANGE(minimum=datetime.date(2008,1,1), \ + maximum=datetime.date(2009,12,31), \ + format="%m/%d/%Y",error_message="Oops") + + >>> v('03/03/2008') + (datetime.date(2008, 3, 3), None) + + >>> v('03/03/2010') + ('03/03/2010', 'oops') + + >>> v(datetime.date(2008,3,3)) + (datetime.date(2008, 3, 3), None) + + >>> v(datetime.date(2010,3,3)) + (datetime.date(2010, 3, 3), 'oops') + + """ + + def __init__(self, + minimum=None, + maximum=None, + format='%Y-%m-%d', + error_message=None): + self.minimum = minimum + self.maximum = maximum + if error_message is None: + if minimum is None: + error_message = "Enter date on or before %(max)s" + elif maximum is None: + error_message = "Enter date on or after %(min)s" + else: + error_message = "Enter date in range %(min)s %(max)s" + IS_DATE.__init__(self, + format=format, + error_message=error_message) + self.extremes = dict(min=self.formatter(minimum), + max=self.formatter(maximum)) + + def __call__(self, value): + ovalue = value + (value, msg) = IS_DATE.__call__(self, value) + if msg is not None: + return (value, msg) + if self.minimum and self.minimum > value: + return (ovalue, translate(self.error_message) % self.extremes) + if self.maximum and value > self.maximum: + return (ovalue, translate(self.error_message) % self.extremes) + return (value, None) + + +class IS_DATETIME_IN_RANGE(IS_DATETIME): + """ + Examples: + Use as:: + >>> v = IS_DATETIME_IN_RANGE(\ + minimum=datetime.datetime(2008,1,1,12,20), \ + maximum=datetime.datetime(2009,12,31,12,20), \ + format="%m/%d/%Y %H:%M",error_message="Oops") + >>> v('03/03/2008 12:40') + (datetime.datetime(2008, 3, 3, 12, 40), None) + + >>> v('03/03/2010 10:34') + ('03/03/2010 10:34', 'oops') + + >>> v(datetime.datetime(2008,3,3,0,0)) + (datetime.datetime(2008, 3, 3, 0, 0), None) + + >>> v(datetime.datetime(2010,3,3,0,0)) + (datetime.datetime(2010, 3, 3, 0, 0), 'oops') + + """ + + def __init__(self, + minimum=None, + maximum=None, + format='%Y-%m-%d %H:%M:%S', + error_message=None, + timezone=None): + self.minimum = minimum + self.maximum = maximum + if error_message is None: + if minimum is None: + error_message = "Enter date and time on or before %(max)s" + elif maximum is None: + error_message = "Enter date and time on or after %(min)s" + else: + error_message = "Enter date and time in range %(min)s %(max)s" + IS_DATETIME.__init__(self, + format=format, + error_message=error_message, + timezone=timezone) + self.extremes = dict(min=self.formatter(minimum), + max=self.formatter(maximum)) + + def __call__(self, value): + ovalue = value + (value, msg) = IS_DATETIME.__call__(self, value) + if msg is not None: + return (value, msg) + if self.minimum and self.minimum > value: + return (ovalue, translate(self.error_message) % self.extremes) + if self.maximum and value > self.maximum: + return (ovalue, translate(self.error_message) % self.extremes) + return (value, None) + + +class IS_LIST_OF(Validator): + + def __init__(self, other=None, minimum=None, maximum=None, error_message=None): + self.other = other + self.minimum = minimum + self.maximum = maximum + self.error_message = error_message + + def __call__(self, value): + ivalue = value + if not isinstance(value, list): + ivalue = [ivalue] + ivalue = [i for i in ivalue if str(i).strip()] + if self.minimum is not None and len(ivalue) < self.minimum: + return (ivalue, translate(self.error_message or + 'Minimum length is %(min)s') % dict(min=self.minimum, max=self.maximum)) + if self.maximum is not None and len(ivalue) > self.maximum: + return (ivalue, translate(self.error_message or + 'Maximum length is %(max)s') % dict(min=self.minimum, max=self.maximum)) + new_value = [] + other = self.other + if self.other: + if not isinstance(other, (list, tuple)): + other = [other] + for item in ivalue: + v = item + for validator in other: + (v, e) = validator(v) + if e: + return (ivalue, e) + new_value.append(v) + ivalue = new_value + return (ivalue, None) + + +class IS_LOWER(Validator): + """ + Converts to lowercase:: + + >>> IS_LOWER()('ABC') + ('abc', None) + >>> IS_LOWER()('Ñ') + ('\\xc3\\xb1', None) + + """ + + def __call__(self, value): + cast_back = lambda x: x + if isinstance(value, str): + cast_back = to_native + elif isinstance(value, bytes): + cast_back = to_bytes + value = to_unicode(value).lower() + return (cast_back(value), None) + + +class IS_UPPER(Validator): + """ + Converts to uppercase:: + + >>> IS_UPPER()('abc') + ('ABC', None) + >>> IS_UPPER()('ñ') + ('\\xc3\\x91', None) + + """ + + def __call__(self, value): + cast_back = lambda x: x + if isinstance(value, str): + cast_back = to_native + elif isinstance(value, bytes): + cast_back = to_bytes + value = to_unicode(value).upper() + return (cast_back(value), None) + + +def urlify(s, maxlen=80, keep_underscores=False): + """ + Converts incoming string to a simplified ASCII subset. + if (keep_underscores): underscores are retained in the string + else: underscores are translated to hyphens (default) + """ + s = to_unicode(s) # to unicode + s = s.lower() # to lowercase + s = unicodedata.normalize('NFKD', s) # replace special characters + s = to_native(s, charset='ascii', errors='ignore') # encode as ASCII + s = re.sub('&\w+?;', '', s) # strip html entities + if keep_underscores: + s = re.sub('\s+', '-', s) # whitespace to hyphens + s = re.sub('[^\w\-]', '', s) + # strip all but alphanumeric/underscore/hyphen + else: + s = re.sub('[\s_]+', '-', s) # whitespace & underscores to hyphens + s = re.sub('[^a-z0-9\-]', '', s) # strip all but alphanumeric/hyphen + s = re.sub('[-_][-_]+', '-', s) # collapse strings of hyphens + s = s.strip('-') # remove leading and trailing hyphens + return s[:maxlen] # enforce maximum length + + +class IS_SLUG(Validator): + """ + converts arbitrary text string to a slug:: + + >>> IS_SLUG()('abc123') + ('abc123', None) + >>> IS_SLUG()('ABC123') + ('abc123', None) + >>> IS_SLUG()('abc-123') + ('abc-123', None) + >>> IS_SLUG()('abc--123') + ('abc-123', None) + >>> IS_SLUG()('abc 123') + ('abc-123', None) + >>> IS_SLUG()('abc\t_123') + ('abc-123', None) + >>> IS_SLUG()('-abc-') + ('abc', None) + >>> IS_SLUG()('--a--b--_ -c--') + ('a-b-c', None) + >>> IS_SLUG()('abc&123') + ('abc123', None) + >>> IS_SLUG()('abc&123&def') + ('abc123def', None) + >>> IS_SLUG()('ñ') + ('n', None) + >>> IS_SLUG(maxlen=4)('abc123') + ('abc1', None) + >>> IS_SLUG()('abc_123') + ('abc-123', None) + >>> IS_SLUG(keep_underscores=False)('abc_123') + ('abc-123', None) + >>> IS_SLUG(keep_underscores=True)('abc_123') + ('abc_123', None) + >>> IS_SLUG(check=False)('abc') + ('abc', None) + >>> IS_SLUG(check=True)('abc') + ('abc', None) + >>> IS_SLUG(check=False)('a bc') + ('a-bc', None) + >>> IS_SLUG(check=True)('a bc') + ('a bc', 'must be slug') + """ + + @staticmethod + def urlify(value, maxlen=80, keep_underscores=False): + return urlify(value, maxlen, keep_underscores) + + def __init__(self, maxlen=80, check=False, error_message='Must be slug', keep_underscores=False): + self.maxlen = maxlen + self.check = check + self.error_message = error_message + self.keep_underscores = keep_underscores + + def __call__(self, value): + if self.check and value != urlify(value, self.maxlen, self.keep_underscores): + return (value, translate(self.error_message)) + return (urlify(value, self.maxlen, self.keep_underscores), None) + + +class ANY_OF(Validator): + """ + Tests if any of the validators in a list returns successfully:: + + >>> ANY_OF([IS_EMAIL(),IS_ALPHANUMERIC()])('a@b.co') + ('a@b.co', None) + >>> ANY_OF([IS_EMAIL(),IS_ALPHANUMERIC()])('abco') + ('abco', None) + >>> ANY_OF([IS_EMAIL(),IS_ALPHANUMERIC()])('@ab.co') + ('@ab.co', 'enter only letters, numbers, and underscore') + >>> ANY_OF([IS_ALPHANUMERIC(),IS_EMAIL()])('@ab.co') + ('@ab.co', 'enter a valid email address') + + """ + + def __init__(self, subs, error_message=None): + self.subs = subs + self.error_message = error_message + + def __call__(self, value): + for validator in self.subs: + value, error = validator(value) + if error is None: + break + if error is not None and self.error_message is not None: + error = translate(self.error_message) + return value, error + + def formatter(self, value): + # Use the formatter of the first subvalidator + # that validates the value and has a formatter + for validator in self.subs: + if hasattr(validator, 'formatter') and validator(value)[1] is None: + return validator.formatter(value) + + +class IS_EMPTY_OR(Validator): + """ + Dummy class for testing IS_EMPTY_OR:: + + >>> IS_EMPTY_OR(IS_EMAIL())('abc@def.com') + ('abc@def.com', None) + >>> IS_EMPTY_OR(IS_EMAIL())(' ') + (None, None) + >>> IS_EMPTY_OR(IS_EMAIL(), null='abc')(' ') + ('abc', None) + >>> IS_EMPTY_OR(IS_EMAIL(), null='abc', empty_regex='def')('def') + ('abc', None) + >>> IS_EMPTY_OR(IS_EMAIL())('abc') + ('abc', 'enter a valid email address') + >>> IS_EMPTY_OR(IS_EMAIL())(' abc ') + ('abc', 'enter a valid email address') + """ + + def __init__(self, other, null=None, empty_regex=None): + (self.other, self.null) = (other, null) + if empty_regex is not None: + self.empty_regex = re.compile(empty_regex) + else: + self.empty_regex = None + if hasattr(other, 'multiple'): + self.multiple = other.multiple + if hasattr(other, 'options'): + self.options = self._options + + def _options(self, *args, **kwargs): + options = self.other.options(*args, **kwargs) + if (not options or options[0][0] != '') and not self.multiple: + options.insert(0, ('', '')) + return options + + def set_self_id(self, id): + if isinstance(self.other, (list, tuple)): + for item in self.other: + if hasattr(item, 'set_self_id'): + item.set_self_id(id) + else: + if hasattr(self.other, 'set_self_id'): + self.other.set_self_id(id) + + def __call__(self, value): + value, empty = is_empty(value, empty_regex=self.empty_regex) + if empty: + return (self.null, None) + if isinstance(self.other, (list, tuple)): + error = None + for item in self.other: + value, error = item(value) + if error: + break + return value, error + else: + return self.other(value) + + def formatter(self, value): + if hasattr(self.other, 'formatter'): + return self.other.formatter(value) + return value + +IS_NULL_OR = IS_EMPTY_OR # for backward compatibility + + +class CLEANUP(Validator): + """ + Examples: + Use as:: + + INPUT(_type='text', _name='name', requires=CLEANUP()) + + removes special characters on validation + """ + REGEX_CLEANUP = re.compile('[^\x09\x0a\x0d\x20-\x7e]') + + def __init__(self, regex=None): + self.regex = self.REGEX_CLEANUP if regex is None \ + else re.compile(regex) + + def __call__(self, value): + v = self.regex.sub('', str(value).strip()) + return (v, None) + + +class LazyCrypt(object): + """ + Stores a lazy password hash + """ + + def __init__(self, crypt, password): + """ + crypt is an instance of the CRYPT validator, + password is the password as inserted by the user + """ + self.crypt = crypt + self.password = password + self.crypted = None + + def __str__(self): + """ + Encrypted self.password and caches it in self.crypted. + If self.crypt.salt the output is in the format $$ + + Try get the digest_alg from the key (if it exists) + else assume the default digest_alg. If not key at all, set key='' + + If a salt is specified use it, if salt is True, set salt to uuid + (this should all be backward compatible) + + Options: + key = 'uuid' + key = 'md5:uuid' + key = 'sha512:uuid' + ... + key = 'pbkdf2(1000,64,sha512):uuid' 1000 iterations and 64 chars length + """ + if self.crypted: + return self.crypted + if self.crypt.key: + if ':' in self.crypt.key: + digest_alg, key = self.crypt.key.split(':', 1) + else: + digest_alg, key = self.crypt.digest_alg, self.crypt.key + else: + digest_alg, key = self.crypt.digest_alg, '' + if self.crypt.salt: + if self.crypt.salt is True: + salt = str(web2py_uuid()).replace('-', '')[-16:] + else: + salt = self.crypt.salt + else: + salt = '' + hashed = simple_hash(self.password, key, salt, digest_alg) + self.crypted = '%s$%s$%s' % (digest_alg, salt, hashed) + return self.crypted + + def __eq__(self, stored_password): + """ + compares the current lazy crypted password with a stored password + """ + + # LazyCrypt objects comparison + if isinstance(stored_password, self.__class__): + return ((self is stored_password) or + ((self.crypt.key == stored_password.crypt.key) and + (self.password == stored_password.password))) + + if self.crypt.key: + if ':' in self.crypt.key: + key = self.crypt.key.split(':')[1] + else: + key = self.crypt.key + else: + key = '' + if stored_password is None: + return False + elif stored_password.count('$') == 2: + (digest_alg, salt, hash) = stored_password.split('$') + h = simple_hash(self.password, key, salt, digest_alg) + temp_pass = '%s$%s$%s' % (digest_alg, salt, h) + else: # no salting + # guess digest_alg + digest_alg = DIGEST_ALG_BY_SIZE.get(len(stored_password), None) + if not digest_alg: + return False + else: + temp_pass = simple_hash(self.password, key, '', digest_alg) + return temp_pass == stored_password + + def __ne__(self, other): + return not self.__eq__(other) + + +class CRYPT(object): + """ + Examples: + Use as:: + + INPUT(_type='text', _name='name', requires=CRYPT()) + + encodes the value on validation with a digest. + + If no arguments are provided CRYPT uses the MD5 algorithm. + If the key argument is provided the HMAC+MD5 algorithm is used. + If the digest_alg is specified this is used to replace the + MD5 with, for example, SHA512. The digest_alg can be + the name of a hashlib algorithm as a string or the algorithm itself. + + min_length is the minimal password length (default 4) - IS_STRONG for serious security + error_message is the message if password is too short + + Notice that an empty password is accepted but invalid. It will not allow login back. + Stores junk as hashed password. + + Specify an algorithm or by default we will use sha512. + + Typical available algorithms: + md5, sha1, sha224, sha256, sha384, sha512 + + If salt, it hashes a password with a salt. + If salt is True, this method will automatically generate one. + Either case it returns an encrypted password string in the following format: + + $$ + + Important: hashed password is returned as a LazyCrypt object and computed only if needed. + The LasyCrypt object also knows how to compare itself with an existing salted password + + Supports standard algorithms + + >>> for alg in ('md5','sha1','sha256','sha384','sha512'): + ... print(str(CRYPT(digest_alg=alg,salt=True)('test')[0])) + md5$...$... + sha1$...$... + sha256$...$... + sha384$...$... + sha512$...$... + + The syntax is always alg$salt$hash + + Supports for pbkdf2 + + >>> alg = 'pbkdf2(1000,20,sha512)' + >>> print(str(CRYPT(digest_alg=alg,salt=True)('test')[0])) + pbkdf2(1000,20,sha512)$...$... + + An optional hmac_key can be specified and it is used as salt prefix + + >>> a = str(CRYPT(digest_alg='md5',key='mykey',salt=True)('test')[0]) + >>> print(a) + md5$...$... + + Even if the algorithm changes the hash can still be validated + + >>> CRYPT(digest_alg='sha1',key='mykey',salt=True)('test')[0] == a + True + + If no salt is specified CRYPT can guess the algorithms from length: + + >>> a = str(CRYPT(digest_alg='sha1',salt=False)('test')[0]) + >>> a + 'sha1$$a94a8fe5ccb19ba61c4c0873d391e987982fbbd3' + >>> CRYPT(digest_alg='sha1',salt=False)('test')[0] == a + True + >>> CRYPT(digest_alg='sha1',salt=False)('test')[0] == a[6:] + True + >>> CRYPT(digest_alg='md5',salt=False)('test')[0] == a + True + >>> CRYPT(digest_alg='md5',salt=False)('test')[0] == a[6:] + True + """ + + def __init__(self, + key=None, + digest_alg='pbkdf2(1000,20,sha512)', + min_length=0, + error_message='Too short', salt=True, + max_length=1024): + """ + important, digest_alg='md5' is not the default hashing algorithm for + web2py. This is only an example of usage of this function. + + The actual hash algorithm is determined from the key which is + generated by web2py in tools.py. This defaults to hmac+sha512. + """ + self.key = key + self.digest_alg = digest_alg + self.min_length = min_length + self.max_length = max_length + self.error_message = error_message + self.salt = salt + + def __call__(self, value): + v = value and str(value)[:self.max_length] + if not v or len(v) < self.min_length: + return ('', translate(self.error_message)) + if isinstance(value, LazyCrypt): + return (value, None) + return (LazyCrypt(self, value), None) + +# entropy calculator for IS_STRONG +# +lowerset = frozenset(u'abcdefghijklmnopqrstuvwxyz') +upperset = frozenset(u'ABCDEFGHIJKLMNOPQRSTUVWXYZ') +numberset = frozenset(u'0123456789') +sym1set = frozenset(u'!@#$%^&*()') +sym2set = frozenset(u'~`-_=+[]{}\\|;:\'",.<>?/') +otherset = frozenset( + u'0123456789abcdefghijklmnopqrstuvwxyz') # anything else + + +def calc_entropy(string): + """ calculates a simple entropy for a given string """ + import math + alphabet = 0 # alphabet size + other = set() + seen = set() + lastset = None + string = to_unicode(string) + for c in string: + # classify this character + inset = otherset + for cset in (lowerset, upperset, numberset, sym1set, sym2set): + if c in cset: + inset = cset + break + # calculate effect of character on alphabet size + if inset not in seen: + seen.add(inset) + alphabet += len(inset) # credit for a new character set + elif c not in other: + alphabet += 1 # credit for unique characters + other.add(c) + if inset is not lastset: + alphabet += 1 # credit for set transitions + lastset = cset + entropy = len( + string) * math.log(alphabet) / 0.6931471805599453 # math.log(2) + return round(entropy, 2) + + +class IS_STRONG(object): + """ + Examples: + Use as:: + + INPUT(_type='password', _name='passwd', + requires=IS_STRONG(min=10, special=2, upper=2)) + + enforces complexity requirements on a field + + >>> IS_STRONG(es=True)('Abcd1234') + ('Abcd1234', + 'Must include at least 1 of the following: ~!@#$%^&*()_+-=?<>,.:;{}[]|') + >>> IS_STRONG(es=True)('Abcd1234!') + ('Abcd1234!', None) + >>> IS_STRONG(es=True, entropy=1)('a') + ('a', None) + >>> IS_STRONG(es=True, entropy=1, min=2)('a') + ('a', 'Minimum length is 2') + >>> IS_STRONG(es=True, entropy=100)('abc123') + ('abc123', 'Entropy (32.35) less than required (100)') + >>> IS_STRONG(es=True, entropy=100)('and') + ('and', 'Entropy (14.57) less than required (100)') + >>> IS_STRONG(es=True, entropy=100)('aaa') + ('aaa', 'Entropy (14.42) less than required (100)') + >>> IS_STRONG(es=True, entropy=100)('a1d') + ('a1d', 'Entropy (15.97) less than required (100)') + >>> IS_STRONG(es=True, entropy=100)('añd') + ('a\\xc3\\xb1d', 'Entropy (18.13) less than required (100)') + + """ + + def __init__(self, min=None, max=None, upper=None, lower=None, number=None, + entropy=None, + special=None, specials=r'~!@#$%^&*()_+-=?<>,.:;{}[]|', + invalid=' "', error_message=None, es=False): + self.entropy = entropy + if entropy is None: + # enforce default requirements + self.min = 8 if min is None else min + self.max = max # was 20, but that doesn't make sense + self.upper = 1 if upper is None else upper + self.lower = 1 if lower is None else lower + self.number = 1 if number is None else number + self.special = 1 if special is None else special + else: + # by default, an entropy spec is exclusive + self.min = min + self.max = max + self.upper = upper + self.lower = lower + self.number = number + self.special = special + self.specials = specials + self.invalid = invalid + self.error_message = error_message + self.estring = es # return error message as string (for doctest) + + def __call__(self, value): + failures = [] + if value and len(value) == value.count('*') > 4: + return (value, None) + if self.entropy is not None: + entropy = calc_entropy(value) + if entropy < self.entropy: + failures.append(translate("Entropy (%(have)s) less than required (%(need)s)") + % dict(have=entropy, need=self.entropy)) + if isinstance(self.min, int) and self.min > 0: + if not len(value) >= self.min: + failures.append(translate("Minimum length is %s") % self.min) + if isinstance(self.max, int) and self.max > 0: + if not len(value) <= self.max: + failures.append(translate("Maximum length is %s") % self.max) + if isinstance(self.special, int): + all_special = [ch in value for ch in self.specials] + if self.special > 0: + if not all_special.count(True) >= self.special: + failures.append(translate("Must include at least %s of the following: %s") + % (self.special, self.specials)) + if self.invalid: + all_invalid = [ch in value for ch in self.invalid] + if all_invalid.count(True) > 0: + failures.append(translate("May not contain any of the following: %s") + % self.invalid) + if isinstance(self.upper, int): + all_upper = re.findall("[A-Z]", value) + if self.upper > 0: + if not len(all_upper) >= self.upper: + failures.append(translate("Must include at least %s uppercase") + % str(self.upper)) + else: + if len(all_upper) > 0: + failures.append( + translate("May not include any uppercase letters")) + if isinstance(self.lower, int): + all_lower = re.findall("[a-z]", value) + if self.lower > 0: + if not len(all_lower) >= self.lower: + failures.append(translate("Must include at least %s lowercase") + % str(self.lower)) + else: + if len(all_lower) > 0: + failures.append( + translate("May not include any lowercase letters")) + if isinstance(self.number, int): + all_number = re.findall("[0-9]", value) + if self.number > 0: + numbers = "number" + if self.number > 1: + numbers = "numbers" + if not len(all_number) >= self.number: + failures.append(translate("Must include at least %s %s") + % (str(self.number), numbers)) + else: + if len(all_number) > 0: + failures.append(translate("May not include any numbers")) + if len(failures) == 0: + return (value, None) + if not self.error_message: + if self.estring: + return (value, '|'.join(failures)) + from gluon.html import XML + return (value, XML('
'.join(failures))) + else: + return (value, translate(self.error_message)) + + +class IS_IMAGE(Validator): + """ + Checks if file uploaded through file input was saved in one of selected + image formats and has dimensions (width and height) within given boundaries. + + Does *not* check for maximum file size (use IS_LENGTH for that). Returns + validation failure if no data was uploaded. + + Supported file formats: BMP, GIF, JPEG, PNG. + + Code parts taken from + http://mail.python.org/pipermail/python-list/2007-June/617126.html + + Args: + extensions: iterable containing allowed *lowercase* image file extensions + ('jpg' extension of uploaded file counts as 'jpeg') + maxsize: iterable containing maximum width and height of the image + minsize: iterable containing minimum width and height of the image + aspectratio: iterable containing target aspect ratio + + Use (-1, -1) as minsize to pass image size check. + Use (-1, -1) as aspectratio to pass aspect ratio check. + + Examples: + Check if uploaded file is in any of supported image formats: + + INPUT(_type='file', _name='name', requires=IS_IMAGE()) + + Check if uploaded file is either JPEG or PNG: + + INPUT(_type='file', _name='name', + requires=IS_IMAGE(extensions=('jpeg', 'png'))) + + Check if uploaded file is PNG with maximum size of 200x200 pixels: + + INPUT(_type='file', _name='name', + requires=IS_IMAGE(extensions=('png'), maxsize=(200, 200))) + + Check if uploaded file has a 16:9 aspect ratio: + + INPUT(_type='file', _name='name', + requires=IS_IMAGE(aspectratio=(16, 9))) + """ + + def __init__(self, + extensions=('bmp', 'gif', 'jpeg', 'png'), + maxsize=(10000, 10000), + minsize=(0, 0), + aspectratio=(-1, -1), + error_message='Invalid image'): + + self.extensions = extensions + self.maxsize = maxsize + self.minsize = minsize + self.aspectratio = aspectratio + self.error_message = error_message + + def __call__(self, value): + try: + extension = value.filename.rfind('.') + assert extension >= 0 + extension = value.filename[extension + 1:].lower() + if extension == 'jpg': + extension = 'jpeg' + assert extension in self.extensions + if extension == 'bmp': + width, height = self.__bmp(value.file) + elif extension == 'gif': + width, height = self.__gif(value.file) + elif extension == 'jpeg': + width, height = self.__jpeg(value.file) + elif extension == 'png': + width, height = self.__png(value.file) + else: + width = -1 + height = -1 + + assert self.minsize[0] <= width <= self.maxsize[0] \ + and self.minsize[1] <= height <= self.maxsize[1] + + if self.aspectratio > (-1, -1): + target_ratio = (1.0 * self.aspectratio[1]) / self.aspectratio[0] + actual_ratio = (1.0 * height) / width + + assert actual_ratio == target_ratio + + value.file.seek(0) + return (value, None) + except Exception as e: + return (value, translate(self.error_message)) + + def __bmp(self, stream): + if stream.read(2) == b'BM': + stream.read(16) + return struct.unpack("= 0xC0 and code <= 0xC3: + return tuple(reversed( + struct.unpack("!xHH", stream.read(5)))) + else: + stream.read(length - 2) + return (-1, -1) + + def __png(self, stream): + if stream.read(8) == b'\211PNG\r\n\032\n': + stream.read(4) + if stream.read(4) == b"IHDR": + return struct.unpack("!LL", stream.read(8)) + return (-1, -1) + + +class IS_UPLOAD_FILENAME(Validator): + """ + Checks if name and extension of file uploaded through file input matches + given criteria. + + Does *not* ensure the file type in any way. Returns validation failure + if no data was uploaded. + + Args: + filename: filename (before dot) regex + extension: extension (after dot) regex + lastdot: which dot should be used as a filename / extension separator: + True means last dot, eg. file.png -> file / png + False means first dot, eg. file.tar.gz -> file / tar.gz + case: 0 - keep the case, 1 - transform the string into lowercase (default), + 2 - transform the string into uppercase + + If there is no dot present, extension checks will be done against empty + string and filename checks against whole value. + + Examples: + Check if file has a pdf extension (case insensitive): + + INPUT(_type='file', _name='name', + requires=IS_UPLOAD_FILENAME(extension='pdf')) + + Check if file has a tar.gz extension and name starting with backup: + + INPUT(_type='file', _name='name', + requires=IS_UPLOAD_FILENAME(filename='backup.*', + extension='tar.gz', lastdot=False)) + + Check if file has no extension and name matching README + (case sensitive): + + INPUT(_type='file', _name='name', + requires=IS_UPLOAD_FILENAME(filename='^README$', + extension='^$', case=0) + + """ + + def __init__(self, filename=None, extension=None, lastdot=True, case=1, + error_message='Enter valid filename'): + if isinstance(filename, str): + filename = re.compile(filename) + if isinstance(extension, str): + extension = re.compile(extension) + self.filename = filename + self.extension = extension + self.lastdot = lastdot + self.case = case + self.error_message = error_message + + def __call__(self, value): + try: + string = value.filename + except: + return (value, translate(self.error_message)) + if self.case == 1: + string = string.lower() + elif self.case == 2: + string = string.upper() + if self.lastdot: + dot = string.rfind('.') + else: + dot = string.find('.') + if dot == -1: + dot = len(string) + if self.filename and not self.filename.match(string[:dot]): + return (value, translate(self.error_message)) + elif self.extension and not self.extension.match(string[dot + 1:]): + return (value, translate(self.error_message)) + else: + return (value, None) + + +class IS_IPV4(Validator): + """ + Checks if field's value is an IP version 4 address in decimal form. Can + be set to force addresses from certain range. + + IPv4 regex taken from: http://regexlib.com/REDetails.aspx?regexp_id=1411 + + Args: + + minip: lowest allowed address; accepts: + + - str, eg. 192.168.0.1 + - list or tuple of octets, eg. [192, 168, 0, 1] + maxip: highest allowed address; same as above + invert: True to allow addresses only from outside of given range; note + that range boundaries are not matched this way + is_localhost: localhost address treatment: + + - None (default): indifferent + - True (enforce): query address must match localhost address (127.0.0.1) + - False (forbid): query address must not match localhost address + is_private: same as above, except that query address is checked against + two address ranges: 172.16.0.0 - 172.31.255.255 and + 192.168.0.0 - 192.168.255.255 + is_automatic: same as above, except that query address is checked against + one address range: 169.254.0.0 - 169.254.255.255 + + Minip and maxip may also be lists or tuples of addresses in all above + forms (str, int, list / tuple), allowing setup of multiple address ranges:: + + minip = (minip1, minip2, ... minipN) + | | | + | | | + maxip = (maxip1, maxip2, ... maxipN) + + Longer iterable will be truncated to match length of shorter one. + + Examples: + Check for valid IPv4 address: + + INPUT(_type='text', _name='name', requires=IS_IPV4()) + + Check for valid IPv4 address belonging to specific range: + + INPUT(_type='text', _name='name', + requires=IS_IPV4(minip='100.200.0.0', maxip='100.200.255.255')) + + Check for valid IPv4 address belonging to either 100.110.0.0 - + 100.110.255.255 or 200.50.0.0 - 200.50.0.255 address range: + + INPUT(_type='text', _name='name', + requires=IS_IPV4(minip=('100.110.0.0', '200.50.0.0'), + maxip=('100.110.255.255', '200.50.0.255'))) + + Check for valid IPv4 address belonging to private address space: + + INPUT(_type='text', _name='name', requires=IS_IPV4(is_private=True)) + + Check for valid IPv4 address that is not a localhost address: + + INPUT(_type='text', _name='name', requires=IS_IPV4(is_localhost=False)) + + >>> IS_IPV4()('1.2.3.4') + ('1.2.3.4', None) + >>> IS_IPV4()('255.255.255.255') + ('255.255.255.255', None) + >>> IS_IPV4()('1.2.3.4 ') + ('1.2.3.4 ', 'enter valid IPv4 address') + >>> IS_IPV4()('1.2.3.4.5') + ('1.2.3.4.5', 'enter valid IPv4 address') + >>> IS_IPV4()('123.123') + ('123.123', 'enter valid IPv4 address') + >>> IS_IPV4()('1111.2.3.4') + ('1111.2.3.4', 'enter valid IPv4 address') + >>> IS_IPV4()('0111.2.3.4') + ('0111.2.3.4', 'enter valid IPv4 address') + >>> IS_IPV4()('256.2.3.4') + ('256.2.3.4', 'enter valid IPv4 address') + >>> IS_IPV4()('300.2.3.4') + ('300.2.3.4', 'enter valid IPv4 address') + >>> IS_IPV4(minip='1.2.3.4', maxip='1.2.3.4')('1.2.3.4') + ('1.2.3.4', None) + >>> IS_IPV4(minip='1.2.3.5', maxip='1.2.3.9', error_message='Bad ip')('1.2.3.4') + ('1.2.3.4', 'bad ip') + >>> IS_IPV4(maxip='1.2.3.4', invert=True)('127.0.0.1') + ('127.0.0.1', None) + >>> IS_IPV4(maxip='1.2.3.4', invert=True)('1.2.3.4') + ('1.2.3.4', 'enter valid IPv4 address') + >>> IS_IPV4(is_localhost=True)('127.0.0.1') + ('127.0.0.1', None) + >>> IS_IPV4(is_localhost=True)('1.2.3.4') + ('1.2.3.4', 'enter valid IPv4 address') + >>> IS_IPV4(is_localhost=False)('127.0.0.1') + ('127.0.0.1', 'enter valid IPv4 address') + >>> IS_IPV4(maxip='100.0.0.0', is_localhost=True)('127.0.0.1') + ('127.0.0.1', 'enter valid IPv4 address') + + """ + + regex = re.compile( + '^(([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])\.){3}([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])$') + numbers = (16777216, 65536, 256, 1) + localhost = 2130706433 + private = ((2886729728, 2886795263), (3232235520, 3232301055)) + automatic = (2851995648, 2852061183) + + def __init__( + self, + minip='0.0.0.0', + maxip='255.255.255.255', + invert=False, + is_localhost=None, + is_private=None, + is_automatic=None, + error_message='Enter valid IPv4 address'): + for n, value in enumerate((minip, maxip)): + temp = [] + if isinstance(value, str): + temp.append(value.split('.')) + elif isinstance(value, (list, tuple)): + if len(value) == len([item for item in value if isinstance(item, int)]) == 4: + temp.append(value) + else: + for item in value: + if isinstance(item, str): + temp.append(item.split('.')) + elif isinstance(item, (list, tuple)): + temp.append(item) + numbers = [] + for item in temp: + number = 0 + for i, j in zip(self.numbers, item): + number += i * int(j) + numbers.append(number) + if n == 0: + self.minip = numbers + else: + self.maxip = numbers + self.invert = invert + self.is_localhost = is_localhost + self.is_private = is_private + self.is_automatic = is_automatic + self.error_message = error_message + + def __call__(self, value): + if self.regex.match(value): + number = 0 + for i, j in zip(self.numbers, value.split('.')): + number += i * int(j) + ok = False + for bottom, top in zip(self.minip, self.maxip): + if self.invert != (bottom <= number <= top): + ok = True + if ok and self.is_localhost is not None and \ + self.is_localhost != (number == self.localhost): + ok = False + if ok and self.is_private is not None and (self.is_private != + any([private_number[0] <= number <= private_number[1] + for private_number in self.private])): + ok = False + if ok and self.is_automatic is not None and (self.is_automatic != + (self.automatic[0] <= number <= self.automatic[1])): + ok = False + if ok: + return (value, None) + return (value, translate(self.error_message)) + + +class IS_IPV6(Validator): + """ + Checks if field's value is an IP version 6 address. + + Uses the ipaddress from the Python 3 standard library + and its Python 2 backport (in contrib/ipaddress.py). + + Args: + is_private: None (default): indifferent + True (enforce): address must be in fc00::/7 range + False (forbid): address must NOT be in fc00::/7 range + is_link_local: Same as above but uses fe80::/10 range + is_reserved: Same as above but uses IETF reserved range + is_multicast: Same as above but uses ff00::/8 range + is_routeable: Similar to above but enforces not private, link_local, + reserved or multicast + is_6to4: Same as above but uses 2002::/16 range + is_teredo: Same as above but uses 2001::/32 range + subnets: value must be a member of at least one from list of subnets + + Examples: + Check for valid IPv6 address: + + INPUT(_type='text', _name='name', requires=IS_IPV6()) + + Check for valid IPv6 address is a link_local address: + + INPUT(_type='text', _name='name', requires=IS_IPV6(is_link_local=True)) + + Check for valid IPv6 address that is Internet routeable: + + INPUT(_type='text', _name='name', requires=IS_IPV6(is_routeable=True)) + + Check for valid IPv6 address in specified subnet: + + INPUT(_type='text', _name='name', requires=IS_IPV6(subnets=['2001::/32']) + + >>> IS_IPV6()('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', None) + >>> IS_IPV6()('192.168.1.1') + ('192.168.1.1', 'enter valid IPv6 address') + >>> IS_IPV6(error_message='Bad ip')('192.168.1.1') + ('192.168.1.1', 'bad ip') + >>> IS_IPV6(is_link_local=True)('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', None) + >>> IS_IPV6(is_link_local=False)('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', 'enter valid IPv6 address') + >>> IS_IPV6(is_link_local=True)('2001::126c:8ffa:fe22:b3af') + ('2001::126c:8ffa:fe22:b3af', 'enter valid IPv6 address') + >>> IS_IPV6(is_multicast=True)('2001::126c:8ffa:fe22:b3af') + ('2001::126c:8ffa:fe22:b3af', 'enter valid IPv6 address') + >>> IS_IPV6(is_multicast=True)('ff00::126c:8ffa:fe22:b3af') + ('ff00::126c:8ffa:fe22:b3af', None) + >>> IS_IPV6(is_routeable=True)('2001::126c:8ffa:fe22:b3af') + ('2001::126c:8ffa:fe22:b3af', None) + >>> IS_IPV6(is_routeable=True)('ff00::126c:8ffa:fe22:b3af') + ('ff00::126c:8ffa:fe22:b3af', 'enter valid IPv6 address') + >>> IS_IPV6(subnets='2001::/32')('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', None) + >>> IS_IPV6(subnets='fb00::/8')('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', 'enter valid IPv6 address') + >>> IS_IPV6(subnets=['fc00::/8','2001::/32'])('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', None) + >>> IS_IPV6(subnets='invalidsubnet')('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', 'invalid subnet provided') + + """ + + def __init__( + self, + is_private=None, + is_link_local=None, + is_reserved=None, + is_multicast=None, + is_routeable=None, + is_6to4=None, + is_teredo=None, + subnets=None, + error_message='Enter valid IPv6 address'): + self.is_private = is_private + self.is_link_local = is_link_local + self.is_reserved = is_reserved + self.is_multicast = is_multicast + self.is_routeable = is_routeable + self.is_6to4 = is_6to4 + self.is_teredo = is_teredo + self.subnets = subnets + self.error_message = error_message + + def __call__(self, value): + from gluon._compat import ipaddress + + try: + ip = ipaddress.IPv6Address(to_unicode(value)) + ok = True + except ipaddress.AddressValueError: + return (value, translate(self.error_message)) + + if self.subnets: + # iterate through self.subnets to see if value is a member + ok = False + if isinstance(self.subnets, str): + self.subnets = [self.subnets] + for network in self.subnets: + try: + ipnet = ipaddress.IPv6Network(to_unicode(network)) + except (ipaddress.NetmaskValueError, ipaddress.AddressValueError): + return (value, translate('invalid subnet provided')) + if ip in ipnet: + ok = True + + if self.is_routeable: + self.is_private = False + self.is_reserved = False + self.is_multicast = False + + if ok and self.is_private is not None and \ + self.is_private != ip.is_private: + ok = False + if ok and self.is_link_local is not None and \ + self.is_link_local != ip.is_link_local: + ok = False + if ok and self.is_reserved is not None and \ + self.is_reserved != ip.is_reserved: + ok = False + if ok and self.is_multicast is not None and \ + self.is_multicast != ip.is_multicast: + ok = False + if ok and self.is_6to4 is not None and \ + self.is_6to4 != bool(ip.sixtofour): + ok = False + if ok and self.is_teredo is not None and \ + self.is_teredo != bool(ip.teredo): + ok = False + + if ok: + return (value, None) + + return (value, translate(self.error_message)) + + +class IS_IPADDRESS(Validator): + """ + Checks if field's value is an IP Address (v4 or v6). Can be set to force + addresses from within a specific range. Checks are done with the correct + IS_IPV4 and IS_IPV6 validators. + + Uses the ipaddress from the Python 3 standard library + and its Python 2 backport (in contrib/ipaddress.py). + + Args: + minip: lowest allowed address; accepts: + str, eg. 192.168.0.1 + list or tuple of octets, eg. [192, 168, 0, 1] + maxip: highest allowed address; same as above + invert: True to allow addresses only from outside of given range; note + that range boundaries are not matched this way + + IPv4 specific arguments: + + - is_localhost: localhost address treatment: + + - None (default): indifferent + - True (enforce): query address must match localhost address + (127.0.0.1) + - False (forbid): query address must not match localhost address + - is_private: same as above, except that query address is checked against + two address ranges: 172.16.0.0 - 172.31.255.255 and + 192.168.0.0 - 192.168.255.255 + - is_automatic: same as above, except that query address is checked against + one address range: 169.254.0.0 - 169.254.255.255 + - is_ipv4: either: + + - None (default): indifferent + - True (enforce): must be an IPv4 address + - False (forbid): must NOT be an IPv4 address + + IPv6 specific arguments: + + - is_link_local: Same as above but uses fe80::/10 range + - is_reserved: Same as above but uses IETF reserved range + - is_multicast: Same as above but uses ff00::/8 range + - is_routeable: Similar to above but enforces not private, link_local, + reserved or multicast + - is_6to4: Same as above but uses 2002::/16 range + - is_teredo: Same as above but uses 2001::/32 range + - subnets: value must be a member of at least one from list of subnets + - is_ipv6: either: + + - None (default): indifferent + - True (enforce): must be an IPv6 address + - False (forbid): must NOT be an IPv6 address + + Minip and maxip may also be lists or tuples of addresses in all above + forms (str, int, list / tuple), allowing setup of multiple address ranges:: + + minip = (minip1, minip2, ... minipN) + | | | + | | | + maxip = (maxip1, maxip2, ... maxipN) + + Longer iterable will be truncated to match length of shorter one. + + >>> IS_IPADDRESS()('192.168.1.5') + ('192.168.1.5', None) + >>> IS_IPADDRESS(is_ipv6=False)('192.168.1.5') + ('192.168.1.5', None) + >>> IS_IPADDRESS()('255.255.255.255') + ('255.255.255.255', None) + >>> IS_IPADDRESS()('192.168.1.5 ') + ('192.168.1.5 ', 'enter valid IP address') + >>> IS_IPADDRESS()('192.168.1.1.5') + ('192.168.1.1.5', 'enter valid IP address') + >>> IS_IPADDRESS()('123.123') + ('123.123', 'enter valid IP address') + >>> IS_IPADDRESS()('1111.2.3.4') + ('1111.2.3.4', 'enter valid IP address') + >>> IS_IPADDRESS()('0111.2.3.4') + ('0111.2.3.4', 'enter valid IP address') + >>> IS_IPADDRESS()('256.2.3.4') + ('256.2.3.4', 'enter valid IP address') + >>> IS_IPADDRESS()('300.2.3.4') + ('300.2.3.4', 'enter valid IP address') + >>> IS_IPADDRESS(minip='192.168.1.0', maxip='192.168.1.255')('192.168.1.100') + ('192.168.1.100', None) + >>> IS_IPADDRESS(minip='1.2.3.5', maxip='1.2.3.9', error_message='Bad ip')('1.2.3.4') + ('1.2.3.4', 'bad ip') + >>> IS_IPADDRESS(maxip='1.2.3.4', invert=True)('127.0.0.1') + ('127.0.0.1', None) + >>> IS_IPADDRESS(maxip='192.168.1.4', invert=True)('192.168.1.4') + ('192.168.1.4', 'enter valid IP address') + >>> IS_IPADDRESS(is_localhost=True)('127.0.0.1') + ('127.0.0.1', None) + >>> IS_IPADDRESS(is_localhost=True)('192.168.1.10') + ('192.168.1.10', 'enter valid IP address') + >>> IS_IPADDRESS(is_localhost=False)('127.0.0.1') + ('127.0.0.1', 'enter valid IP address') + >>> IS_IPADDRESS(maxip='100.0.0.0', is_localhost=True)('127.0.0.1') + ('127.0.0.1', 'enter valid IP address') + + >>> IS_IPADDRESS()('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', None) + >>> IS_IPADDRESS(is_ipv4=False)('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', None) + >>> IS_IPADDRESS()('fe80::126c:8ffa:fe22:b3af ') + ('fe80::126c:8ffa:fe22:b3af ', 'enter valid IP address') + >>> IS_IPADDRESS(is_ipv4=True)('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', 'enter valid IP address') + >>> IS_IPADDRESS(is_ipv6=True)('192.168.1.1') + ('192.168.1.1', 'enter valid IP address') + >>> IS_IPADDRESS(is_ipv6=True, error_message='Bad ip')('192.168.1.1') + ('192.168.1.1', 'bad ip') + >>> IS_IPADDRESS(is_link_local=True)('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', None) + >>> IS_IPADDRESS(is_link_local=False)('fe80::126c:8ffa:fe22:b3af') + ('fe80::126c:8ffa:fe22:b3af', 'enter valid IP address') + >>> IS_IPADDRESS(is_link_local=True)('2001::126c:8ffa:fe22:b3af') + ('2001::126c:8ffa:fe22:b3af', 'enter valid IP address') + >>> IS_IPADDRESS(is_multicast=True)('2001::126c:8ffa:fe22:b3af') + ('2001::126c:8ffa:fe22:b3af', 'enter valid IP address') + >>> IS_IPADDRESS(is_multicast=True)('ff00::126c:8ffa:fe22:b3af') + ('ff00::126c:8ffa:fe22:b3af', None) + >>> IS_IPADDRESS(is_routeable=True)('2001::126c:8ffa:fe22:b3af') + ('2001::126c:8ffa:fe22:b3af', None) + >>> IS_IPADDRESS(is_routeable=True)('ff00::126c:8ffa:fe22:b3af') + ('ff00::126c:8ffa:fe22:b3af', 'enter valid IP address') + >>> IS_IPADDRESS(subnets='2001::/32')('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', None) + >>> IS_IPADDRESS(subnets='fb00::/8')('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', 'enter valid IP address') + >>> IS_IPADDRESS(subnets=['fc00::/8','2001::/32'])('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', None) + >>> IS_IPADDRESS(subnets='invalidsubnet')('2001::8ffa:fe22:b3af') + ('2001::8ffa:fe22:b3af', 'invalid subnet provided') + """ + + def __init__( + self, + minip='0.0.0.0', + maxip='255.255.255.255', + invert=False, + is_localhost=None, + is_private=None, + is_automatic=None, + is_ipv4=None, + is_link_local=None, + is_reserved=None, + is_multicast=None, + is_routeable=None, + is_6to4=None, + is_teredo=None, + subnets=None, + is_ipv6=None, + error_message='Enter valid IP address'): + self.minip = minip, + self.maxip = maxip, + self.invert = invert + self.is_localhost = is_localhost + self.is_private = is_private + self.is_automatic = is_automatic + self.is_ipv4 = is_ipv4 or is_ipv6 is False + self.is_private = is_private + self.is_link_local = is_link_local + self.is_reserved = is_reserved + self.is_multicast = is_multicast + self.is_routeable = is_routeable + self.is_6to4 = is_6to4 + self.is_teredo = is_teredo + self.subnets = subnets + self.is_ipv6 = is_ipv6 or is_ipv4 is False + self.error_message = error_message + + def __call__(self, value): + from gluon._compat import ipaddress + IPAddress = ipaddress.ip_address + IPv6Address = ipaddress.IPv6Address + IPv4Address = ipaddress.IPv4Address + + try: + ip = IPAddress(to_unicode(value)) + except ValueError: + return (value, translate(self.error_message)) + + if self.is_ipv4 and isinstance(ip, IPv6Address): + retval = (value, translate(self.error_message)) + elif self.is_ipv6 and isinstance(ip, IPv4Address): + retval = (value, translate(self.error_message)) + elif self.is_ipv4 or isinstance(ip, IPv4Address): + retval = IS_IPV4( + minip=self.minip, + maxip=self.maxip, + invert=self.invert, + is_localhost=self.is_localhost, + is_private=self.is_private, + is_automatic=self.is_automatic, + error_message=self.error_message + )(value) + elif self.is_ipv6 or isinstance(ip, IPv6Address): + retval = IS_IPV6( + is_private=self.is_private, + is_link_local=self.is_link_local, + is_reserved=self.is_reserved, + is_multicast=self.is_multicast, + is_routeable=self.is_routeable, + is_6to4=self.is_6to4, + is_teredo=self.is_teredo, + subnets=self.subnets, + error_message=self.error_message + )(value) + else: + retval = (value, translate(self.error_message)) + + return retval