summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Merickel <michael@merickel.org>2020-01-09 12:20:22 -0600
committerGitHub <noreply@github.com>2020-01-09 12:20:22 -0600
commit912bccb8b715b0249c2c23736c467eaee14a4e3b (patch)
tree8c8b05a388418b54d43589c042777f6bd25f7a5a
parent5702c3c3a4357a6071c9ba624a89655209548336 (diff)
parentf04c06c1de47373e51f6fb1b5dc1330b3df58299 (diff)
downloadpyramid-912bccb8b715b0249c2c23736c467eaee14a4e3b.tar.gz
pyramid-912bccb8b715b0249c2c23736c467eaee14a4e3b.tar.bz2
pyramid-912bccb8b715b0249c2c23736c467eaee14a4e3b.zip
Merge pull request #3561 from mmerickel/request-local-cache
Request local cache
-rw-r--r--CHANGES.rst16
-rw-r--r--docs/api/request.rst3
-rw-r--r--src/pyramid/paster.py6
-rw-r--r--src/pyramid/request.py134
-rw-r--r--src/pyramid/scripting.py8
-rw-r--r--tests/test_request.py116
-rw-r--r--tests/test_scripting.py24
7 files changed, 302 insertions, 5 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index 70ee43b96..8159cea36 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -78,6 +78,22 @@ Features
in testing.
See https://github.com/Pylons/pyramid/pull/3559
+- Finished callbacks are now executed as part of the ``closer`` that is
+ invoked as part of ``pyramid.scripting.prepare`` and
+ ``pyramid.paster.bootstrap``.
+ See https://github.com/Pylons/pyramid/pull/3561
+
+- 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.
+ See https://github.com/Pylons/pyramid/pull/3561
+
Deprecations
------------
diff --git a/docs/api/request.rst b/docs/api/request.rst
index 9e9c70d3a..59d85ac2a 100644
--- a/docs/api/request.rst
+++ b/docs/api/request.rst
@@ -362,3 +362,6 @@
see :class:`pyramid.interfaces.IMultiDict`.
.. autofunction:: apply_request_extensions(request)
+
+.. autoclass:: RequestLocalCache
+ :members:
diff --git a/src/pyramid/paster.py b/src/pyramid/paster.py
index 22c09e41a..00c1a8915 100644
--- a/src/pyramid/paster.py
+++ b/src/pyramid/paster.py
@@ -107,6 +107,12 @@ def bootstrap(config_uri, request=None, options=None):
Added the ability to use the return value as a context manager.
+ .. versionchanged:: 2.0
+
+ Request finished callbacks added via
+ :meth:`pyramid.request.Request.add_finished_callback` will be invoked
+ by the ``closer``.
+
"""
app = get_app(config_uri, options=options)
env = prepare(request)
diff --git a/src/pyramid/request.py b/src/pyramid/request.py
index d65be2a2f..62bd22589 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,134 @@ 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.
+
+ Wrapping Functions
+
+ Instantiate and use it to decorate functions that accept a request
+ parameter. The result is cached and returned in subsequent invocations
+ of the function.
+
+ .. code-block:: python
+
+ @RequestLocalCache()
+ def get_user(request):
+ result = ... # do some expensive computations
+ return result
+
+ value = get_user(request)
+
+ # manipulate the cache directly
+ get_user.cache.clear(request)
+
+ The cache instance is attached to the resulting function as the ``cache``
+ attribute such that the function may be used to manipulate the cache.
+
+ Wrapping Methods
+
+ A method can be used as the creator function but it needs to be bound to
+ an instance such that it only accepts one argument - the request. An easy
+ way to do this is to bind the creator in the constructor and then use
+ :meth:`.get_or_create`:
+
+ .. code-block:: python
+
+ class SecurityPolicy:
+ def __init__(self):
+ self.identity_cache = RequestLocalCache(self.load_identity)
+
+ def load_identity(self, request):
+ result = ... # do some expensive computations
+ return result
+
+ def authenticated_identity(self, request):
+ return self.identity_cache.get_or_create(request)
+
+ The cache maintains a weakref to each request and will release the cached
+ values when the request is garbage-collected. However, in most scenarios,
+ it will release resources earlier via
+ :meth:`pyramid.request.Request.add_finished_callback`.
+
+ .. versionadded:: 2.0
+
+ """
+
+ NO_VALUE = Sentinel('NO_VALUE')
+
+ def __init__(self, creator=None):
+ self._store = weakref.WeakKeyDictionary()
+ self._creator = creator
+
+ def __call__(self, fn):
+ @functools.wraps(fn)
+ def wrapper(request):
+ return wrapper.cache.get_or_create(request, fn)
+
+ wrapper.cache = self
+ self._creator = fn
+ return wrapper
+
+ def get_or_create(self, request, creator=None):
+ """
+ Return the value from the cache. Compute if necessary.
+
+ If no value is cached then execute the creator, cache the result,
+ and return it.
+
+ The creator may be passed in as an argument or bound to the cache
+ by decorating a function or supplied as a constructor argument.
+
+ """
+ result = self._store.get(request, self.NO_VALUE)
+ if result is self.NO_VALUE:
+ if creator is None:
+ creator = self._creator
+ if creator is None:
+ raise ValueError(
+ 'no creator function has been registered with the '
+ 'cache or supplied to "get_or_create"'
+ )
+ result = creator(request)
+ self.set(request, result)
+ return result
+
+ 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.
+
+ """
+ already_set = request in self._store
+ self._store[request] = value
+
+ # avoid registering the callback more than once
+ if not already_set:
+ request.add_finished_callback(self._store.pop)
+
+ def clear(self, request):
+ """
+ Delete the value from the cache.
+
+ The cached value is returned or :attr:`.NO_VALUE`.
+
+ """
+ old_value = self.NO_VALUE
+ if request in self._store:
+ old_value = self._store[request]
+
+ # keep a value in the store so that we don't register another
+ # finished callback when set is invoked
+ self._store[request] = self.NO_VALUE
+ return old_value
diff --git a/src/pyramid/scripting.py b/src/pyramid/scripting.py
index abcdd1030..bf23f1008 100644
--- a/src/pyramid/scripting.py
+++ b/src/pyramid/scripting.py
@@ -74,6 +74,12 @@ def prepare(request=None, registry=None):
Added the ability to use the return value as a context manager.
+ .. versionchanged:: 2.0
+
+ Request finished callbacks added via
+ :meth:`pyramid.request.Request.add_finished_callback` will be invoked
+ by the ``closer``.
+
"""
if registry is None:
registry = getattr(request, 'registry', global_registries.last)
@@ -94,6 +100,8 @@ def prepare(request=None, registry=None):
apply_request_extensions(request)
def closer():
+ if request.finished_callbacks:
+ request._process_finished_callbacks()
ctx.end()
root_factory = registry.queryUtility(
diff --git a/tests/test_request.py b/tests/test_request.py
index bbf6aa47c..3c5535b0e 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,124 @@ class Test_subclassing_Request(unittest.TestCase):
self.assertTrue(IRequest.implementedBy(RequestSub))
+class TestRequestLocalCache(unittest.TestCase):
+ def _makeOne(self, *args, **kwargs):
+ from pyramid.request import RequestLocalCache
+
+ return RequestLocalCache(*args, **kwargs)
+
+ def test_it_works_with_functions(self):
+ a = [0]
+
+ @self._makeOne()
+ 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_clear_works(self):
+ a = [0]
+
+ @self._makeOne()
+ def foo(request):
+ a[0] += 1
+ return a[0]
+
+ req = DummyRequest()
+ self.assertEqual(foo(req), 1)
+ self.assertEqual(len(req.finished_callbacks), 1)
+ foo.cache.clear(req)
+ self.assertEqual(foo(req), 2)
+ self.assertEqual(len(req.finished_callbacks), 1)
+
+ def test_set_overrides_current_value(self):
+ a = [0]
+
+ @self._makeOne()
+ def foo(request):
+ a[0] += 1
+ return a[0]
+
+ req = DummyRequest()
+ self.assertEqual(foo(req), 1)
+ self.assertEqual(len(req.finished_callbacks), 1)
+ foo.cache.set(req, 8)
+ self.assertEqual(foo(req), 8)
+ self.assertEqual(len(req.finished_callbacks), 1)
+ self.assertEqual(foo.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)
+
+ def test_creator_in_constructor(self):
+ def foo(request):
+ return 8
+
+ cache = self._makeOne(foo)
+ req = DummyRequest()
+ result = cache.get_or_create(req)
+ self.assertEqual(result, 8)
+
+ def test_decorator_overrides_creator(self):
+ def foo(request): # pragma: no cover
+ raise AssertionError
+
+ cache = self._makeOne(foo)
+
+ @cache
+ def bar(request):
+ return 8
+
+ req = DummyRequest()
+ result = cache.get_or_create(req)
+ self.assertEqual(result, 8)
+
+ def test_get_or_create_overrides_creator(self):
+ cache = self._makeOne()
+
+ @cache
+ def foo(request): # pragma: no cover
+ raise AssertionError
+
+ req = DummyRequest()
+ result = cache.get_or_create(req, lambda r: 8)
+ self.assertEqual(result, 8)
+
+ def test_get_or_create_with_no_creator(self):
+ cache = self._makeOne()
+ req = DummyRequest()
+ self.assertRaises(ValueError, cache.get_or_create, req)
+
+
+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
diff --git a/tests/test_scripting.py b/tests/test_scripting.py
index 8f74f35f8..b8a18f57e 100644
--- a/tests/test_scripting.py
+++ b/tests/test_scripting.py
@@ -1,3 +1,4 @@
+from collections import deque
import unittest
@@ -162,6 +163,20 @@ class Test_prepare(unittest.TestCase):
self.assertEqual(request.context, root)
self.assertEqual(request.registry, registry)
+ def test_closer_invokes_finished_callbacks(self):
+ finish_called = [False]
+
+ def finished_callback(request):
+ finish_called[0] = True
+
+ request = DummyRequest({})
+ request.registry = self._makeRegistry()
+ info = self._callFUT(request=request)
+ request.add_finished_callback(finished_callback)
+ closer = info['closer']
+ closer()
+ self.assertTrue(finish_called[0])
+
class Test__make_request(unittest.TestCase):
def _callFUT(self, path='/', registry=None):
@@ -234,6 +249,15 @@ class DummyRequest(object):
def __init__(self, environ):
self.environ = environ
+ self.finished_callbacks = deque()
+
+ def add_finished_callback(self, cb):
+ self.finished_callbacks.append(cb)
+
+ def _process_finished_callbacks(self):
+ while self.finished_callbacks:
+ cb = self.finished_callbacks.popleft()
+ cb(self)
class DummyExtensions: