diff options
| -rw-r--r-- | CHANGES.rst | 10 | ||||
| -rw-r--r-- | src/pyramid/request.py | 111 | ||||
| -rw-r--r-- | tests/test_request.py | 100 |
3 files changed, 216 insertions, 5 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index 70f5bbb64..e93f8a813 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -82,6 +82,16 @@ Features invoked as part of ``pyramid.scripting.prepare`` and ``pyramid.paster.boostrap``. +- Added ``pyramid.request.RequestLocalCache`` which can be used to create + simple objects that are shared across requests and can be used to store + per-request data. This is useful when the source of data is external to + the request itself. Often a reified property is used on a request via + ``pyramid.config.Configurator.add_request_method``, or + ``pyramid.decorator.reify``, and these work great when the data is + generated on-demand when accessing the request property. However, often + the case is that the data is generated when accessing some other system + and then we want to cache the data for the duration of the request. + Deprecations ------------ diff --git a/src/pyramid/request.py b/src/pyramid/request.py index d65be2a2f..83134666d 100644 --- a/src/pyramid/request.py +++ b/src/pyramid/request.py @@ -1,4 +1,6 @@ from collections import deque +import functools +import weakref from webob import BaseRequest from zope.interface import implementer from zope.interface.interface import InterfaceClass @@ -17,6 +19,7 @@ from pyramid.url import URLMethodsMixin from pyramid.util import ( InstancePropertyHelper, InstancePropertyMixin, + Sentinel, bytes_, text_, ) @@ -330,3 +333,111 @@ def apply_request_extensions(request, extensions=None): InstancePropertyHelper.apply_properties( request, extensions.descriptors ) + + +class RequestLocalCache: + """ + A store that caches values during for the lifecycle of a request. + + Instantiate a cache object and use it to decorate methods or functions + that accept a request parameter. Any other arguments are not used as + cache keys so make sure they are constant across calls per-request. + + Wrapping methods: + + .. code-block:: python + + class SecurityPolicy: + identity_cache = RequestLocalCache() + + @identity_cache + def authenticated_identity(self, request): + result = ... # do some expensive computations + return result + + Wrapping functions: + + .. code-block:: python + + user_cache = RequestLocalCache() + + @user_cache + def get_user(request): + result = ... # do some expensive computations + return result + + It's also possible to inspect and influence the cache during runtime using + :meth:`.get`, :meth:`.set` and :meth:`.clear`. Using these methods, the + cache can be used directly as well, without using it as a decorator. + + The cache will clean release resources aggressively by utilizing + :meth:`pyramid.request.Request.add_finished_callback`, but it will also + maintain a weakref to the request and cleanup when it is garbage collected + if the callbacks are not invoked for some reason. + + .. versionadded:: 2.0 + + """ + NO_VALUE = Sentinel('NO_VALUE') + + def __init__(self): + self.store = weakref.WeakKeyDictionary() + + def auto_adapt(decorator): + class FunctionOrMethodAdapter: + def __init__(self, inst, func): + self.inst = inst + self.__wrapped__ = func + + def __call__(self, *args, **kwargs): + return decorator(self.inst, self.__wrapped__)(*args, **kwargs) + + def __get__(self, instance, owner): + return decorator(self.inst, self.__wrapped__.__get__(instance, owner)) + + def adapt(inst, fn): + return FunctionOrMethodAdapter(inst, fn) + return adapt + + @auto_adapt + def __call__(self, fn): + """ + Decorate a method or function. + + """ + @functools.wraps(fn) + def wrapper(request, *args, **kwargs): + result = self.get(request) + if result is self.NO_VALUE: + result = fn(request, *args, **kwargs) + self.set(request, result) + request.add_finished_callback(self.clear) + return result + return wrapper + + del auto_adapt + + def clear(self, request): + """ + Delete the value from the cache. + + The cached value is returned or :attr:`.NO_VALUE`. + + """ + return self.store.pop(request, self.NO_VALUE) + + def get(self, request, default=NO_VALUE): + """ + Return the value from the cache. + + The cached value is returned or ``default``. + + """ + return self.store.get(request, default) + + def set(self, request, value): + """ + Update the cache with a new value. + + """ + self.store[request] = value diff --git a/tests/test_request.py b/tests/test_request.py index bbf6aa47c..3cff4bb53 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -535,10 +535,6 @@ class Test_apply_request_extensions(unittest.TestCase): self.assertEqual(request.foo('abc'), 'abc') -class Dummy(object): - pass - - class Test_subclassing_Request(unittest.TestCase): def test_subclass(self): from pyramid.interfaces import IRequest @@ -598,14 +594,108 @@ class Test_subclassing_Request(unittest.TestCase): self.assertTrue(IRequest.implementedBy(RequestSub)) +class TestRequestLocalCache(unittest.TestCase): + def _makeOne(self): + from pyramid.request import RequestLocalCache + + return RequestLocalCache() + + def test_it_works_with_functions(self): + cache = self._makeOne() + a = [0] + + @cache + def foo(request): + a[0] += 1 + return a[0] + + req1 = DummyRequest() + req2 = DummyRequest() + self.assertEqual(foo(req1), 1) + self.assertEqual(foo(req2), 2) + self.assertEqual(foo(req1), 1) + self.assertEqual(foo(req2), 2) + self.assertEqual(len(req1.finished_callbacks), 1) + self.assertEqual(len(req2.finished_callbacks), 1) + + def test_it_works_with_methods(self): + cache = self._makeOne() + a = [0] + + class DummyPolicy: + @cache + def foo(self, request): + a[0] += 1 + return a[0] + + policy = DummyPolicy() + req1 = DummyRequest() + req2 = DummyRequest() + self.assertEqual(policy.foo(req1), 1) + self.assertEqual(policy.foo(req2), 2) + self.assertEqual(policy.foo(req1), 1) + self.assertEqual(policy.foo(req2), 2) + self.assertEqual(len(req1.finished_callbacks), 1) + self.assertEqual(len(req2.finished_callbacks), 1) + + def test_clear_works(self): + cache = self._makeOne() + a = [0] + + @cache + def foo(request): + a[0] += 1 + return a[0] + + req = DummyRequest() + self.assertEqual(foo(req), 1) + self.assertEqual(len(req.finished_callbacks), 1) + cache.clear(req) + self.assertEqual(foo(req), 2) + self.assertEqual(len(req.finished_callbacks), 2) + + def test_set_overrides_current_value(self): + cache = self._makeOne() + a = [0] + + @cache + def foo(request): + a[0] += 1 + return a[0] + + req = DummyRequest() + self.assertEqual(foo(req), 1) + self.assertEqual(len(req.finished_callbacks), 1) + cache.set(req, 8) + self.assertEqual(foo(req), 8) + self.assertEqual(len(req.finished_callbacks), 1) + self.assertEqual(cache.get(req), 8) + + def test_get_works(self): + cache = self._makeOne() + req = DummyRequest() + self.assertIs(cache.get(req), cache.NO_VALUE) + cache.set(req, 2) + self.assertIs(cache.get(req), 2) + + +class Dummy(object): + pass + + class DummyRequest(object): def __init__(self, environ=None): if environ is None: environ = {} self.environ = environ + self.response_callbacks = [] + self.finished_callbacks = [] def add_response_callback(self, callback): - self.response_callbacks = [callback] + self.response_callbacks.append(callback) + + def add_finished_callback(self, callback): + self.finished_callbacks.append(callback) def get_response(self, app): return app |
