├── MANIFEST.in ├── .gitignore ├── requirements.in ├── CHANGES.rst ├── .github └── workflows │ └── ci.yml ├── tox.ini ├── README.rst ├── pyproject.toml ├── COPYING.txt ├── requirements.txt ├── kv.py └── tests.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include kv.py README.rst COPYING.txt 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | MANIFEST 3 | dist 4 | *.egg-info 5 | .coverage 6 | .tox 7 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | coverage 2 | flake8 3 | mock 4 | pip-tools 5 | pytest 6 | tox 7 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 0.3 (2017-11-15) 2 | ---------------- 3 | - Command-line interface. 4 | 5 | 0.2 (2012-10-18) 6 | ---------------- 7 | - Multiple tables in the same SQLite database. 8 | 9 | 0.1 (2012-10-03) 10 | ---------------- 11 | First public release. 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python: ["3.8", "3.9", "3.10", "3.11"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Setup Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python }} 19 | - name: Install tox and any other packages 20 | run: pip install tox 21 | - name: Run tox 22 | # Run tox using the version of Python in `PATH` 23 | run: tox -e py 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = lint, py38, py39, py310, py311, covreport 8 | skipsdist = True 9 | 10 | [tox:travis] 11 | 3.8 = py38 12 | 3.9 = py39 13 | 3.10 = py310 14 | 3.11 = py311 15 | 16 | [testenv] 17 | install_command = pip install {opts} {packages} 18 | passenv = TERM 19 | commands = pytest tests.py 20 | deps = 21 | -e{toxinidir} 22 | -r{toxinidir}/requirements.txt 23 | 24 | [testenv:lint] 25 | basepython = python3 26 | commands = {envbindir}/flake8 kv.py setup.py 27 | 28 | [testenv:covreport] 29 | basepython = python3 30 | commands = coverage report -m 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | KV - simple key/value store 2 | =========================== 3 | 4 | .. image:: https://github.com/mgax/kv/actions/workflows/ci.yml/badge.svg?branch=master 5 | 6 | KV provides a dictionary-like interface on top of SQLite. Keys can be 7 | unicode strings, numbers or None. Values are stored as JSON. 8 | 9 | :: 10 | 11 | >>> from kv import KV 12 | >>> db = KV('/tmp/demo.kv') 13 | >>> db['hello'] = 'world' 14 | >>> db[42] = ['answer', 2, {'ultimate': 'question'}] 15 | >>> dict(db) 16 | {42: [u'answer', 2, {u'ultimate': u'question'}], u'hello': u'world'} 17 | 18 | 19 | There is a locking facility that uses SQLite's transaction API:: 20 | 21 | >>> with kv.lock(): 22 | ... l = db[42] 23 | ... l += ['or is it?'] 24 | ... db[42] = l 25 | 26 | 27 | And that's about it. The code_ is really simple. 28 | 29 | .. _code: https://github.com/mgax/kv 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "kv" 3 | version = "0.4.1" 4 | description = "KV provides a dictionary-like interface on top of SQLite." 5 | authors = ["Alex Morega "] 6 | readme = "README.rst" 7 | license = "BSD-2-Clause" 8 | classifiers = [ 9 | "Development Status :: 4 - Beta", 10 | "Intended Audience :: Developers", 11 | "License :: OSI Approved :: BSD License", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3.8", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Topic :: Database :: Front-Ends", 19 | ] 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.8" 23 | 24 | [tool.poetry.scripts] 25 | kv-cli = "kv:main" 26 | 27 | [build-system] 28 | requires = ["poetry-core"] 29 | build-backend = "poetry.core.masonry.api" 30 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Alex Morega 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 16 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 17 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 18 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 19 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 21 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 22 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 23 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 24 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | attrs==22.1.0 8 | # via pytest 9 | build==0.9.0 10 | # via pip-tools 11 | click==8.1.3 12 | # via pip-tools 13 | coverage==6.5.0 14 | # via -r requirements.in 15 | distlib==0.3.6 16 | # via virtualenv 17 | exceptiongroup==1.0.4 18 | # via pytest 19 | filelock==3.8.0 20 | # via 21 | # tox 22 | # virtualenv 23 | flake8==6.0.0 24 | # via -r requirements.in 25 | iniconfig==1.1.1 26 | # via pytest 27 | mccabe==0.7.0 28 | # via flake8 29 | mock==4.0.3 30 | # via -r requirements.in 31 | packaging==21.3 32 | # via 33 | # build 34 | # pytest 35 | # tox 36 | pep517==0.13.0 37 | # via build 38 | pip-tools==6.10.0 39 | # via -r requirements.in 40 | platformdirs==2.5.4 41 | # via virtualenv 42 | pluggy==1.0.0 43 | # via 44 | # pytest 45 | # tox 46 | py==1.11.0 47 | # via tox 48 | pycodestyle==2.10.0 49 | # via flake8 50 | pyflakes==3.0.1 51 | # via flake8 52 | pyparsing==3.0.9 53 | # via packaging 54 | pytest==7.2.0 55 | # via -r requirements.in 56 | six==1.16.0 57 | # via tox 58 | tomli==2.0.1 59 | # via 60 | # build 61 | # pep517 62 | # pytest 63 | # tox 64 | tox==3.27.1 65 | # via -r requirements.in 66 | virtualenv==20.17.0 67 | # via tox 68 | wheel==0.38.4 69 | # via pip-tools 70 | 71 | # The following packages are considered to be unsafe in a requirements file: 72 | # pip 73 | # setuptools 74 | -------------------------------------------------------------------------------- /kv.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import argparse 3 | import sqlite3 4 | import sys 5 | try: 6 | from collections import MutableMapping 7 | except ImportError: 8 | # python3.10 ended this import, try from the new home 9 | from collections.abc import MutableMapping 10 | 11 | from contextlib import contextmanager 12 | try: 13 | import simplejson as json 14 | except ImportError: 15 | import json # noqa 16 | 17 | 18 | class KV(MutableMapping): 19 | 20 | def __init__(self, db_uri=':memory:', table='data', timeout=5): 21 | self._db = sqlite3.connect(db_uri, timeout=timeout) 22 | self._db.isolation_level = None 23 | self._table = table 24 | self._execute('CREATE TABLE IF NOT EXISTS %s ' 25 | '(key PRIMARY KEY, value)' % self._table) 26 | self._locks = 0 27 | 28 | def _execute(self, *args): 29 | return self._db.cursor().execute(*args) 30 | 31 | def __len__(self): 32 | [[n]] = self._execute('SELECT COUNT(*) FROM %s' % self._table) 33 | return n 34 | 35 | def __getitem__(self, key): 36 | if key is None: 37 | q = ('SELECT value FROM %s WHERE key is NULL' % self._table, ()) 38 | else: 39 | q = ('SELECT value FROM %s WHERE key=?' % self._table, (key,)) 40 | for row in self._execute(*q): 41 | return json.loads(row[0]) 42 | else: 43 | raise KeyError 44 | 45 | def __iter__(self): 46 | return (key for [key] in self._execute('SELECT key FROM %s' % 47 | self._table)) 48 | 49 | def __setitem__(self, key, value): 50 | jvalue = json.dumps(value) 51 | with self.lock(): 52 | try: 53 | self._execute('INSERT INTO %s VALUES (?, ?)' % self._table, 54 | (key, jvalue)) 55 | except sqlite3.IntegrityError: 56 | self._execute('UPDATE %s SET value=? WHERE key=?' % 57 | self._table, (jvalue, key)) 58 | 59 | def __delitem__(self, key): 60 | if key in self: 61 | self._execute('DELETE FROM %s WHERE key=?' % self._table, (key,)) 62 | else: 63 | raise KeyError 64 | 65 | @contextmanager 66 | def lock(self): 67 | if not self._locks: 68 | self._execute('BEGIN IMMEDIATE TRANSACTION') 69 | self._locks += 1 70 | try: 71 | yield 72 | finally: 73 | self._locks -= 1 74 | if not self._locks: 75 | self._execute('COMMIT') 76 | 77 | 78 | def main(): 79 | parser = argparse.ArgumentParser( 80 | description="Key-value store backed by SQLite.") 81 | parser.add_argument('db_uri', help='Database filename or URI') 82 | parser.add_argument('-t', '--table', nargs=1, default='data', 83 | help='Table name') 84 | subparsers = parser.add_subparsers(dest='command') 85 | 86 | parser_get = subparsers.add_parser('get', help='Get the value for a key') 87 | parser_get.add_argument('key') 88 | 89 | parser_set = subparsers.add_parser('set', help='Set a value for a key') 90 | parser_set.add_argument('key') 91 | parser_set.add_argument('value') 92 | 93 | parser_del = subparsers.add_parser('del', help='Delete a key') 94 | parser_del.add_argument('key') 95 | 96 | opts = parser.parse_args() 97 | kv = KV(opts.db_uri, opts.table) 98 | if opts.command == 'get': 99 | if opts.key not in kv: 100 | sys.exit(1) 101 | print(kv[opts.key]) 102 | elif opts.command == 'set': 103 | kv[opts.key] = opts.value 104 | elif opts.command == 'del': 105 | if opts.key not in kv: 106 | sys.exit(1) 107 | del kv[opts.key] 108 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import tempfile 3 | import unittest 4 | from copy import deepcopy 5 | from shutil import rmtree 6 | from threading import Thread 7 | 8 | import mock 9 | from pathlib import Path 10 | 11 | import kv 12 | 13 | try: 14 | from queue import Queue 15 | except ImportError: 16 | from Queue import Queue 17 | 18 | 19 | class KVTest(unittest.TestCase): 20 | 21 | def setUp(self): 22 | self.kv = kv.KV() 23 | 24 | def assertCountEqual(self, *args, **kwargs): 25 | try: 26 | meth = super(KVTest, self).assertCountEqual 27 | except AttributeError: 28 | meth = self.assertItemsEqual 29 | meth(*args, **kwargs) 30 | 31 | def test_new_kv_is_empty(self): 32 | self.assertEqual(len(self.kv), 0) 33 | 34 | def test_kv_with_two_items_has_size_two(self): 35 | self.kv['a'] = 'x' 36 | self.kv['b'] = 'x' 37 | self.assertEqual(len(self.kv), 2) 38 | 39 | def test_get_missing_value_raises_key_error(self): 40 | with self.assertRaises(KeyError): 41 | self.kv['missing'] 42 | 43 | def test_get_missing_value_returns_default(self): 44 | self.assertIsNone(self.kv.get('missing')) 45 | 46 | def test_get_missing_value_with_default_returns_argument(self): 47 | fallback = object() 48 | self.assertEqual(self.kv.get('missing', fallback), fallback) 49 | 50 | def test_contains_missing_value_is_false(self): 51 | self.assertFalse('missing' in self.kv) 52 | 53 | def test_contains_existing_value_is_true(self): 54 | self.kv['a'] = 'b' 55 | self.assertTrue('a' in self.kv) 56 | 57 | def test_saved_item_is_retrieved_via_getitem(self): 58 | self.kv['a'] = 'b' 59 | self.assertEqual(self.kv['a'], 'b') 60 | 61 | def test_saved_item_is_retrieved_via_get(self): 62 | self.kv['a'] = 'b' 63 | self.assertEqual(self.kv.get('a'), 'b') 64 | 65 | def test_updated_item_is_retrieved_via_getitem(self): 66 | self.kv['a'] = 'b' 67 | self.kv['a'] = 'c' 68 | self.assertEqual(self.kv['a'], 'c') 69 | 70 | def test_udpate_with_dictionary_items_retrieved_via_getitem(self): 71 | self.kv.update({'a': 'b'}) 72 | self.assertEqual(self.kv['a'], 'b') 73 | 74 | def test_delete_missing_item_raises_key_error(self): 75 | with self.assertRaises(KeyError): 76 | del self.kv['missing'] 77 | 78 | def test_get_deleted_item_raises_key_error(self): 79 | self.kv['a'] = 'b' 80 | del self.kv['a'] 81 | with self.assertRaises(KeyError): 82 | self.kv['a'] 83 | 84 | def test_iter_yields_keys(self): 85 | self.kv['a'] = 'x' 86 | self.kv['b'] = 'x' 87 | self.kv['c'] = 'x' 88 | self.assertCountEqual(self.kv, ['a', 'b', 'c']) 89 | 90 | def test_value_saved_with_int_key_is_retrieved_with_int_key(self): 91 | self.kv[13] = 'a' 92 | self.assertEqual(self.kv[13], 'a') 93 | 94 | def test_value_saved_with_int_key_is_not_retrieved_with_str_key(self): 95 | self.kv[13] = 'a' 96 | self.assertIsNone(self.kv.get('13')) 97 | 98 | def test_value_saved_with_str_key_is_not_retrieved_with_int_key(self): 99 | self.kv['13'] = 'a' 100 | self.assertIsNone(self.kv.get(13)) 101 | 102 | def test_value_saved_at_null_key_is_retrieved(self): 103 | self.kv[None] = 'a' 104 | self.assertEqual(self.kv[None], 'a') 105 | 106 | def test_value_saved_with_float_key_is_retrieved_with_float_key(self): 107 | self.kv[3.14] = 'a' 108 | self.assertEqual(self.kv[3.14], 'a') 109 | 110 | def test_value_saved_with_unicode_key_is_retrieved(self): 111 | key = u'\u2022' 112 | self.kv[key] = 'a' 113 | self.assertEqual(self.kv[key], 'a') 114 | 115 | 116 | class KVPersistenceTest(unittest.TestCase): 117 | 118 | def setUp(self): 119 | self.tmp = Path(tempfile.mkdtemp()) 120 | self.addCleanup(rmtree, self.tmp) 121 | 122 | def test_value_saved_by_one_kv_client_is_read_by_another(self): 123 | kv1 = kv.KV(self.tmp / 'kv.sqlite') 124 | kv1['a'] = 'b' 125 | kv2 = kv.KV(self.tmp / 'kv.sqlite') 126 | self.assertEqual(kv2['a'], 'b') 127 | 128 | def test_deep_structure_is_retrieved_the_same(self): 129 | value = {'a': ['b', {'c': 123}]} 130 | kv1 = kv.KV(self.tmp / 'kv.sqlite') 131 | kv1['a'] = deepcopy(value) 132 | kv2 = kv.KV(self.tmp / 'kv.sqlite') 133 | self.assertEqual(kv2['a'], value) 134 | 135 | def test_lock_fails_if_db_already_locked(self): 136 | db_path = self.tmp / 'kv.sqlite' 137 | q1 = Queue() 138 | q2 = Queue() 139 | kv2 = kv.KV(db_path, timeout=0.1) 140 | 141 | def locker(): 142 | kv1 = kv.KV(db_path) 143 | with kv1.lock(): 144 | q1.put(None) 145 | q2.get() 146 | th = Thread(target=locker) 147 | th.start() 148 | try: 149 | q1.get() 150 | with self.assertRaises(sqlite3.OperationalError) as cm1: 151 | with kv2.lock(): 152 | pass 153 | self.assertEqual(str(cm1.exception), 'database is locked') 154 | with self.assertRaises(sqlite3.OperationalError) as cm2: 155 | kv2['a'] = 'b' 156 | self.assertEqual(str(cm2.exception), 'database is locked') 157 | finally: 158 | q2.put(None) 159 | th.join() 160 | 161 | def test_lock_during_lock_still_saves_value(self): 162 | kv1 = kv.KV(self.tmp / 'kv.sqlite') 163 | with kv1.lock(): 164 | with kv1.lock(): 165 | kv1['a'] = 'b' 166 | self.assertEqual(kv1['a'], 'b') 167 | 168 | def test_same_database_can_contain_two_namespaces(self): 169 | kv1 = kv.KV(self.tmp / 'kv.sqlite') 170 | kv2 = kv.KV(self.tmp / 'kv.sqlite', table='other') 171 | kv1['a'] = 'b' 172 | kv2['a'] = 'c' 173 | self.assertEqual(kv1['a'], 'b') 174 | self.assertEqual(kv2['a'], 'c') 175 | 176 | 177 | class CLITest(unittest.TestCase): 178 | 179 | def setUp(self): 180 | self.tmp = Path(tempfile.mkdtemp()) 181 | self.kv_file = str(self.tmp / 'kv.sqlite') 182 | self.kv = kv.KV(self.kv_file) 183 | self.addCleanup(rmtree, self.tmp) 184 | 185 | def _run(self, *args): 186 | with mock.patch('sys.argv', ['kv', self.kv_file] + list(args)): 187 | with mock.patch('kv.print') as mprint: 188 | with mock.patch('sys.stderr') as mstderr: 189 | mstderr.write = mprint 190 | retcode = 0 191 | output = '' 192 | try: 193 | kv.main() 194 | except SystemExit as e: 195 | retcode = e.code 196 | if mprint.called: 197 | output = mprint.call_args[0][0] 198 | return retcode, output 199 | 200 | def test_get(self): 201 | assert 'foo' not in self.kv 202 | self.assertEqual(self._run('get', 'foo'), (1, '')) 203 | self.kv['foo'] = 'test' 204 | self.assertEqual(self._run('get', 'foo'), (0, 'test')) 205 | 206 | def test_set(self): 207 | assert 'foo' not in self.kv 208 | self.assertEqual(self._run('set', 'foo', 'test'), (0, '')) 209 | assert 'foo' in self.kv 210 | self.assertEqual(self.kv['foo'], 'test') 211 | 212 | def test_del(self): 213 | assert 'foo' not in self.kv 214 | self.assertEqual(self._run('del', 'foo'), (1, '')) 215 | self.kv['foo'] = 'test' 216 | assert 'foo' in self.kv 217 | self.assertEqual(self._run('del', 'foo'), (0, '')) 218 | assert 'foo' not in self.kv 219 | --------------------------------------------------------------------------------