Skip to content

Commit

Permalink
no cache mode
Browse files Browse the repository at this point in the history
Add no_cache config flag, defaulting to False.

When no_cache=True, pypyr will bypass the internal caches entirely.
pypyr will NOT retrieve anything from cache nor save anything to
cache while no_cache=True.

Setting this flag has no effect on items currently in cache,
but any get() operation on a cache won't even look at these
while the no_cache flag is True.

Closes #317.
  • Loading branch information
yaythomas committed Mar 13, 2023
1 parent b45488b commit b3a0393
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 25 deletions.
27 changes: 17 additions & 10 deletions pypyr/cache/backoffcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import logging
from pypyr.cache.cache import Cache
import pypyr.moduleloader
import pypyr.retries
from pypyr.retries import builtin_backoffs

logger = logging.getLogger(__name__)

Expand All @@ -18,13 +18,13 @@ class BackoffCache(Cache):
def __init__(self):
"""Initialize the cache with the built-in back-off strategies."""
super().__init__()
self._cache = pypyr.retries.builtin_backoffs.copy()
self._cache = builtin_backoffs.copy()

def clear(self):
"""Clear the cache of all objects except built-in back-offs.."""
with self._lock:
# rather than iterating & selectively removing, just reset entirely
self._cache = pypyr.retries.builtin_backoffs.copy()
self._cache = builtin_backoffs.copy()

def get_backoff(self, name):
"""Get cached backoff callable. Adds to cache if not exist.
Expand Down Expand Up @@ -62,13 +62,20 @@ def load_backoff_callable(name):
module_name, dot, attr_name = name.rpartition('.')
if not dot:
# just a bare name, no dot, means must be in globals.
# note that built-in backoffs already in cache, so this will only run
# for bare names that aren't built-in back-offs.
raise ValueError(
f"Trying to find back-off strategy '{name}'. If this is a "
"built-in back-off strategy, are you sure you got the name right?"
"\nIf you're trying to load a custom callable, name should be in "
"format 'package.module.ClassName' or 'mod.ClassName'.")
backoff_callable = builtin_backoffs.get(name)

if backoff_callable:
logger.debug("found built-in backoff callable `%s`", name)
return backoff_callable
else:
# note that built-in backoffs already in cache, so this will only
# run for bare names that aren't built-in back-offs.
raise ValueError(
f"Trying to find back-off strategy '{name}'. If this is a "
"built-in back-off strategy, are you sure you got the name "
"right?\nIf you're trying to load a custom callable, name "
"should be in format 'package.module.ClassName' or "
"'mod.ClassName'.")

backoff_module = pypyr.moduleloader.get_module(module_name)
logger.debug("retry backoff strategy module loaded: %s", backoff_module)
Expand Down
14 changes: 12 additions & 2 deletions pypyr/cache/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import logging
import threading

from pypyr.config import config

# use pypyr logger to ensure loglevel is set correctly
logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -34,19 +36,27 @@ def get(self, key, creator):
Be warned that get happens under the context of a Lock. . . so if
creator takes a long time you might well be blocking.
If config no_cache is True, bypasses cache entirely - will call
creator each time and also not save the result to cache.
Args:
key: key (unique id) of cached item
creator: callable that will create cached object if key not found
Returns:
Cached item at key or the result of creator()
"""
if config.no_cache:
logger.debug("no cache mode enabled. creating `%s` sans cache",
key)
return creator()

with self._lock:
if key in self._cache:
logger.debug("%s loading from cache", key)
logger.debug("`%s` loading from cache", key)
obj = self._cache[key]
else:
logger.debug("%s not found in cache. . . creating", key)
logger.debug("`%s` not found in cache. . . creating", key)
obj = creator()
self._cache[key] = obj

Expand Down
7 changes: 7 additions & 0 deletions pypyr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class Config():
Set by init().
vars: dict. User provided variables to write into the pypyr context.
Set by init().
no_cache: bool. Default False. Bypass all pypyr caches entirely.
platform_paths: pypyr.platform.PlatformPaths: O/S specific paths to
config files & data dirs. Set by init().
pyproject_toml: dict. The pyproject.toml file as a dict in a full.
Expand All @@ -90,6 +91,8 @@ class Config():
'default_group',
'default_success_group',
'default_failure_group',
# flags
'no_cache',
# functional
'shortcuts',
'vars'}
Expand Down Expand Up @@ -125,6 +128,10 @@ def __init__(self) -> None:
self.default_success_group = 'on_success'
self.default_failure_group = 'on_failure'

# flags
self.no_cache: bool = cast_str_to_bool(os.getenv('PYPYR_NO_CACHE',
'0'))

# functional
self.shortcuts: dict = {}
self.vars: dict = {}
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/pypyr/cache/backoffcache_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ def test_get_backoff_builtin():
assert callable_ref(789) == 123
assert callable_ref(999) == 123


@pytest.fixture
def no_cache(monkeypatch):
"""Set no cache."""
monkeypatch.setattr('pypyr.cache.cache.config.no_cache', True)


def test_get_backoff_builtin_no_cache(no_cache):
"""Load built-in backoff callable when no_cache true."""
backoff_cache = backoffcache.BackoffCache()
f = backoff_cache.get_backoff('fixed')

callable_ref = f(sleep=123, max_sleep=456)
assert callable_ref(789) == 123
assert callable_ref(999) == 123

f2 = backoff_cache.get_backoff('fixed')
callable_ref = f2(sleep=789, max_sleep=1000)
assert callable_ref(111) == 789

# didn't add anything to cache even when calling same built-in twice,
# bypassing it entirely
assert backoff_cache._cache == pypyr.retries.builtin_backoffs

# endregion BackoffCache: get_backoff

# region BackoffCache: clear
Expand Down
63 changes: 52 additions & 11 deletions tests/unit/pypyr/cache/cache_test.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
"""cache.py unit tests."""
import logging
from unittest.mock import call, MagicMock

import pytest

from pypyr.cache.cache import Cache
from tests.common.utils import patch_logger


@pytest.fixture
def no_cache(monkeypatch):
"""Set no cache."""
monkeypatch.setattr('pypyr.cache.cache.config.no_cache', True)


def test_cache_get_miss():
"""Cache get should execute creator."""
cache = Cache()
Expand All @@ -17,7 +26,7 @@ def test_cache_get_miss():
assert obj == "created obj"
creator_mock.assert_called_once_with("1")
mock_logger_debug.assert_called_once_with(
"one not found in cache. . . creating")
"`one` not found in cache. . . creating")


def test_cache_get_hit():
Expand All @@ -36,15 +45,47 @@ def test_cache_get_hit():
assert obj3 == "created obj1"

creator_mock.assert_called_once_with("1")

assert mock_logger_debug.mock_calls == [
call("one not found in cache. . . creating"),
call("one loading from cache"),
call("one loading from cache")]
call("`one` not found in cache. . . creating"),
call("`one` loading from cache"),
call("`one` loading from cache")]

obj4 = creator_mock("4")
assert obj4 == "created obj2"


def test_cache_get_hit_no_cache(no_cache):
"""Cache get with no_cache set should run creator each time."""
cache = Cache()
creator_mock = MagicMock()
creator_mock.side_effect = ["created obj1", "created obj2", "created obj3",
"created obj4"]

with patch_logger('pypyr.cache', logging.DEBUG) as mock_logger_debug:
obj1 = cache.get('one', lambda: creator_mock("1"))
obj2 = cache.get('one', lambda: creator_mock("2"))
obj3 = cache.get('one', lambda: creator_mock("3"))

assert obj1 == "created obj1"
assert obj2 == "created obj2"
assert obj3 == "created obj3"

assert creator_mock.mock_calls == [
call('1'),
call('2'),
call('3')
]

assert mock_logger_debug.mock_calls == [
call("no cache mode enabled. creating `one` sans cache"),
call("no cache mode enabled. creating `one` sans cache"),
call("no cache mode enabled. creating `one` sans cache")]

obj4 = creator_mock("4")
assert obj4 == "created obj4"


def test_cache_multiple_items_get_hit_closures():
"""Cache with multiple items work."""
cache = Cache()
Expand All @@ -66,10 +107,10 @@ def inner():
assert obj4 == 5

assert mock_logger_debug.mock_calls == [
call("one not found in cache. . . creating"),
call("two not found in cache. . . creating"),
call("three not found in cache. . . creating"),
call("one loading from cache")]
call("`one` not found in cache. . . creating"),
call("`two` not found in cache. . . creating"),
call("`three` not found in cache. . . creating"),
call("`one` loading from cache")]


def test_cache_clear():
Expand All @@ -93,9 +134,9 @@ def inner():
assert obj3 == 5

assert mock_logger_debug.mock_calls == [
call("one not found in cache. . . creating"),
call("two not found in cache. . . creating"),
call("one loading from cache")]
call("`one` not found in cache. . . creating"),
call("`two` not found in cache. . . creating"),
call("`one` loading from cache")]

obj1 = cache.get('one', closure(2, 3))
obj2 = cache.get('two', closure(4, 5))
Expand Down
36 changes: 34 additions & 2 deletions tests/unit/pypyr/config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def no_envs(monkeypatch):
monkeypatch.delenv('PYPYR_SKIP_INIT', raising=False)
monkeypatch.delenv('PYPYR_CONFIG_GLOBAL', raising=False)
monkeypatch.delenv('PYPYR_CONFIG_LOCAL', raising=False)
monkeypatch.delenv('PYPYR_NO_CACHE', raising=False)

# region default initialization

Expand All @@ -41,6 +42,8 @@ def test_config_defaults(no_envs):
assert config.pyproject_toml is None
assert config.skip_init is False

assert config.no_cache is False

assert config.vars == {}
assert config.shortcuts == {}

Expand All @@ -67,8 +70,31 @@ def test_config_with_encoding(monkeypatch, no_envs):
monkeypatch.setenv('PYPYR_ENCODING', 'arb')
monkeypatch.setenv('PYPYR_CMD_ENCODING', 'arb2')
config = Config()
config.default_encoding == 'arb'
config.default_cmd_encoding == 'arb2'
assert config.default_encoding == 'arb'
assert config.default_cmd_encoding == 'arb2'


def test_config_with_no_cache(monkeypatch, no_envs):
"""Set no cache via env variable."""
monkeypatch.setenv('PYPYR_NO_CACHE', '1')
config = Config()
assert config.no_cache is True

monkeypatch.setenv('PYPYR_NO_CACHE', 'TRUE')
config = Config()
assert config.no_cache is True

monkeypatch.setenv('PYPYR_NO_CACHE', 'true')
config = Config()
assert config.no_cache is True

monkeypatch.setenv('PYPYR_NO_CACHE', '0')
config = Config()
assert config.no_cache is False

monkeypatch.setenv('PYPYR_NO_CACHE', 'fAlse')
config = Config()
assert config.no_cache is False


def test_config_platforms(monkeypatch):
Expand Down Expand Up @@ -737,6 +763,7 @@ def test_config_default_str(no_envs):
log_date_format: '%Y-%m-%d %H:%M:%S'
log_detail_format: '%(asctime)s %(levelname)s:%(name)s:%(funcName)s: %(message)s'
log_notify_format: '%(message)s'
no_cache: false
pipelines_subdir: pipelines
shortcuts: {{}}
vars: {{}}
Expand Down Expand Up @@ -765,6 +792,7 @@ def test_config_all_str(mock_get_platform, no_envs):
b'[tool.pypyr]\n'
b'pipelines_subdir = "arb4"\n'
b'default_success_group = "dsg"\n'
b'no_cache = true\n'
b'[tool.pypyr.vars]\n'
b'a = "e"\n'
b'f4 = 4'))
Expand Down Expand Up @@ -802,6 +830,8 @@ def test_config_all_str(mock_get_platform, no_envs):

assert config.platform_paths == fake_pp

assert config.no_cache is True

mock_get_platform.assert_called_once_with('pypyr', 'config.yaml')

assert mock_files.mock_calls == [
Expand All @@ -825,6 +855,7 @@ def test_config_all_str(mock_get_platform, no_envs):
log_date_format: '%Y-%m-%d %H:%M:%S'
log_detail_format: '%(asctime)s %(levelname)s:%(name)s:%(funcName)s: %(message)s'
log_notify_format: '%(message)s'
no_cache: true
pipelines_subdir: arb5
shortcuts:
s1: one
Expand Down Expand Up @@ -858,6 +889,7 @@ def test_config_all_str(mock_get_platform, no_envs):
pypyr:
pipelines_subdir: arb4
default_success_group: dsg
no_cache: true
vars:
a: e
f4: 4
Expand Down

0 comments on commit b3a0393

Please sign in to comment.