whee

Unified interface to key value with locking and transactions.
Info | Log | Files | Refs | README | LICENSE

commit 39a46ca95d2b6c69ae5aecf8cd02906ca108238d
Author: lash <dev@holbrook.no>
Date:   Sun,  4 Jan 2026 10:40:31 +0100

initial commit

Diffstat:
A.gitignore | 3+++
Areadme.txt | 4++++
Atests/test_couchdb.py | 20++++++++++++++++++++
Atests/test_mem.py | 35+++++++++++++++++++++++++++++++++++
Atests/test_valkey.py | 38++++++++++++++++++++++++++++++++++++++
Awhee/__init__.py | 29+++++++++++++++++++++++++++++
Awhee/base.py | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awhee/couchdb/__init__.py | 33+++++++++++++++++++++++++++++++++
Awhee/mem.py | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awhee/valkey/__init__.py | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 376 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,3 @@ +build/ +*.egg-info +__pycache__ diff --git a/readme.txt b/readme.txt @@ -0,0 +1,4 @@ +No setup yet, extensions tested with following module versions: + +CouchDB==1.2 +valkey==6.1.1 diff --git a/tests/test_couchdb.py b/tests/test_couchdb.py @@ -0,0 +1,20 @@ +import logging +import unittest + +from whee.couchdb import CouchDBStore + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + +class TestCouchDB(unittest.TestCase): + + def setUp(self): + self.store = CouchDBStore('test', 'ya0JK6)hp') + + + def test_get_put(self): + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_mem.py b/tests/test_mem.py @@ -0,0 +1,35 @@ +import logging +import unittest + +from whee.mem import MemStore + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + + +class TestMem(unittest.TestCase): + + def setUp(self): + self.store = MemStore() + + + def test_get_put(self): + r = self.store.have(b'foo') + self.assertFalse(r) + self.store.put(b'foo', b'bar') + r = self.store.have(b'foo') + self.assertTrue(r) + r = self.store.get(b'foo') + self.assertEqual(r, b'bar') + r = self.store.get(b'foo'.hex()) + self.assertEqual(r, b'bar') + with self.assertRaises(FileExistsError): + self.store.put(b'foo', b'baz') + self.store.put(b'foo', b'baz', exist_ok=True) + self.store.delete(b'foo') + with self.assertRaises(FileNotFoundError): + self.store.delete(b'foo') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_valkey.py b/tests/test_valkey.py @@ -0,0 +1,38 @@ +import logging +import unittest + +from whee.valkey import ValkeyStore + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + +class TestValkey(unittest.TestCase): + + def setUp(self): + self.store = ValkeyStore(124) + + def test_get_put(self): + # remove the initial delete once we are creating temporary test dbs + try: + self.store.delete(b'foo') + except: + pass + r = self.store.have(b'foo') + self.assertFalse(r) + self.store.put(b'foo', b'bar') + r = self.store.have(b'foo') + self.assertTrue(r) + r = self.store.get(b'foo') + self.assertEqual(r, b'bar') + r = self.store.get(b'foo'.hex()) + self.assertEqual(r, b'bar') + with self.assertRaises(FileExistsError): + self.store.put(b'foo', b'baz') + self.store.put(b'foo', b'baz', exist_ok=True) + self.store.delete(b'foo') + with self.assertRaises(FileNotFoundError): + self.store.delete(b'foo') + + +if __name__ == '__main__': + unittest.main() diff --git a/whee/__init__.py b/whee/__init__.py @@ -0,0 +1,29 @@ +from .base import Interface + + +def ensure_hex_key(v): + """Return hex for bytes, or verify valid hex. + + :param v: Value to check or convert. + :type v: bytes or str + :raises: ValueError if invalid hexadecimal value (and not bytes). + :returns: Corresponding hexadecimal data. + :rtype: str + """ + if isinstance(v, bytes): + return v.hex() + bytes.fromhex(v) + return v + + +def ensure_bytes_key(v): + """Return bytes key if hex is given. + + :param v: Value to check or convert. + :type v: bytes or str + :returns: Corresponding bytes data + :rtype: bytes + """ + if isinstance(v, bytes): + return v + return bytes.fromhex(v) diff --git a/whee/base.py b/whee/base.py @@ -0,0 +1,104 @@ +class Interface: + """This is Interface class abstracts transactional store operations on key-value-based backends. + """ + + def get(self, k): + """Retrieve value for key. + + :raises: FileNotFoundError if key does not exist. + :raises: ConnectionRefusedError if store is locked. + :raises: IOError if key is found but read fails for any reason. + :returns: Value + :rtype: bytes + """ + raise NotImplementedError() + + + def put(self, k, v, exist_ok=False): + """Retrieve value for key. + + :raises: ValueError if value is in a format that cannot be stored. + :raises: ConnectionRefusedError if store is locked. + :raises: FileExistsError if key already exists and exist_ok is not True. + :raises: IOError if value is valid and key is available, but write fails for any other reason. + :returns: Value + :rtype: bytes + """ + raise NotImplementedError() + + + def have(self, k): + """Check if key exists in store. + + :raises: FileNotFoundError if key does not exist. + :raises: ConnectionRefusedError if store is locked. + :raises: IOError if value is valid and key is available, but write fails for any other reason. + """ + raise NotImplementedError() + + + def start(self): + """Start a store transaction. + + :raises: ConnectionError if the transaction cannot be made due to missing connection with the backend. + :raises: ConnectionRefusedError if store is locked. + :raises: PermissionError if a transaction is already in place, and/or the backend does not support (multiple) transactions. + :raises: IOError if lock cannot be placed for any other reason. + """ + raise NotImplementedError() + + + def stop(self): + """Commit and end a store transaction. + + :raises: ConnectionError if no transaction exists. + :raises: ConnectionRefusedError if store is locked. + :raises: ConnectionAbortedError if transaction could not be committed. After this, the transaction has been dropped. + :raises: IOError if transaction abort fails for any reason (transaction will still be pending). + """ + raise NotImplementedError() + + + def delete(self, k): + """Delete key and its corresponding value. This action is not reversible. + + :raises: FileNotFoundError if key does not exist. + :raises: ConnectionRefusedError if store is locked. + :raises: IOError if key is valid but operation fails for any other reason. + """ + raise NotImplementedError() + + + def lock(self): + """Lock the store for any operation by any process. + + :raises: ConnectionRefusedError if store is already locked. + :raises: IOError if lock fails for any other reason. + """ + raise NotImplementedError() + + + def abort(self): + """Stop a store transaction without committing. + + :raises: ConnectionError if no transaction exists. + :raises: ConnectionRefusedError if store is locked. + :raises: IOError if transaction abort fails for any reason (transaction will still be pending). + """ + raise NotImplementedError() + + + def flush(self): + """Write changes to store and unlock. + + :raises: IOError if write fails for any reason. + """ + raise NotImplementedError() + + + def cap(self): + """Return bytes available for storage. + + :raises: IOError if query fails for any reason. + """ + return 0 diff --git a/whee/couchdb/__init__.py b/whee/couchdb/__init__.py @@ -0,0 +1,33 @@ +import logging + +import couchdb + +from whee import Interface, ensure_hex_key + +logg = logging.getLogger('whee.couchdb') + + +class CouchDBStore(Interface): + """Implements whee.Interface for Apache CouchDB + """ + + dbname_prefix = 'whee-' + + def __init__(self, dbname, passphrase, user='admin', host='localhost', port=5984, ssl=False): + self.dbname = self.dbname_prefix + dbname + connstr = 'http' + if ssl: + connstr += 's' + connstr += '://' + connstr += '{}:{}@{}:{}/'.format(user, passphrase, host, port) + self.conn = couchdb.Server(connstr) + try: + self.db = self.conn.create(self.dbname) + except couchdb.http.PreconditionFailed: + self.db = self.conn[self.dbname] + + + def get(self, k): + #try: + # self.db.find('selector': {'type': 'wheekv'}, + pass diff --git a/whee/mem.py b/whee/mem.py @@ -0,0 +1,55 @@ +import logging + +from whee import Interface, ensure_hex_key + +logg = logging.getLogger('memstore') + + +class MemStore(Interface): + """Memstore implements the whee.Interface for python dicts in in-process memory. + """ + + def __init__(self): + self.v = {} + self.__to_store_key = ensure_hex_key + + + def have(self, k): + k = self.__to_store_key(k) + return bool(self.v.get(k)) + + + def get(self, k): + k = self.__to_store_key(k) + r = self.v.get(k) + if r == None: + raise FileNotFoundError() + logg.debug('memstore get {} -> {}'.format(k, r)) + return r + + + def put(self, k, v, exist_ok=False): + k = self.__to_store_key(k) + if self.have(k): + if not exist_ok: + raise FileExistsError() + logg.debug('memstore put (replace) {} <- {}'.format(k, v)) + else: + logg.debug('memstore put {} <- {}'.format(k, v)) + self.v[k] = v + + + def delete(self, k): + k = self.__to_store_key(k) + if not self.have(k): + raise FileNotFoundError + logg.debug('memstore delete {}'.format(k)) + del self.v[k] + + + def start(self): + raise PermissionError() + + + def stop(self): + raise PermissionError() diff --git a/whee/valkey/__init__.py b/whee/valkey/__init__.py @@ -0,0 +1,55 @@ +import logging + +import valkey + +from whee import Interface, ensure_bytes_key + +logg = logging.getLogger('whee.valkey') + + +class ValkeyStore(Interface): + """Implements whee.Interface for Valkey + """ + + dbno_default = 0 + + def __init__(self, passphrase, user=None, host='localhost', port=6379, dbno=None): + if dbno == None: + dbno = self.dbno_default + self.db = valkey.Valkey(host=host, port=port, db=dbno) + if user != None: + self.db.auth(user, passphrase) + self.db.ping() + + + def have(self, k): + k = ensure_bytes_key(k) + return bool(self.db.get(k)) + + + def get(self, k): + k = ensure_bytes_key(k) + r = self.db.get(k) + if r == None: + raise FileNotFoundError() + logg.debug('valkeystore get {} -> {}'.format(k, r)) + return r + + + def put(self, k, v, exist_ok=False): + k = ensure_bytes_key(k) + if self.have(k): + if not exist_ok: + raise FileExistsError() + logg.debug('valkeystore put (replace) {} <- {}'.format(k, v)) + else: + logg.debug('valkeystore put {} <- {}'.format(k, v)) + self.db.set(k, v) + + + def delete(self, k): + k = ensure_bytes_key(k) + if not self.have(k): + raise FileNotFoundError + logg.debug('valkeystore delete {}'.format(k)) + self.db.delete(k)