From 35103e146b2d3947653022be4a87aab551a6f407 Mon Sep 17 00:00:00 2001 From: patrick cieplak Date: Tue, 16 Jun 2015 10:50:54 -0700 Subject: [PATCH 1/4] add MultipleExceptions class to display multiple parsing exceptions --- pilo/fields.py | 43 ++++++++++++++++-- tests/test_fields.py | 105 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/pilo/fields.py b/pilo/fields.py index b8d96c2..83cf5da 100644 --- a/pilo/fields.py +++ b/pilo/fields.py @@ -120,7 +120,7 @@ class RaiseErrors(list, Errors): def __call__(self, *ex): self.extend(ex) - raise ex[0] + raise MultipleExceptions(*ex) class CollectErrors(list, Errors): @@ -129,6 +129,43 @@ def __call__(self, *ex): self.extend(ex) +class MultipleExceptions(Exception): + + def __new__(cls, *exceptions): + exception_classes = list(set( + exc.__class__ for exc in exceptions + )) + bases = tuple([cls] + sorted( + exception_classes, + key=lambda c: len(c.mro()), reverse=True + )) + cls = type(cls.__name__, bases, dict(cls.__dict__)) + instance = Exception.__new__(cls) + instance.__init__(*exceptions) + return instance + + def __init__(self, *exceptions): + if len(exceptions) == 1: + raise exceptions[0] + self.exceptions = exceptions + + def __str__(self): + return self.message + + def __repr__(self): + return 'MultipleExceptions({0})'.format( + ', '.join(exc.__class__.__name__ for exc in self.exceptions) + ) + + @property + def message(self): + msg = '\n* '.join( + '{0}: {1}'.format(exc.__class__.__name__, str(exc)) + for exc in self.exceptions + ) + return '\n\n* {0}'.format(msg) + + class CreatedCountMixin(object): """ Mixin used to add a `._count` instance value that can use used to sort @@ -1541,7 +1578,7 @@ def __init__(self, *args, **kwargs): if src: errors = self.map(src) if errors: - raise errors[0] + raise MultipleExceptions(*errors) def _map_source(self, obj): return DefaultSource(obj) @@ -1638,7 +1675,7 @@ def map(self, src=None, tags=None, reset=False, unmapped='ignore', error='collec if error == 'collect': return errors if errors: - raise errors[0] + raise MultipleExceptions(*errors) return self def has(self, field): diff --git a/tests/test_fields.py b/tests/test_fields.py index d933d67..b7ba121 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -3,6 +3,7 @@ import re import pilo +from pilo.fields import Integer, List, String from tests import TestCase @@ -374,3 +375,107 @@ def send_to_zoo(self): ]: obj = Animal.type.cast(desc)(desc) self.assertIsInstance(obj, cls) + + def test_exceptions(self): + + class Image(pilo.Field): + + supported_encodings = ['base64'] + supported_formats = ['png'] + + def __init__(self, *args, **kwargs): + self.encoding = kwargs.pop('encoding', None) + self.format = kwargs.pop('format', None) + super(Image, self).__init__(*args, **kwargs) + + def _parse(self, path): + return path.value.decode(self.encoding) + + def _validate(self, value): + predicate_by_format = dict( + png=lambda value: value[:8] == '\211PNG\r\n\032\n' + ) + predicate = predicate_by_format.get(self.format) + if predicate and predicate(value): + return True + self.ctx.errors.invalid( + 'Image must be formatted as {0}'.format(self.format) + ) + return False + + class DatingProfile(pilo.Form): + + genders = ['male', 'female', 'neutral'] + + name = String() + email = String() + postal_code = String(length=5) + blurb = String(max_length=100) + gender = String(choices=genders) + sexual_preferences = List(String(choices=genders)) + likes = List(String()) + picture = Image(format='png', encoding='base64') + + profile_params_with_one_error = dict( + name='William Henry Cavendish III', + email='whc@example.org', + postal_code='9021', # Invalid length + blurb='I am a test fixture', + gender='male', + sexual_preferences=['female', 'neutral'], + likes=['croquet', 'muesli', 'ruses', 'umbrellas', 'wenches'], + picture='iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAA1UlEQ' + 'VRYR+1XQRKAIAjUr/SbXtxv\n+kqNBxsjcCFsGmfoqsC6C5vmlNKRf' + 'vxyAEAMHPvqEigvWzeelaAtihIgdCjXA0AJ8BaVQHG5bwC+\nLF5B0' + 'RoXAG3x3r43OQLAjYGiE2pArwR1KmodcxOOANAeMgCYGUDOh9ZFHy' + 'iBtEFQMus6l19txVqT\nqQfhpglasTXY4vlS7rkY0BqVtM8lQdukXA' + 'H033dLQPWmNyVk4cMBWEdwLgCaZrMyIJlc91JKddXe\nkKU4rk+6D5' + 'M3jUanBbEZL6Ng4HcGTmCz+wGYWb2FAAAAAElFTkSuQmCC\n' + ) + profile_params_with_two_errors = dict( + name='William Henry Cavendish III', + email='whc@example.org', + postal_code='9021', # Invalid length + blurb='I am a test fixture', + gender='male', + sexual_preferences=['female', 'neutral'], + likes=['croquet', 'muesli', 'ruses', 'umbrellas', 'wenches'], + picture='PHN2ZyBoZWlnaHQ9IjEwMCIgd2lkdGg9IjEwMCI+CiAgPGNpcmNsZ' + 'SBjeD0iNTAiIGN5PSI1MCIg\ncj0iNDAiIHN0cm9rZT0iYmxhY2siI' + 'HN0cm9rZS13aWR0aD0iMyIgZmlsbD0icmVkIiAvPgogIFNv\ncnJ5L' + 'CB5b3VyIGJyb3dzZXIgZG9lcyBub3Qgc3VwcG9ydCBpbmxpbmUgU1' + 'ZHLiAgCjwvc3ZnPiA=\n' # SVG Image + ) + profile_params_with_three_errors = dict( + name='William Henry Cavendish III', + email='whc@example.org', + postal_code='9021', # Invalid length + blurb='I am a test fixture', + gender='male', + sexual_preferences=['aliens'], + likes=['croquet', 'muesli', 'ruses', 'umbrellas', 'wenches'], + # No picture + ) + with self.assertRaises(pilo.fields.Invalid): + DatingProfile(**profile_params_with_one_error) + + with self.assertRaises(pilo.fields.FieldError): + DatingProfile(**profile_params_with_two_errors) + + with self.assertRaises(pilo.fields.Invalid): + DatingProfile(**profile_params_with_two_errors) + + with self.assertRaises(pilo.fields.MultipleExceptions): + DatingProfile(**profile_params_with_two_errors) + + with self.assertRaises(pilo.fields.MultipleExceptions): + DatingProfile(**profile_params_with_three_errors) + + with self.assertRaises(pilo.fields.Invalid): + DatingProfile(**profile_params_with_three_errors) + + with self.assertRaises(pilo.fields.Missing): + DatingProfile(**profile_params_with_three_errors) + + with self.assertRaises(pilo.fields.FieldError): + DatingProfile(**profile_params_with_three_errors) From f5d073be49e1a79085d2ab6b62a44c0326254556 Mon Sep 17 00:00:00 2001 From: patrick cieplak Date: Sat, 20 Jun 2015 15:21:07 -0700 Subject: [PATCH 2/4] Collect Field Errors into a Form Error --- pilo/fields.py | 75 +++++++++++++----------------- tests/test_fields.py | 107 +++++++++++++++++-------------------------- 2 files changed, 73 insertions(+), 109 deletions(-) diff --git a/pilo/fields.py b/pilo/fields.py index 83cf5da..52a0742 100644 --- a/pilo/fields.py +++ b/pilo/fields.py @@ -79,6 +79,29 @@ def field1(self, value): ] +class FormError(ValueError): + + def __init__(self, *field_errors): + self.field_errors = field_errors + super(FormError, self).__init__(self.message) + + def __str__(self): + return self.message + + def __repr__(self): + return 'FormError({0})'.format( + ', '.join(exc.__class__.__name__ for exc in self.field_errors) + ) + + @property + def message(self): + msg = '\n* '.join( + '{0}: {1}'.format(exc.__class__.__name__, str(exc)) + for exc in self.field_errors + ) + return '\n* {0}\n'.format(msg) + + class FieldError(ValueError): def __init__(self, message, field): @@ -118,9 +141,12 @@ def invalid(self, violation): class RaiseErrors(list, Errors): - def __call__(self, *ex): - self.extend(ex) - raise MultipleExceptions(*ex) + def __call__(self, *excs): + self.extend(excs) + field_errors = [e for e in self if isinstance(e, FieldError)] + if field_errors: + raise FormError(*field_errors) + raise self[0] class CollectErrors(list, Errors): @@ -129,43 +155,6 @@ def __call__(self, *ex): self.extend(ex) -class MultipleExceptions(Exception): - - def __new__(cls, *exceptions): - exception_classes = list(set( - exc.__class__ for exc in exceptions - )) - bases = tuple([cls] + sorted( - exception_classes, - key=lambda c: len(c.mro()), reverse=True - )) - cls = type(cls.__name__, bases, dict(cls.__dict__)) - instance = Exception.__new__(cls) - instance.__init__(*exceptions) - return instance - - def __init__(self, *exceptions): - if len(exceptions) == 1: - raise exceptions[0] - self.exceptions = exceptions - - def __str__(self): - return self.message - - def __repr__(self): - return 'MultipleExceptions({0})'.format( - ', '.join(exc.__class__.__name__ for exc in self.exceptions) - ) - - @property - def message(self): - msg = '\n* '.join( - '{0}: {1}'.format(exc.__class__.__name__, str(exc)) - for exc in self.exceptions - ) - return '\n\n* {0}'.format(msg) - - class CreatedCountMixin(object): """ Mixin used to add a `._count` instance value that can use used to sort @@ -174,7 +163,7 @@ class CreatedCountMixin(object): _created_count = 0 - def __init__(self): + def __init__(self): CreatedCountMixin._created_count += 1 self._count = CreatedCountMixin._created_count @@ -1578,7 +1567,7 @@ def __init__(self, *args, **kwargs): if src: errors = self.map(src) if errors: - raise MultipleExceptions(*errors) + RaiseErrors()(*errors) def _map_source(self, obj): return DefaultSource(obj) @@ -1675,7 +1664,7 @@ def map(self, src=None, tags=None, reset=False, unmapped='ignore', error='collec if error == 'collect': return errors if errors: - raise MultipleExceptions(*errors) + RaiseErrors()(*errors) return self def has(self, field): diff --git a/tests/test_fields.py b/tests/test_fields.py index b7ba121..96ca802 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -376,32 +376,10 @@ def send_to_zoo(self): obj = Animal.type.cast(desc)(desc) self.assertIsInstance(obj, cls) - def test_exceptions(self): - - class Image(pilo.Field): - - supported_encodings = ['base64'] - supported_formats = ['png'] - - def __init__(self, *args, **kwargs): - self.encoding = kwargs.pop('encoding', None) - self.format = kwargs.pop('format', None) - super(Image, self).__init__(*args, **kwargs) - def _parse(self, path): - return path.value.decode(self.encoding) +class TestFormExceptions(TestCase): - def _validate(self, value): - predicate_by_format = dict( - png=lambda value: value[:8] == '\211PNG\r\n\032\n' - ) - predicate = predicate_by_format.get(self.format) - if predicate and predicate(value): - return True - self.ctx.errors.invalid( - 'Image must be formatted as {0}'.format(self.format) - ) - return False + def test_exceptions(self): class DatingProfile(pilo.Form): @@ -414,9 +392,8 @@ class DatingProfile(pilo.Form): gender = String(choices=genders) sexual_preferences = List(String(choices=genders)) likes = List(String()) - picture = Image(format='png', encoding='base64') - profile_params_with_one_error = dict( + profile_with_one_error = dict( name='William Henry Cavendish III', email='whc@example.org', postal_code='9021', # Invalid length @@ -424,58 +401,56 @@ class DatingProfile(pilo.Form): gender='male', sexual_preferences=['female', 'neutral'], likes=['croquet', 'muesli', 'ruses', 'umbrellas', 'wenches'], - picture='iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAA1UlEQ' - 'VRYR+1XQRKAIAjUr/SbXtxv\n+kqNBxsjcCFsGmfoqsC6C5vmlNKRf' - 'vxyAEAMHPvqEigvWzeelaAtihIgdCjXA0AJ8BaVQHG5bwC+\nLF5B0' - 'RoXAG3x3r43OQLAjYGiE2pArwR1KmodcxOOANAeMgCYGUDOh9ZFHy' - 'iBtEFQMus6l19txVqT\nqQfhpglasTXY4vlS7rkY0BqVtM8lQdukXA' - 'H033dLQPWmNyVk4cMBWEdwLgCaZrMyIJlc91JKddXe\nkKU4rk+6D5' - 'M3jUanBbEZL6Ng4HcGTmCz+wGYWb2FAAAAAElFTkSuQmCC\n' ) - profile_params_with_two_errors = dict( + profile_with_two_errors = dict( name='William Henry Cavendish III', email='whc@example.org', - postal_code='9021', # Invalid length + postal_code='9021', # Invalid postal code blurb='I am a test fixture', gender='male', sexual_preferences=['female', 'neutral'], - likes=['croquet', 'muesli', 'ruses', 'umbrellas', 'wenches'], - picture='PHN2ZyBoZWlnaHQ9IjEwMCIgd2lkdGg9IjEwMCI+CiAgPGNpcmNsZ' - 'SBjeD0iNTAiIGN5PSI1MCIg\ncj0iNDAiIHN0cm9rZT0iYmxhY2siI' - 'HN0cm9rZS13aWR0aD0iMyIgZmlsbD0icmVkIiAvPgogIFNv\ncnJ5L' - 'CB5b3VyIGJyb3dzZXIgZG9lcyBub3Qgc3VwcG9ydCBpbmxpbmUgU1' - 'ZHLiAgCjwvc3ZnPiA=\n' # SVG Image + # Likes parameter missing ) - profile_params_with_three_errors = dict( + profile_with_three_errors = dict( name='William Henry Cavendish III', email='whc@example.org', postal_code='9021', # Invalid length blurb='I am a test fixture', gender='male', - sexual_preferences=['aliens'], - likes=['croquet', 'muesli', 'ruses', 'umbrellas', 'wenches'], - # No picture + sexual_preferences=['alien'], # Invalid preference + # likes is missing ) - with self.assertRaises(pilo.fields.Invalid): - DatingProfile(**profile_params_with_one_error) - with self.assertRaises(pilo.fields.FieldError): - DatingProfile(**profile_params_with_two_errors) + with self.assertRaises(pilo.fields.FormError) as ctx: + DatingProfile(profile_with_one_error) - with self.assertRaises(pilo.fields.Invalid): - DatingProfile(**profile_params_with_two_errors) - - with self.assertRaises(pilo.fields.MultipleExceptions): - DatingProfile(**profile_params_with_two_errors) - - with self.assertRaises(pilo.fields.MultipleExceptions): - DatingProfile(**profile_params_with_three_errors) - - with self.assertRaises(pilo.fields.Invalid): - DatingProfile(**profile_params_with_three_errors) - - with self.assertRaises(pilo.fields.Missing): - DatingProfile(**profile_params_with_three_errors) - - with self.assertRaises(pilo.fields.FieldError): - DatingProfile(**profile_params_with_three_errors) + self.assertEquals( + ctx.exception.message, + '\n' + '* Invalid: postal_code - "9021" must have length >= 5' + '\n' + ) + with self.assertRaises(pilo.fields.FormError) as ctx: + DatingProfile(profile_with_two_errors) + + self.assertEquals( + ctx.exception.message, + '\n' + '* Invalid: postal_code - "9021" must have length >= 5' + '\n' + '* Missing: likes - missing' + '\n' + ) + with self.assertRaises(pilo.fields.FormError) as ctx: + DatingProfile(profile_with_three_errors) + + self.assertEquals( + ctx.exception.message, + '\n' + '* Invalid: postal_code - "9021" must have length >= 5' + '\n' + '* Invalid: sexual_preferences[0] - "alien" is not one of "male", "female", "neutral"' + '\n' + '* Missing: likes - missing' + '\n' + ) From 829cf6f609028bbe65b2c3f1805eaa3daa430055 Mon Sep 17 00:00:00 2001 From: patrick cieplak Date: Sat, 20 Jun 2015 15:24:00 -0700 Subject: [PATCH 3/4] pep8 --- tests/test_fields.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 96ca802..af73fd0 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -449,7 +449,8 @@ class DatingProfile(pilo.Form): '\n' '* Invalid: postal_code - "9021" must have length >= 5' '\n' - '* Invalid: sexual_preferences[0] - "alien" is not one of "male", "female", "neutral"' + '* Invalid: sexual_preferences[0] - "alien" is not one of "male", ' + '"female", "neutral"' '\n' '* Missing: likes - missing' '\n' From a40a0c71ff2629600c0a6fca8bbe0203b2ae9a37 Mon Sep 17 00:00:00 2001 From: patrick cieplak Date: Sat, 20 Jun 2015 15:36:54 -0700 Subject: [PATCH 4/4] add a test that includes a nested form --- tests/test_fields.py | 62 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index af73fd0..1c11081 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -3,7 +3,7 @@ import re import pilo -from pilo.fields import Integer, List, String +from pilo.fields import Integer, List, String, SubForm from tests import TestCase @@ -455,3 +455,63 @@ class DatingProfile(pilo.Form): '* Missing: likes - missing' '\n' ) + + def test_exceptions_in_nested_forms(self): + + class DatingProfile(pilo.Form): + + genders = ['male', 'female', 'neutral'] + + name = String() + email = String() + postal_code = String(length=5) + blurb = String(max_length=100) + gender = String(choices=genders) + sexual_preferences = List(String(choices=genders)) + likes = List(String()) + + class Matches(pilo.Form): + + similarity = Integer() + candidates = List(SubForm(DatingProfile)) + + with self.assertRaises(pilo.fields.FormError) as ctx: + Matches( + similarity="Not an integer", + candidates=[ + dict( + name='William Henry Cavendish III', + email='whc@example.org', + postal_code='9021', # Invalid postal code + blurb='I am a test fixture', + gender='male', + sexual_preferences=['female', 'neutral'], + # Likes parameter missing + ), + dict(), + ] + ) + self.assertEquals( + ctx.exception.message, + '\n' + '* Invalid: similarity - "Not an integer" is not an integer' + '\n' + '* Invalid: candidates[0].postal_code - "9021" must have length >= 5' + '\n' + '* Missing: candidates[0].likes - missing' + '\n' + '* Missing: candidates[1].name - missing' + '\n' + '* Missing: candidates[1].email - missing' + '\n' + '* Missing: candidates[1].postal_code - missing' + '\n' + '* Missing: candidates[1].blurb - missing' + '\n' + '* Missing: candidates[1].gender - missing' + '\n' + '* Missing: candidates[1].sexual_preferences - missing' + '\n' + '* Missing: candidates[1].likes - missing' + '\n' + )