Repository: django (difficulty: hard)

Issue:
ValidationError (django.core.exceptions.ValidationError) instances with identical messages do not compare equal, which is counter-intuitive and complicates testing. Add __eq__ and __hash__ so two ValidationErrors constructed identically are equal, independent of the ordering of params, dict fields, or the order errors were raised in a list/non-field-errors. Errors differing in message, code, params, or structure (scalar vs list vs dict) must remain unequal.

Source files:
exceptions.py: """
Self-contained extract of django.core.exceptions.ValidationError
(from django/django @ 16218c20606d8cd89c5393970c83da04598a3e04).

The `make_hashable` / `is_iterable` helpers are inlined verbatim from
django.utils.hashable and django.utils.itercompat so the module runs in an
empty directory with plain pytest. This is the BUGGY revision: ValidationError
has no __eq__/__hash__, so two identically-constructed errors are unequal.
"""


def is_iterable(x):
    "An implementation independent way of checking for iterables"
    try:
        iter(x)
    except TypeError:
        return False
    else:
        return True


def make_hashable(value):
    if isinstance(value, dict):
        return tuple([
            (key, make_hashable(nested_value))
            for key, nested_value in value.items()
        ])
    # Try hash to avoid converting a hashable iterable (e.g. string, frozenset)
    # to a tuple.
    try:
        hash(value)
    except TypeError:
        if is_iterable(value):
            return tuple(map(make_hashable, value))
        # Non-hashable, non-iterable.
        raise
    return value


NON_FIELD_ERRORS = '__all__'


class ValidationError(Exception):
    """An error while validating data."""
    def __init__(self, message, code=None, params=None):
        """
        The `message` argument can be a single error, a list of errors, or a
        dictionary that maps field names to lists of errors. What we define as
        an "error" can be either a simple string or an instance of
        ValidationError with its message attribute set, and what we define as
        list or dictionary can be an actual `list` or `dict` or an instance
        of ValidationError with its `error_list` or `error_dict` attribute set.
        """
        super().__init__(message, code, params)

        if isinstance(message, ValidationError):
            if hasattr(message, 'error_dict'):
                message = message.error_dict
            elif not hasattr(message, 'message'):
                message = message.error_list
            else:
                message, code, params = message.message, message.code, message.params

        if isinstance(message, dict):
            self.error_dict = {}
            for field, messages in message.items():
                if not isinstance(messages, ValidationError):
                    messages = ValidationError(messages)
                self.error_dict[field] = messages.error_list

        elif isinstance(message, list):
            self.error_list = []
            for message in message:
                # Normalize plain strings to instances of ValidationError.
                if not isinstance(message, ValidationError):
                    message = ValidationError(message)
                if hasattr(message, 'error_dict'):
                    self.error_list.extend(sum(message.error_dict.values(), []))
                else:
                    self.error_list.extend(message.error_list)

        else:
            self.message = message
            self.code = code
            self.params = params
            self.error_list = [self]

    @property
    def message_dict(self):
        # Trigger an AttributeError if this ValidationError
        # doesn't have an error_dict.
        getattr(self, 'error_dict')

        return dict(self)

    @property
    def messages(self):
        if hasattr(self, 'error_dict'):
            return sum(dict(self).values(), [])
        return list(self)

    def update_error_dict(self, error_dict):
        if hasattr(self, 'error_dict'):
            for field, error_list in self.error_dict.items():
                error_dict.setdefault(field, []).extend(error_list)
        else:
            error_dict.setdefault(NON_FIELD_ERRORS, []).extend(self.error_list)
        return error_dict

    def __iter__(self):
        if hasattr(self, 'error_dict'):
            for field, errors in self.error_dict.items():
                yield field, list(ValidationError(errors))
        else:
            for error in self.error_list:
                message = error.message
                if error.params:
                    message %= error.params
                yield str(message)

    def __str__(self):
        if hasattr(self, 'error_dict'):
            return repr(dict(self))
        return repr(list(self))

    def __repr__(self):
        return 'ValidationError(%s)' % self


Test files:
test_exceptions.py: import unittest
from unittest import mock

from exceptions import ValidationError


class TestValidationError(unittest.TestCase):
    def test_messages_concatenates_error_dict_values(self):
        message_dict = {}
        exception = ValidationError(message_dict)
        self.assertEqual(sorted(exception.messages), [])
        message_dict['field1'] = ['E1', 'E2']
        exception = ValidationError(message_dict)
        self.assertEqual(sorted(exception.messages), ['E1', 'E2'])
        message_dict['field2'] = ['E3', 'E4']
        exception = ValidationError(message_dict)
        self.assertEqual(sorted(exception.messages), ['E1', 'E2', 'E3', 'E4'])

    def test_eq(self):
        error1 = ValidationError('message')
        error2 = ValidationError('message', code='my_code1')
        error3 = ValidationError('message', code='my_code2')
        error4 = ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm1': 'val1', 'parm2': 'val2'},
        )
        error5 = ValidationError({'field1': 'message', 'field2': 'other'})
        error6 = ValidationError({'field1': 'message'})
        error7 = ValidationError([
            ValidationError({'field1': 'field error', 'field2': 'other'}),
            'message',
        ])

        self.assertEqual(error1, ValidationError('message'))
        self.assertNotEqual(error1, ValidationError('message2'))
        self.assertNotEqual(error1, error2)
        self.assertNotEqual(error1, error4)
        self.assertNotEqual(error1, error5)
        self.assertNotEqual(error1, error6)
        self.assertNotEqual(error1, error7)
        self.assertEqual(error1, mock.ANY)
        self.assertEqual(error2, ValidationError('message', code='my_code1'))
        self.assertNotEqual(error2, ValidationError('other', code='my_code1'))
        self.assertNotEqual(error2, error3)
        self.assertNotEqual(error2, error4)
        self.assertNotEqual(error2, error5)
        self.assertNotEqual(error2, error6)
        self.assertNotEqual(error2, error7)

        self.assertEqual(error4, ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm1': 'val1', 'parm2': 'val2'},
        ))
        self.assertNotEqual(error4, ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code2',
            params={'parm1': 'val1', 'parm2': 'val2'},
        ))
        self.assertNotEqual(error4, ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm2': 'val2'},
        ))
        self.assertNotEqual(error4, ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm2': 'val1', 'parm1': 'val2'},
        ))
        self.assertNotEqual(error4, ValidationError(
            'error val1 val2',
            code='my_code1',
        ))
        # params ordering is ignored.
        self.assertEqual(error4, ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm2': 'val2', 'parm1': 'val1'},
        ))

        self.assertEqual(
            error5,
            ValidationError({'field1': 'message', 'field2': 'other'}),
        )
        self.assertNotEqual(
            error5,
            ValidationError({'field1': 'message', 'field2': 'other2'}),
        )
        self.assertNotEqual(
            error5,
            ValidationError({'field1': 'message', 'field3': 'other'}),
        )
        self.assertNotEqual(error5, error6)
        # fields ordering is ignored.
        self.assertEqual(
            error5,
            ValidationError({'field2': 'other', 'field1': 'message'}),
        )

        self.assertNotEqual(error7, ValidationError(error7.error_list[1:]))
        self.assertNotEqual(
            ValidationError(['message']),
            ValidationError([ValidationError('message', code='my_code')]),
        )
        # messages ordering is ignored.
        self.assertEqual(
            error7,
            ValidationError(list(reversed(error7.error_list))),
        )

        self.assertNotEqual(error4, ValidationError([error4]))
        self.assertNotEqual(ValidationError([error4]), error4)
        self.assertNotEqual(error4, ValidationError({'field1': error4}))
        self.assertNotEqual(ValidationError({'field1': error4}), error4)

    def test_eq_nested(self):
        error_dict = {
            'field1': ValidationError(
                'error %(parm1)s %(parm2)s',
                code='my_code',
                params={'parm1': 'val1', 'parm2': 'val2'},
            ),
            'field2': 'other',
        }
        error = ValidationError(error_dict)
        self.assertEqual(error, ValidationError(dict(error_dict)))
        self.assertEqual(error, ValidationError({
            'field1': ValidationError(
                'error %(parm1)s %(parm2)s',
                code='my_code',
                params={'parm2': 'val2', 'parm1': 'val1'},
            ),
            'field2': 'other',
        }))
        self.assertNotEqual(error, ValidationError(
            {**error_dict, 'field2': 'message'},
        ))
        self.assertNotEqual(error, ValidationError({
            'field1': ValidationError(
                'error %(parm1)s val2',
                code='my_code',
                params={'parm1': 'val1'},
            ),
            'field2': 'other',
        }))

    def test_hash(self):
        error1 = ValidationError('message')
        error2 = ValidationError('message', code='my_code1')
        error3 = ValidationError('message', code='my_code2')
        error4 = ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm1': 'val1', 'parm2': 'val2'},
        )
        error5 = ValidationError({'field1': 'message', 'field2': 'other'})
        error6 = ValidationError({'field1': 'message'})
        error7 = ValidationError([
            ValidationError({'field1': 'field error', 'field2': 'other'}),
            'message',
        ])

        self.assertEqual(hash(error1), hash(ValidationError('message')))
        self.assertNotEqual(hash(error1), hash(ValidationError('message2')))
        self.assertNotEqual(hash(error1), hash(error2))
        self.assertNotEqual(hash(error1), hash(error4))
        self.assertNotEqual(hash(error1), hash(error5))
        self.assertNotEqual(hash(error1), hash(error6))
        self.assertNotEqual(hash(error1), hash(error7))
        self.assertEqual(
            hash(error2),
            hash(ValidationError('message', code='my_code1')),
        )
        self.assertNotEqual(
            hash(error2),
            hash(ValidationError('other', code='my_code1')),
        )
        self.assertNotEqual(hash(error2), hash(error3))
        self.assertNotEqual(hash(error2), hash(error4))
        self.assertNotEqual(hash(error2), hash(error5))
        self.assertNotEqual(hash(error2), hash(error6))
        self.assertNotEqual(hash(error2), hash(error7))

        self.assertEqual(hash(error4), hash(ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm1': 'val1', 'parm2': 'val2'},
        )))
        self.assertNotEqual(hash(error4), hash(ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code2',
            params={'parm1': 'val1', 'parm2': 'val2'},
        )))
        self.assertNotEqual(hash(error4), hash(ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm2': 'val2'},
        )))
        self.assertNotEqual(hash(error4), hash(ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm2': 'val1', 'parm1': 'val2'},
        )))
        self.assertNotEqual(hash(error4), hash(ValidationError(
            'error val1 val2',
            code='my_code1',
        )))
        # params ordering is ignored.
        self.assertEqual(hash(error4), hash(ValidationError(
            'error %(parm1)s %(parm2)s',
            code='my_code1',
            params={'parm2': 'val2', 'parm1': 'val1'},
        )))

        self.assertEqual(
            hash(error5),
            hash(ValidationError({'field1': 'message', 'field2': 'other'})),
        )
        self.assertNotEqual(
            hash(error5),
            hash(ValidationError({'field1': 'message', 'field2': 'other2'})),
        )
        self.assertNotEqual(
            hash(error5),
            hash(ValidationError({'field1': 'message', 'field3': 'other'})),
        )
        self.assertNotEqual(error5, error6)
        # fields ordering is ignored.
        self.assertEqual(
            hash(error5),
            hash(ValidationError({'field2': 'other', 'field1': 'message'})),
        )

        self.assertNotEqual(
            hash(error7),
            hash(ValidationError(error7.error_list[1:])),
        )
        self.assertNotEqual(
            hash(ValidationError(['message'])),
            hash(ValidationError([ValidationError('message', code='my_code')])),
        )
        # messages ordering is ignored.
        self.assertEqual(
            hash(error7),
            hash(ValidationError(list(reversed(error7.error_list)))),
        )

        self.assertNotEqual(hash(error4), hash(ValidationError([error4])))
        self.assertNotEqual(hash(ValidationError([error4])), hash(error4))
        self.assertNotEqual(
            hash(error4),
            hash(ValidationError({'field1': error4})),
        )

    def test_hash_nested(self):
        error_dict = {
            'field1': ValidationError(
                'error %(parm1)s %(parm2)s',
                code='my_code',
                params={'parm2': 'val2', 'parm1': 'val1'},
            ),
            'field2': 'other',
        }
        error = ValidationError(error_dict)
        self.assertEqual(hash(error), hash(ValidationError(dict(error_dict))))
        self.assertEqual(hash(error), hash(ValidationError({
            'field1': ValidationError(
                'error %(parm1)s %(parm2)s',
                code='my_code',
                params={'parm1': 'val1', 'parm2': 'val2'},
            ),
            'field2': 'other',
        })))
        self.assertNotEqual(hash(error), hash(ValidationError(
            {**error_dict, 'field2': 'message'},
        )))
        self.assertNotEqual(hash(error), hash(ValidationError({
            'field1': ValidationError(
                'error %(parm1)s val2',
                code='my_code',
                params={'parm1': 'val1'},
            ),
            'field2': 'other',
        })))


Patch:
--- a/exceptions.py
+++ b/exceptions.py
@@ -4,9 +4,11 @@
 
 The `make_hashable` / `is_iterable` helpers are inlined verbatim from
 django.utils.hashable and django.utils.itercompat so the module runs in an
-empty directory with plain pytest. This is the BUGGY revision: ValidationError
-has no __eq__/__hash__, so two identically-constructed errors are unequal.
+empty directory with plain pytest. This revision carries the upstream fix:
+ValidationError defines __eq__/__hash__ so identically-constructed errors compare equal.
 """
+
+import operator
 
 
 def is_iterable(x):
@@ -23,7 +25,7 @@
     if isinstance(value, dict):
         return tuple([
             (key, make_hashable(nested_value))
-            for key, nested_value in value.items()
+            for key, nested_value in sorted(value.items())
         ])
     # Try hash to avoid converting a hashable iterable (e.g. string, frozenset)
     # to a tuple.
@@ -85,6 +87,18 @@
             self.params = params
             self.error_list = [self]
 
+    def __eq__(self, other):
+        if not isinstance(other, ValidationError):
+            return NotImplemented
+        return hash(self) == hash(other)
+
+    def __hash__(self):
+        if hasattr(self, 'message'):
+            return hash((self.message, self.code, make_hashable(self.params)))
+        if hasattr(self, 'error_dict'):
+            return hash(make_hashable(self.error_dict))
+        return hash(tuple(sorted(self.error_list, key=operator.attrgetter('message'))))
+
     @property
     def message_dict(self):
         # Trigger an AttributeError if this ValidationError