diff --git a/django/utils/hashable.py b/django/utils/hashable.py index 859ea8073e..0bef5b003d 100644 --- a/django/utils/hashable.py +++ b/django/utils/hashable.py @@ -1,9 +1,19 @@ +from django.utils.itercompat import is_iterable + + def make_hashable(value): - if isinstance(value, list): - return tuple(map(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 diff --git a/tests/utils_tests/test_hashable.py b/tests/utils_tests/test_hashable.py index 2310c4c605..b4db3ef7d7 100644 --- a/tests/utils_tests/test_hashable.py +++ b/tests/utils_tests/test_hashable.py @@ -8,9 +8,11 @@ class TestHashable(SimpleTestCase): ([], ()), (['a', 1], ('a', 1)), ({}, ()), - ({'a'}, {'a'}), + ({'a'}, ('a',)), (frozenset({'a'}), {'a'}), ({'a': 1}, (('a', 1),)), + (('a', ['b', 1]), ('a', ('b', 1))), + (('a', {'b': 1}), ('a', (('b', 1),))), ) for value, expected in tests: with self.subTest(value=value): @@ -19,7 +21,15 @@ class TestHashable(SimpleTestCase): def test_count_equal(self): tests = ( ({'a': 1, 'b': ['a', 1]}, (('a', 1), ('b', ('a', 1)))), + ({'a': 1, 'b': ('a', [1, 2])}, (('a', 1), ('b', ('a', (1, 2))))), ) for value, expected in tests: with self.subTest(value=value): self.assertCountEqual(make_hashable(value), expected) + + def test_unhashable(self): + class Unhashable: + __hash__ = None + + with self.assertRaisesMessage(TypeError, "unhashable type: 'Unhashable'"): + make_hashable(Unhashable())