Add FrozenDict for caching parsed_scopes dicts

Since we need them to be immutable
This commit is contained in:
Min RK
2022-03-30 13:48:26 +02:00
parent 29b73563dc
commit bb6427ea9b
3 changed files with 71 additions and 8 deletions

View File

@@ -88,3 +88,50 @@ def lru_cache_key(key_func, maxsize=1024):
return cached
return cache_func
class FrozenDict(dict):
"""A frozen dictionary subclass
Immutable and hashable, so it can be used as a cache key
Values will be frozen with `.freeze(value)`
and must be hashable after freezing.
Not rigorous, but enough for our purposes.
"""
_hash = None
def __init__(self, d):
dict_set = dict.__setitem__
for key, value in d.items():
dict.__setitem__(self, key, self._freeze(value))
def _freeze(self, item):
"""Make values of a dict hashable
- list, set -> frozenset
- dict -> recursive _FrozenDict
- anything else: assumed hashable
"""
if isinstance(item, FrozenDict):
return item
elif isinstance(item, (set, list)):
return frozenset(item)
elif isinstance(item, dict):
return FrozenDict(item)
else:
# any other type is assumed hashable
return item
def __setitem__(self, key):
raise RuntimeError("Cannot modify frozen {type(self).__name__}")
def update(self, other):
raise RuntimeError("Cannot modify frozen {type(self).__name__}")
def __hash__(self):
"""Cache hash because we are immutable"""
if self._hash is None:
self._hash = hash(tuple((key, value) for key, value in self.items()))
return self._hash

View File

@@ -24,7 +24,7 @@ from tornado import web
from tornado.log import app_log
from . import orm, roles
from ._memoize import DoNotCache, lru_cache_key
from ._memoize import DoNotCache, FrozenDict, lru_cache_key
"""when modifying the scope definitions, make sure that `docs/source/rbac/generate-scope-table.py` is run
so that changes are reflected in the documentation and REST API description."""
@@ -698,13 +698,10 @@ def parse_scopes(scope_list):
parsed_scopes[base_scope][key] = {value}
else:
parsed_scopes[base_scope][key].add(value)
return parsed_scopes
# Note: it doesn't make sense to cache unparse_scopes
# because computing the cache key is as expensive as the function itself
return FrozenDict(parsed_scopes)
@lru_cache_key(FrozenDict)
def unparse_scopes(parsed_scopes):
"""Turn a parsed_scopes dictionary back into a expanded scopes set"""
expanded_scopes = set()
@@ -722,7 +719,7 @@ def unparse_scopes(parsed_scopes):
def reduce_scopes(expanded_scopes):
"""Reduce expanded scopes to minimal set
Eliminates redundancy, such as access:services and access:services!service=x
Eliminates overlapping scopes, such as access:services and access:services!service=x
"""
return unparse_scopes(parse_scopes(expanded_scopes))

View File

@@ -1,4 +1,6 @@
from jupyterhub._memoize import DoNotCache, LRUCache, lru_cache_key
import pytest
from jupyterhub._memoize import DoNotCache, FrozenDict, LRUCache, lru_cache_key
def test_lru_cache():
@@ -73,3 +75,20 @@ def test_do_not_cache():
before = call_count
assert is_even(1) == False
assert call_count == before + 1
@pytest.mark.parametrize(
"d",
[
{"key": "value"},
{"key": ["list"]},
{"key": {"set"}},
{"key": ("tu", "ple")},
{"key": {"nested": ["dict"]}},
],
)
def test_frozen_dict(d):
frozen_1 = FrozenDict(d)
frozen_2 = FrozenDict(d)
assert hash(frozen_1) == hash(frozen_2)
assert frozen_1 == frozen_2