shep

Multi-state key stores using bit masks for python3
git clone git://git.defalsify.org/shep.git
Log | Files | Refs | LICENSE

commit 2f7508ad6e9ac79366548d2d9d5eeca00ba9e223
parent 4fc8358e270cccc4d5f81bf0a11c55df5d3b746b
Author: lash <dev@holbrook.no>
Date:   Sat,  9 Apr 2022 17:19:48 +0000

Add redis store backend with tests

Diffstat:
MCHANGELOG | 3+++
Msetup.cfg | 2+-
Mshep/persist.py | 2++
Mshep/state.py | 2+-
Ashep/store/redis.py | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/test_file.py | 7+++++--
Atests/test_redis.py | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_verify.py | 31+++++++++++++++++++++++++++++++
8 files changed, 230 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG @@ -1,3 +1,6 @@ +- 0.2.0 + * Add redis backend + * UTC timestamp for modification time in core state - 0.1.1 * Optional, pluggable verifier to protect state transition * Change method for atomic simultaneous set and unset diff --git a/setup.cfg b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = shep -version = 0.1.1 +version = 0.2.0rc1 description = Multi-state key stores using bit masks author = Louis Holbrook author_email = dev@holbrook.no diff --git a/shep/persist.py b/shep/persist.py @@ -41,6 +41,8 @@ class PersistedState(State): self.__ensure_store(k) self.__stores[k].add(key, contents) + self.register_modify(key) + def set(self, key, or_state): """Persist a new state for a key or key/content. diff --git a/shep/state.py b/shep/state.py @@ -613,7 +613,7 @@ class State: def register_modify(self, key): - self.modified_last[key] = datetime.datetime.now().timestamp() + self.modified_last[key] = datetime.datetime.utcnow().timestamp() def mask(self, key, states=0): diff --git a/shep/store/redis.py b/shep/store/redis.py @@ -0,0 +1,94 @@ +# external imports +import redis + + +class RedisStore: + + def __init__(self, path, redis, binary=False): + self.redis = redis + self.__path = path + self.__binary = binary + + def __to_path(self, k): + return '.'.join([self.__path, k]) + + + def __from_path(self, s): + (left, right) = s.split('.', maxsplit=1) + return right + + + def __to_result(self, v): + if self.__binary: + return v + return v.decode('utf-8') + + + def add(self, k, contents=b''): + if contents == None: + contents = b'' + k = self.__to_path(k) + self.redis.set(k, contents) + + + def remove(self, k): + k = self.__to_path(k) + self.redis.delete(k) + + + def get(self, k): + k = self.__to_path(k) + v = self.redis.get(k) + return self.__to_result(v) + + + def list(self): + (cursor, matches) = self.redis.scan(match=self.__path + '.*') + + r = [] + for s in matches: + k = self.__from_path(s) + v = self.redis.get(v) + r.append((k, v,)) + + return r + + + def path(self): + return None + + + def replace(self, k, contents): + if contents == None: + contents = b'' + k = self.__to_path(k) + v = self.redis.get(k) + if v == None: + raise FileNotFoundError(k) + self.redis.set(k, contents) + + + def modified(self, k): + k = self.__to_path(k) + k = '_mod' + k + v = self.redis.get(k) + return int(v) + + + def register_modify(self, k): + k = self.__to_path(k) + k = '_mod' + k + ts = datetime.datetime.utcnow().timestamp() + self.redis.set(k) + + +class RedisStoreFactory: + + def __init__(self, host='localhost', port=6379, db=0, binary=False): + self.redis = redis.Redis(host=host, port=port, db=db) + self.__binary = binary + + + def add(self, k): + k = str(k) + return RedisStore(k, self.redis, binary=self.__binary) diff --git a/tests/test_file.py b/tests/test_file.py @@ -13,12 +13,12 @@ from shep.error import ( ) -class TestStateReport(unittest.TestCase): +class TestFileStore(unittest.TestCase): def setUp(self): self.d = tempfile.mkdtemp() self.factory = SimpleFileStoreFactory(self.d) - self.states = PersistedState(self.factory.add, 4) + self.states = PersistedState(self.factory.add, 3) self.states.add('foo') self.states.add('bar') self.states.add('baz') @@ -206,6 +206,9 @@ class TestStateReport(unittest.TestCase): with self.assertRaises(StateInvalid): self.states.next('abcd') + v = self.states.state('abcd') + self.assertEqual(v, self.states.BAZ) + fp = os.path.join(self.d, 'FOO', 'abcd') with self.assertRaises(FileNotFoundError): os.stat(fp) diff --git a/tests/test_redis.py b/tests/test_redis.py @@ -0,0 +1,93 @@ +# standard imports +import unittest +import os +import logging +import sys +import importlib + +# local imports +from shep.persist import PersistedState +from shep.error import ( + StateExists, + StateInvalid, + StateItemExists, + StateItemNotFound, + ) + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + + +class TestRedisStore(unittest.TestCase): + + def setUp(self): + from shep.store.redis import RedisStoreFactory + self.factory = RedisStoreFactory() + self.states = PersistedState(self.factory.add, 3) + self.states.add('foo') + self.states.add('bar') + self.states.add('baz') + + + def test_add(self): + self.states.put('abcd', state=self.states.FOO, contents='baz') + v = self.states.get('abcd') + self.assertEqual(v, 'baz') + v = self.states.state('abcd') + self.assertEqual(v, self.states.FOO) + + + def test_next(self): + self.states.put('abcd') + + self.states.next('abcd') + self.assertEqual(self.states.state('abcd'), self.states.FOO) + + self.states.next('abcd') + self.assertEqual(self.states.state('abcd'), self.states.BAR) + + self.states.next('abcd') + self.assertEqual(self.states.state('abcd'), self.states.BAZ) + + with self.assertRaises(StateInvalid): + self.states.next('abcd') + + v = self.states.state('abcd') + self.assertEqual(v, self.states.BAZ) + + + def test_replace(self): + with self.assertRaises(StateItemNotFound): + self.states.replace('abcd', contents='foo') + + self.states.put('abcd', state=self.states.FOO, contents='baz') + self.states.replace('abcd', contents='bar') + v = self.states.get('abcd') + self.assertEqual(v, 'bar') + + +if __name__ == '__main__': + noredis = False + redis = None + try: + redis = importlib.import_module('redis') + except ModuleNotFoundError: + logg.critical('redis module not available, skipping tests.') + sys.exit(0) + + host = os.environ.get('REDIS_HOST', 'localhost') + port = os.environ.get('REDIS_PORT', 6379) + port = int(port) + db = os.environ.get('REDIS_DB', 0) + db = int(db) + r = redis.Redis(host=host, port=port, db=db) + try: + r.get('foo') + except redis.exceptions.ConnectionError: + logg.critical('could not connect to redis, skipping tests.') + sys.exit(0) + except redis.exceptions.InvalidResponse as e: + logg.critical('is that really redis running on {}:{}? Got unexpected response: {}'.format(host, port, e)) + sys.exit(0) + + unittest.main() diff --git a/tests/test_verify.py b/tests/test_verify.py @@ -0,0 +1,31 @@ +# standard imports +import unittest + +# local imports +from shep import State +from shep.error import ( + StateTransitionInvalid, + ) + + +def mock_verify(state, from_state, to_state): + if from_state == state.FOO: + if to_state == state.BAR: + return 'bar cannot follow foo' + + +class TestState(unittest.TestCase): + + def test_verify(self): + states = State(2, verifier=mock_verify) + states.add('foo') + states.add('bar') + states.put('xyzzy') + states.next('xyzzy') + with self.assertRaises(StateTransitionInvalid): + states.next('xyzzy') + + + +if __name__ == '__main__': + unittest.main()