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:
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()