diff --git a/README.rst b/README.rst index 04ce5e73..d7844188 100644 --- a/README.rst +++ b/README.rst @@ -139,6 +139,7 @@ The following parameters will only be applied to decorators defined after `set_d These parameters can be changed at any time and they will apply to all decorators: +* `allow_none` * `caching_enabled` * `stale_after` * `next_time` @@ -256,6 +257,10 @@ Verbose Cache Call You can have ``cachier`` print out a detailed explanation of the logic of a specific call by passing ``verbose_cache=True`` to the function call. This can be useful if you are not sure why a certain function result is, or is not, returned. +Cache `None` Values +~~~~~~~~~~~~~~~~~~~ + +By default, ``cachier`` does not cache ``None`` values. You can override this behaviour by passing ``allow_none=True`` to the function call. Cachier Cores diff --git a/cachier/core.py b/cachier/core.py index a07cced6..f6bd9f51 100644 --- a/cachier/core.py +++ b/cachier/core.py @@ -107,6 +107,7 @@ class Params(TypedDict): pickle_reload: bool separate_files: bool wait_for_calc_timeout: int + allow_none: bool _default_params: Params = { @@ -120,6 +121,7 @@ class Params(TypedDict): 'pickle_reload': True, 'separate_files': False, 'wait_for_calc_timeout': 0, + 'allow_none': False, } @@ -134,6 +136,7 @@ def cachier( pickle_reload: Optional[bool] = None, separate_files: Optional[bool] = None, wait_for_calc_timeout: Optional[int] = None, + allow_none: Optional[bool] = None, ): """A persistent, stale-free memoization decorator. @@ -187,6 +190,9 @@ def cachier( True, any process trying to read the same entry will wait a maximum of seconds specified in this parameter. 0 means wait forever. Once the timeout expires the calculation will be triggered. + allow_none: bool, optional + Allows storing None values in the cache. If False, functions returning + None will not be cached and are recalculated every call. """ # Check for deprecated parameters if hash_params is not None: @@ -234,6 +240,12 @@ def _cachier_decorator(func): @wraps(func) def func_wrapper(*args, **kwds): # pylint: disable=C0111,R0911 + nonlocal allow_none + _allow_none = ( + allow_none + if allow_none is not None else + _default_params['allow_none'] + ) # print('Inside general wrapper for {}.'.format(func.__name__)) ignore_cache = kwds.pop('ignore_cache', False) overwrite_cache = kwds.pop('overwrite_cache', False) @@ -251,7 +263,7 @@ def func_wrapper(*args, **kwds): # pylint: disable=C0111,R0911 return _calc_entry(core, key, func, args, kwds) if entry is not None: # pylint: disable=R0101 _print('Entry found.') - if entry.get('value', None) is not None: + if (_allow_none or entry.get('value', None) is not None): _print('Cached result found.') local_stale_after = stale_after if stale_after is not None else _default_params['stale_after'] # noqa: E501 local_next_time = next_time if next_time is not None else _default_params['next_time'] # noqa: E501 diff --git a/tests/test_core_lookup.py b/tests/test_core_lookup.py index a3fcb585..5e288e1a 100644 --- a/tests/test_core_lookup.py +++ b/tests/test_core_lookup.py @@ -7,6 +7,7 @@ def test_get_default_params(): params = get_default_params() assert tuple(sorted(params)) == ( + 'allow_none', 'backend', 'cache_dir', 'caching_enabled', diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 407654af..68674440 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -1,16 +1,15 @@ import datetime import os -import pytest import queue import random import tempfile import threading import time -import cachier - -from tests.test_mongo_core import _test_mongetter, MONGO_DELTA +import pytest +import cachier +from tests.test_mongo_core import MONGO_DELTA, _test_mongetter _default_params = cachier.get_default_params().copy() @@ -125,6 +124,40 @@ def global_test_2(arg_1, arg_2): assert len(os.listdir(cache_dir_2)) == 1 +def test_allow_none_default_param(): + cachier.set_default_params( + allow_none=True, + separate_files=True, + verbose_cache=True, + ) + allow_count = 0 + disallow_count = 0 + + @cachier.cachier(cache_dir=tempfile.mkdtemp()) + def allow_none(): + nonlocal allow_count + allow_count += 1 + return None + + @cachier.cachier(cache_dir=tempfile.mkdtemp(), allow_none=False) + def disallow_none(): + nonlocal disallow_count + disallow_count += 1 + return None + + allow_none.clear_cache() + assert allow_count == 0 + allow_none() + allow_none() + assert allow_count == 1 + + disallow_none.clear_cache() + assert disallow_count == 0 + disallow_none() + disallow_none() + assert disallow_count == 2 + + parametrize_keys = 'backend,mongetter' parametrize_values = [ pytest.param('pickle', None, marks=pytest.mark.pickle), diff --git a/tests/test_general.py b/tests/test_general.py index 283d55af..f1aa6888 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -1,6 +1,7 @@ """Non-core-specific tests for cachier.""" from __future__ import print_function + import functools import os import queue @@ -8,18 +9,20 @@ import threading from random import random from time import sleep, time + import pytest + import cachier from cachier.core import ( - MAX_WORKERS_ENVAR_NAME, DEFAULT_MAX_WORKERS, + MAX_WORKERS_ENVAR_NAME, + _get_executor, _max_workers, _set_max_workers, - _get_executor ) from tests.test_mongo_core import ( - _test_mongetter, MONGO_DELTA_LONG, + _test_mongetter, ) @@ -240,3 +243,35 @@ def get_random(): result_4 = get_random() assert result_1 == result_2 == result_4 assert result_1 != result_3 + + +def test_none_not_cached_by_default(): + count = 0 + + @cachier.cachier() + def do_operation(): + nonlocal count + count += 1 + return None + + do_operation.clear_cache() + assert count == 0 + do_operation() + do_operation() + assert count == 2 + + +def test_allow_caching_none(): + count = 0 + + @cachier.cachier(allow_none=True) + def do_operation(): + nonlocal count + count += 1 + return None + + do_operation.clear_cache() + assert count == 0 + do_operation() + do_operation() + assert count == 1