Skip to content

Commit

Permalink
Add support for caching exceptions (#42)
Browse files Browse the repository at this point in the history
* Add support for caching exceptions.

* Add option to specify which exceptions to cache.

* Some cleanup.

* Some tidying-up.

* Follow style guide.

* Follow style guide II.

* Follow style guide III.

Co-authored-by: albinlindskog <albin@zerebra.com>
  • Loading branch information
AlbinLindskog and albinlindskog authored Dec 15, 2020
1 parent 9e0bd24 commit 9f1ef56
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 1 deletion.
38 changes: 38 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,44 @@ returning ``True``.
# won't be called more than once every 1000 seconds.
send_tax_returns(request.user)
``cache_exceptions``
~~~~~~~~~~~~~~~~~~~~

This is useful if you have a function that can raise an exception as valid
result. If the cached function raises any of specified exceptions is the
exception cached and raised as normal. Subsequent cached calls will
immediately re-raise the exception and the function will not be executed.
``cache_exceptions`` accepts an Exception or a tuple of Exceptions.


This option allows you to cache said exceptions like any other result.
Only exceptions raised from the list of classes provided as cache_exceptions
are cached, all others are propagated immediately.

.. code-block:: python
>>> from cache_memoize import cache_memoize
>>> class InvalidParameter(Exception):
... pass
>>> @cache_memoize(1000, cache_exceptions=(InvalidParameter, ))
... def run_calculations(parameter):
... # something something time consuming
... raise InvalidParameter
>>> run_calculations(1)
Traceback (most recent call last):
...
InvalidParameter
# run_calculations will now raise InvalidParameter immediately
# without running the expensive calculation
>>> run_calculations(1)
Traceback (most recent call last):
...
InvalidParameter
``cache_alias``
~~~~~~~~~~~~~~~

Expand Down
20 changes: 19 additions & 1 deletion src/cache_memoize/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def cache_memoize(
miss_callable=None,
key_generator_callable=None,
store_result=True,
cache_exceptions=(),
cache_alias=DEFAULT_CACHE_ALIAS,
):
"""Decorator for memoizing function calls where we use the
Expand All @@ -33,6 +34,11 @@ def cache_memoize(
:arg key_generator_callable: Custom cache key name generator.
:arg bool store_result: If you know the result is not important, just
that the cache blocked it from running repeatedly, set this to False.
:arg Exception cache_exceptions: Accepts an Exception or a tuple of
Exceptions. If the cached function raises any of these exceptions is the
exception cached and raised as normal. Subsequent cached calls will
immediately re-raise the exception and the function will not be executed.
this tuple will be cached, all other will be propagated.
:arg string cache_alias: The cache alias to use; defaults to 'default'.
Usage::
Expand Down Expand Up @@ -117,7 +123,14 @@ def inner(*args, **kwargs):
else:
result = cache.get(cache_key, MARKER)
if result is MARKER:
result = func(*args, **kwargs)

# If the function all raises an exception we want to cache,
# catch it, else let it propagate.
try:
result = func(*args, **kwargs)
except cache_exceptions as exception:
result = exception

if not store_result:
# Then the result isn't valuable/important to store but
# we want to store something. Just to remember that
Expand All @@ -129,6 +142,11 @@ def inner(*args, **kwargs):
miss_callable(*args, **kwargs)
elif hit_callable:
hit_callable(*args, **kwargs)

# If the result is an exception we've caught and cached, raise it
# in the end as to not change the API of the function we're caching.
if isinstance(result, Exception):
raise result
return result

def invalidate(*args, **kwargs):
Expand Down
100 changes: 100 additions & 0 deletions tests/test_cache_memoize.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,103 @@ def func_that_calls_runmeonce(*args):
thread.join()

assert len(calls_made) == 2


class TestException(Exception):
pass


class DerivedTestException(TestException):
pass


class SecondTestException(Exception):
pass


def test_dont_cache_exceptions():
calls_made = []

@cache_memoize(10, prefix="dont_cache_exceptions")
def raise_test_exception():
calls_made.append(1)
raise TestException

# Caching of exceptions i turned off. These should both call the function
# and propagate the exception.
with pytest.raises(TestException):
raise_test_exception()
with pytest.raises(TestException):
raise_test_exception()
assert len(calls_made) == 2


def test_cache_exception():
calls_made = []

@cache_memoize(10, cache_exceptions=TestException, prefix="cache_exceptions")
def raise_test_exception():
calls_made.append(1)
raise TestException

# The first call should be cached, raised and the second call should
# re-raise the cached exception without calling the cached function.
with pytest.raises(TestException):
raise_test_exception()
with pytest.raises(TestException):
raise_test_exception()
assert len(calls_made) == 1


def test_cache_exceptions():
calls_made = []

# It should be possible to specify a tuple of exceptions to cache.
@cache_memoize(
10,
cache_exceptions=(TestException, SecondTestException),
prefix="cache_exceptions",
)
def raise_test_exception():
calls_made.append(1)
raise TestException

with pytest.raises(TestException):
raise_test_exception()
with pytest.raises(TestException):
raise_test_exception()
assert len(calls_made) == 1


def test_cache_derived_exceptions():
calls_made = []

@cache_memoize(10, cache_exceptions=TestException, prefix="cache_exceptions")
def raise_test_exception():
calls_made.append(1)
raise DerivedTestException

# We're raising DerivedTestException, which is a subclass of TestException
# and should thus be cached.
with pytest.raises(DerivedTestException):
raise_test_exception()
with pytest.raises(DerivedTestException):
raise_test_exception()
assert len(calls_made) == 1


def test_dont_cache_unrelated_exceptions():
calls_made = []

@cache_memoize(10, cache_exceptions=TestException, prefix="cache_exceptions")
def raise_test_exception():
calls_made.append(1)
raise SecondTestException

# We're raising SecondTestException, which is not a subclass
# of TestException, so the calls shouldn't be cached.
with pytest.raises(SecondTestException):
raise_test_exception()
with pytest.raises(SecondTestException):
raise_test_exception()
assert len(calls_made) == 2

0 comments on commit 9f1ef56

Please sign in to comment.