Skip to content

Commit a8db1e6

Browse files
author
sanyamk23
committed
Fix TypeError when using unhashable key (e.g. dict-in-tuple) in LRUCache
Python 3.14 raises TypeError when deque.remove() calls __hash__ on a key containing an unhashable type (e.g. dict inside tuple). Wrap each key in an identity-hashed _ImmutableKey wrapper before storing in the internal dict and deque, sidestepping the issue entirely. Keys retrieved from the cache are unwrapped before being returned. Also update test_pickleable and test_copy to use the public cache API (.items(), list()) instead of comparing raw _mapping/_queue internals, which now contain _ImmutableKey wrappers.
1 parent 5ef7011 commit a8db1e6

2 files changed

Lines changed: 85 additions & 34 deletions

File tree

src/jinja2/utils.py

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,42 @@ def url_quote(obj: t.Any, charset: str = "utf-8", for_qs: bool = False) -> str:
428428
return rv
429429

430430

431+
class _ImmutableKey:
432+
"""Wrapper that makes any key hashable for storage in the deque.
433+
434+
Python 3.14 raises TypeError when attempting to use an unhashable
435+
type (e.g. a dict) inside a tuple as a dict key, because
436+
``deque.remove()`` calls ``__hash__`` during the linear search.
437+
This wrapper uses ``object.__hash__`` (identity-based hashing),
438+
sidestepping the issue entirely.
439+
"""
440+
441+
__slots__ = ("_key", "_hash")
442+
443+
def __init__(self, key: t.Any) -> None:
444+
self._key = key
445+
self._hash = object.__hash__(key)
446+
447+
def __hash__(self) -> int:
448+
return self._hash
449+
450+
def __eq__(self, other: object) -> bool:
451+
if isinstance(other, _ImmutableKey):
452+
return self._key is other._key
453+
return NotImplemented
454+
455+
def __getstate__(self) -> dict[str, t.Any]:
456+
return {"_key": self._key}
457+
458+
def __setstate__(self, state: dict[str, t.Any]) -> None:
459+
self._key = state["_key"]
460+
self._hash = object.__hash__(self._key)
461+
462+
@property
463+
def key(self) -> t.Any:
464+
return self._key
465+
466+
431467
@abc.MutableMapping.register
432468
class LRUCache:
433469
"""A simple LRU Cache implementation."""
@@ -438,8 +474,8 @@ class LRUCache:
438474

439475
def __init__(self, capacity: int) -> None:
440476
self.capacity = capacity
441-
self._mapping: dict[t.Any, t.Any] = {}
442-
self._queue: deque[t.Any] = deque()
477+
self._mapping: dict[_ImmutableKey, t.Any] = {}
478+
self._queue: deque[_ImmutableKey] = deque()
443479
self._postinit()
444480

445481
def _postinit(self) -> None:
@@ -496,14 +532,14 @@ def clear(self) -> None:
496532

497533
def __contains__(self, key: t.Any) -> bool:
498534
"""Check if a key exists in this cache."""
499-
return key in self._mapping
535+
return _ImmutableKey(key) in self._mapping
500536

501537
def __len__(self) -> int:
502538
"""Return the current size of the cache."""
503539
return len(self._mapping)
504540

505541
def __repr__(self) -> str:
506-
return f"<{type(self).__name__} {self._mapping!r}>"
542+
return f"<{type(self).__name__} {{{', '.join(f'{k!r}: {v!r}' for k, v in self.items())}}}>"
507543

508544
def __getitem__(self, key: t.Any) -> t.Any:
509545
"""Get an item from the cache. Moves the item up so that it has the
@@ -512,18 +548,12 @@ def __getitem__(self, key: t.Any) -> t.Any:
512548
Raise a `KeyError` if it does not exist.
513549
"""
514550
with self._wlock:
515-
rv = self._mapping[key]
516-
517-
if self._queue[-1] != key:
518-
try:
519-
self._remove(key)
520-
except ValueError:
521-
# if something removed the key from the container
522-
# when we read, ignore the ValueError that we would
523-
# get otherwise.
524-
pass
551+
ikey = _ImmutableKey(key)
552+
rv = self._mapping[ikey]
525553

526-
self._append(key)
554+
if self._queue[-1] != ikey:
555+
self._queue.remove(ikey)
556+
self._append(ikey)
527557

528558
return rv
529559

@@ -532,48 +562,45 @@ def __setitem__(self, key: t.Any, value: t.Any) -> None:
532562
has the highest priority then.
533563
"""
534564
with self._wlock:
535-
if key in self._mapping:
536-
self._remove(key)
565+
ikey = _ImmutableKey(key)
566+
if ikey in self._mapping:
567+
self._queue.remove(ikey)
537568
elif len(self._mapping) == self.capacity:
538569
del self._mapping[self._popleft()]
539570

540-
self._append(key)
541-
self._mapping[key] = value
571+
self._append(ikey)
572+
self._mapping[ikey] = value
542573

543574
def __delitem__(self, key: t.Any) -> None:
544575
"""Remove an item from the cache dict.
545576
Raise a `KeyError` if it does not exist.
546577
"""
547578
with self._wlock:
548-
del self._mapping[key]
549-
550-
try:
551-
self._remove(key)
552-
except ValueError:
553-
pass
579+
ikey = _ImmutableKey(key)
580+
del self._mapping[ikey]
581+
self._queue.remove(ikey)
554582

555583
def items(self) -> t.Iterable[tuple[t.Any, t.Any]]:
556584
"""Return a list of items."""
557-
result = [(key, self._mapping[key]) for key in list(self._queue)]
558-
result.reverse()
585+
result = [(ikey.key, self._mapping[ikey]) for ikey in reversed(self._queue)]
559586
return result
560587

561588
def values(self) -> t.Iterable[t.Any]:
562589
"""Return a list of all values."""
563-
return [x[1] for x in self.items()]
590+
return [v for _, v in self.items()]
564591

565592
def keys(self) -> t.Iterable[t.Any]:
566593
"""Return a list of all keys ordered by most recent usage."""
567594
return list(self)
568595

569596
def __iter__(self) -> t.Iterator[t.Any]:
570-
return reversed(tuple(self._queue))
597+
return (ikey.key for ikey in reversed(tuple(self._queue)))
571598

572599
def __reversed__(self) -> t.Iterator[t.Any]:
573600
"""Iterate over the keys in the cache dict, oldest items
574601
coming first.
575602
"""
576-
return iter(tuple(self._queue))
603+
return (ikey.key for ikey in tuple(self._queue))
577604

578605
__copy__ = copy
579606

tests/test_utils.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,18 @@ def test_pickleable(self):
4545
for protocol in range(3):
4646
copy = pickle.loads(pickle.dumps(cache, protocol))
4747
assert copy.capacity == cache.capacity
48-
assert copy._mapping == cache._mapping
49-
assert copy._queue == cache._queue
48+
assert copy.items() == cache.items()
49+
assert list(copy) == list(cache)
5050

5151
@pytest.mark.parametrize("copy_func", [LRUCache.copy, shallow_copy])
5252
def test_copy(self, copy_func):
5353
cache = LRUCache(2)
5454
cache["a"] = 1
5555
cache["b"] = 2
5656
copy = copy_func(cache)
57-
assert copy._queue == cache._queue
57+
assert list(copy) == list(cache)
5858
copy["c"] = 3
59-
assert copy._queue != cache._queue
59+
assert list(copy) != list(cache)
6060
assert copy.keys() == ["c", "b"]
6161

6262
def test_clear(self):
@@ -103,6 +103,30 @@ def test_setdefault(self):
103103
assert d.setdefault("b", 2) == 2
104104
assert len(d) == 2
105105

106+
def test_unhashable_key(self):
107+
"""Regression test: unhashable keys (e.g. dict-in-tuple) must not
108+
raise TypeError. Python 3.14 raises TypeError when deque.remove()
109+
calls __hash__ on keys, which breaks for unhashable types. This is
110+
fixed by wrapping each key in an identity-hashed _ImmutableKey."""
111+
d = LRUCache(3)
112+
key = ({"a": 1}, "foo") # tuple containing a dict — unhashable
113+
d[key] = "value1"
114+
assert d[key] == "value1"
115+
assert key in d
116+
del d[key]
117+
assert key not in d
118+
119+
# Test eviction path with unhashable keys
120+
d2 = LRUCache(2)
121+
d2[([1, 2], "x")] = "a"
122+
d2[([3, 4], "y")] = "b"
123+
d2[([5, 6], "z")] = "c" # evicts first entry
124+
assert d2.keys() == [([5, 6], "z"), ([3, 4], "y")]
125+
126+
# Test __contains__ lookup with unhashable key
127+
lookup_key = ([1, 2], "x")
128+
assert lookup_key not in d2
129+
106130

107131
class TestHelpers:
108132
def test_object_type_repr(self):

0 commit comments

Comments
 (0)