diff --git a/README.rst b/README.rst index a0ad34d..17077cd 100644 --- a/README.rst +++ b/README.rst @@ -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`` ~~~~~~~~~~~~~~~ diff --git a/src/cache_memoize/__init__.py b/src/cache_memoize/__init__.py index 3c4ca9b..763ed81 100644 --- a/src/cache_memoize/__init__.py +++ b/src/cache_memoize/__init__.py @@ -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 @@ -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:: @@ -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 @@ -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): diff --git a/tests/test_cache_memoize.py b/tests/test_cache_memoize.py index 1b55f21..de8327b 100644 --- a/tests/test_cache_memoize.py +++ b/tests/test_cache_memoize.py @@ -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