tinykv 0.1.2__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinykv-0.2.1/CHANGELOG.md +65 -0
- {tinykv-0.1.2 → tinykv-0.2.1}/PKG-INFO +111 -20
- {tinykv-0.1.2 → tinykv-0.2.1}/README.md +101 -17
- {tinykv-0.1.2 → tinykv-0.2.1}/pyproject.toml +12 -2
- {tinykv-0.1.2 → tinykv-0.2.1}/src/tinykv/__init__.py +146 -22
- tinykv-0.2.1/tests/test_tinykv.py +321 -0
- tinykv-0.1.2/tests/test_tinykv.py +0 -132
- {tinykv-0.1.2 → tinykv-0.2.1}/.flake8 +0 -0
- {tinykv-0.1.2 → tinykv-0.2.1}/.gitignore +0 -0
- {tinykv-0.1.2 → tinykv-0.2.1}/LICENSE +0 -0
- {tinykv-0.1.2 → tinykv-0.2.1}/src/tinykv/py.typed +0 -0
- {tinykv-0.1.2 → tinykv-0.2.1}/tests/__init__.py +0 -0
- {tinykv-0.1.2 → tinykv-0.2.1}/tests/test_doc.py +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
This project adheres to [Semantic
|
|
6
|
+
Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
Entries marked as **BC BREAK** indicate backward-incompatible changes.
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.2.1] - 2026-03-11
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Added an optional `if_not_exists` parameter to `create_schema()` to support
|
|
16
|
+
idempotent table creation when needed while preserving the default error on
|
|
17
|
+
existing tables. Close #1.
|
|
18
|
+
|
|
19
|
+
## [0.2.0] - 2026-03-11
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **BC BREAK** Minimum Python version is now 3.11 (was 3.7). This enables
|
|
23
|
+
modern type hint syntax and removes compatibility code for older Python
|
|
24
|
+
versions.
|
|
25
|
+
- **BC BREAK** Keys are now case-sensitive. Previously, the database used
|
|
26
|
+
case-insensitive collation, so `'Foo'` and `'foo'` referred to the same key.
|
|
27
|
+
Existing tables using the old schema will continue to work with the old
|
|
28
|
+
behavior, but new tables created with `create_schema()` will use
|
|
29
|
+
case-sensitive keys.
|
|
30
|
+
- Tables created with `create_schema()` now use SQLite `WITHOUT ROWID` storage
|
|
31
|
+
for better performance and smaller database size
|
|
32
|
+
|
|
33
|
+
## [0.1.3] - 2026-03-11
|
|
34
|
+
|
|
35
|
+
Pickle safety improvements and bug fixes.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- Add explicit pickle safety mode with user-facing warning
|
|
39
|
+
- Enforce string key contract: non-string keys raise `TypeError`, empty
|
|
40
|
+
strings raise `ValueError`
|
|
41
|
+
|
|
42
|
+
### Security
|
|
43
|
+
- Validate table names to prevent SQL injection via table identifier
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- Fix precision loss when roundtripping large integers through the database
|
|
47
|
+
- Fix NaN roundtrip crash by storing as pickle and always deserializing
|
|
48
|
+
- Fix `set_many({})` crash by making empty batch a no-op
|
|
49
|
+
- Fix `remove_many([])` crash by making empty batch a no-op
|
|
50
|
+
- Fix integral float type loss (e.g., `1.0` returned as `1`)
|
|
51
|
+
|
|
52
|
+
## [0.1.2] - 2025-07-28
|
|
53
|
+
|
|
54
|
+
Package reorganization for better pip compatibility.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
- Reorganize package into standard "src" layout for better pip install
|
|
58
|
+
experience
|
|
59
|
+
|
|
60
|
+
## [0.1.1] - 2023-03-14
|
|
61
|
+
|
|
62
|
+
Initial release of tinykv.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
- Initial release
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinykv
|
|
3
|
-
Version: 0.1
|
|
4
|
-
Summary: A
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: A lightweight Python SQLite key-value store built on sqlite3.
|
|
5
|
+
Keywords: python,sqlite,sqlite3,key-value store,kv
|
|
5
6
|
Author-email: Flavio Veloso Soares <flaviovs@magnux.com>
|
|
6
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.11
|
|
7
8
|
Description-Content-Type: text/markdown
|
|
8
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
9
13
|
License-File: LICENSE
|
|
10
14
|
Requires-Dist: codespell ; extra == "dev"
|
|
11
15
|
Requires-Dist: flake8-bugbear ; extra == "dev"
|
|
@@ -18,17 +22,35 @@ Requires-Dist: flit ; extra == "dev"
|
|
|
18
22
|
Requires-Dist: mypy ; extra == "dev"
|
|
19
23
|
Requires-Dist: pylint ; extra == "dev"
|
|
20
24
|
Requires-Dist: taskipy ; extra == "dev"
|
|
25
|
+
Project-URL: Homepage, https://github.com/flaviovs/tinykv
|
|
26
|
+
Project-URL: Issues, https://github.com/flaviovs/tinykv/issues
|
|
27
|
+
Project-URL: Repository, https://github.com/flaviovs/tinykv
|
|
21
28
|
Provides-Extra: dev
|
|
22
29
|
|
|
23
|
-
TinyKV
|
|
24
|
-
|
|
30
|
+
TinyKV: Python SQLite key-value store
|
|
31
|
+
=====================================
|
|
25
32
|
|
|
26
|
-
TinyKV
|
|
33
|
+
TinyKV is a lightweight Python SQLite key-value store built on top of
|
|
27
34
|
the [sqlite3](https://docs.python.org/3/library/sqlite3.html) module
|
|
28
|
-
from the standard library.
|
|
29
|
-
|
|
35
|
+
from the standard library. It provides a small key-value database API
|
|
36
|
+
for Python applications that need persistent local storage without
|
|
37
|
+
external dependencies.
|
|
30
38
|
|
|
31
|
-
TinyKV
|
|
39
|
+
Use TinyKV when you want an embedded SQLite-backed key-value database
|
|
40
|
+
for configuration data, local caches, application state, or small
|
|
41
|
+
metadata stores.
|
|
42
|
+
|
|
43
|
+
TinyKV requires Python 3.11 or above.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Why TinyKV?
|
|
47
|
+
-----------
|
|
48
|
+
|
|
49
|
+
- Python key-value store backed by SQLite
|
|
50
|
+
- Uses the standard library `sqlite3` module
|
|
51
|
+
- Works with in-memory and file-based SQLite databases
|
|
52
|
+
- Stores strings, numbers, bytes, and other Python objects
|
|
53
|
+
- Keeps connection and transaction control with the caller
|
|
32
54
|
|
|
33
55
|
|
|
34
56
|
Installation
|
|
@@ -37,7 +59,7 @@ Installation
|
|
|
37
59
|
pip install tinykv
|
|
38
60
|
|
|
39
61
|
|
|
40
|
-
|
|
62
|
+
Quick start
|
|
41
63
|
-----------
|
|
42
64
|
|
|
43
65
|
First let’s import _sqlite3_ and the TinyKV module:
|
|
@@ -45,15 +67,15 @@ First let’s import _sqlite3_ and the TinyKV module:
|
|
|
45
67
|
>>> import sqlite3
|
|
46
68
|
>>> import tinykv
|
|
47
69
|
|
|
48
|
-
TinyKV does not create a
|
|
49
|
-
|
|
50
|
-
database
|
|
70
|
+
TinyKV does not create a SQLite database connection for you. Instead,
|
|
71
|
+
it operates on connections managed by the caller. So let’s create a
|
|
72
|
+
database to use in the examples:
|
|
51
73
|
|
|
52
74
|
>>> conn = sqlite3.connect(':memory:')
|
|
53
75
|
|
|
54
76
|
This is how you create a TinyKV object:
|
|
55
77
|
|
|
56
|
-
>>> kv = tinykv.TinyKV(conn)
|
|
78
|
+
>>> kv = tinykv.TinyKV(conn, allow_pickle=True)
|
|
57
79
|
Traceback (most recent call last):
|
|
58
80
|
...
|
|
59
81
|
RuntimeError: Table 'kv' not found in the database
|
|
@@ -65,12 +87,20 @@ that the application can use `create_schema()`:
|
|
|
65
87
|
|
|
66
88
|
Let’s try again:
|
|
67
89
|
|
|
68
|
-
>>> kv = tinykv.TinyKV(conn)
|
|
90
|
+
>>> kv = tinykv.TinyKV(conn, allow_pickle=True)
|
|
69
91
|
>>> kv # doctest: +ELLIPSIS
|
|
70
92
|
<tinykv.TinyKV object at 0x...>
|
|
71
93
|
|
|
72
94
|
Now it works!
|
|
73
95
|
|
|
96
|
+
By default, TinyKV currently allows pickle-based value
|
|
97
|
+
serialization/deserialization for compatibility.
|
|
98
|
+
If `allow_pickle` is omitted, TinyKV currently behaves as if
|
|
99
|
+
`allow_pickle=True` and emits a `FutureWarning`.
|
|
100
|
+
Applications should not rely on this behavior and should explicitly set
|
|
101
|
+
`allow_pickle=False` when handling databases that might be untrusted.
|
|
102
|
+
The default is expected to change to `False` in a future release.
|
|
103
|
+
|
|
74
104
|
|
|
75
105
|
## Storing and retrieving data
|
|
76
106
|
|
|
@@ -111,13 +141,19 @@ You can store any regular Python scalar in the key-value database:
|
|
|
111
141
|
>>> kv.get('pi')
|
|
112
142
|
3.1415926
|
|
113
143
|
|
|
144
|
+
>>> kv.set('nan', float('nan'))
|
|
145
|
+
>>> import math
|
|
146
|
+
>>> math.isnan(kv.get('nan'))
|
|
147
|
+
True
|
|
148
|
+
|
|
114
149
|
Same for container objects:
|
|
115
150
|
|
|
116
151
|
>>> kv.set('a_list', ['one', 'two', 'three'])
|
|
117
152
|
>>> kv.get('a_list')
|
|
118
153
|
['one', 'two', 'three']
|
|
119
154
|
|
|
120
|
-
In fact, you can store any pickable object
|
|
155
|
+
In fact, you can store any pickable object when
|
|
156
|
+
`allow_pickle=True` (the current default):
|
|
121
157
|
|
|
122
158
|
>>> import datetime
|
|
123
159
|
>>>
|
|
@@ -131,6 +167,10 @@ In fact, you can store any pickable object:
|
|
|
131
167
|
>>> type(a_long_time_ago)
|
|
132
168
|
<class 'datetime.datetime'>
|
|
133
169
|
|
|
170
|
+
For safer behavior with untrusted database contents, disable pickle:
|
|
171
|
+
|
|
172
|
+
>>> safe_kv = tinykv.TinyKV(conn, allow_pickle=False)
|
|
173
|
+
|
|
134
174
|
|
|
135
175
|
## Removing entries
|
|
136
176
|
|
|
@@ -179,11 +219,11 @@ You can also use `get_glob()` to fetch entries based a glob pattern:
|
|
|
179
219
|
{'bar:123': 3}
|
|
180
220
|
|
|
181
221
|
Notice that `get_many()` and `get_glob()` never raise _KeyError_ for
|
|
182
|
-
nonexistent keys. Instead, those keys are
|
|
222
|
+
nonexistent keys. Instead, those keys are simply not present in the
|
|
183
223
|
returned _dict_.
|
|
184
224
|
|
|
185
225
|
You can also remove many entries in one call with
|
|
186
|
-
`remove_many()`. Nonexistent keys are silently ignored
|
|
226
|
+
`remove_many()`. Nonexistent keys are silently ignored.
|
|
187
227
|
|
|
188
228
|
>>> kv.get('one')
|
|
189
229
|
1
|
|
@@ -195,10 +235,54 @@ You can also remove many entries in one call with
|
|
|
195
235
|
KeyError: 'one'
|
|
196
236
|
|
|
197
237
|
|
|
238
|
+
## Using glob patterns
|
|
239
|
+
|
|
240
|
+
Use `get_glob()` to fetch entries using a shell-like wildcard pattern
|
|
241
|
+
from the SQLite key-value store:
|
|
242
|
+
|
|
243
|
+
The pattern uses SQLite's [GLOB](https://www.sqlite.org/lang_expr.html#glob)
|
|
244
|
+
syntax: `*` matches any sequence of characters, and `?` matches a single
|
|
245
|
+
character. Note that patterns are case-sensitive and use literal character
|
|
246
|
+
matching (not regex).
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
## Database setup
|
|
250
|
+
|
|
251
|
+
TinyKV requires the database table to exist before use. Create it with
|
|
252
|
+
`create_schema()`:
|
|
253
|
+
|
|
254
|
+
>>> import sqlite3
|
|
255
|
+
>>> conn = sqlite3.connect(':memory:')
|
|
256
|
+
>>> tinykv.create_schema(conn)
|
|
257
|
+
|
|
258
|
+
If you need idempotent setup, pass `if_not_exists=True`:
|
|
259
|
+
|
|
260
|
+
>>> tinykv.create_schema(conn, if_not_exists=True)
|
|
261
|
+
|
|
262
|
+
You can use a custom table name:
|
|
263
|
+
|
|
264
|
+
>>> tinykv.create_schema(conn, table='my_keys')
|
|
265
|
+
|
|
266
|
+
See the Miscellaneous section for table name requirements.
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
## Use cases
|
|
270
|
+
|
|
271
|
+
TinyKV is a good fit when you need a Python SQLite key-value store for:
|
|
272
|
+
|
|
273
|
+
- application configuration and settings
|
|
274
|
+
- local cache data
|
|
275
|
+
- lightweight metadata storage
|
|
276
|
+
- persistent state for command-line tools
|
|
277
|
+
- embedded storage in desktop scripts or services
|
|
278
|
+
|
|
279
|
+
|
|
198
280
|
Miscellaneous
|
|
199
281
|
-------------
|
|
200
282
|
|
|
201
|
-
- TinyKV keys must be string scalars.
|
|
283
|
+
- TinyKV keys must be non-empty string scalars. Non-string keys raise
|
|
284
|
+
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
285
|
+
(since v0.1.4).
|
|
202
286
|
|
|
203
287
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
204
288
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -220,7 +304,14 @@ Miscellaneous
|
|
|
220
304
|
>>>
|
|
221
305
|
>>> tinykv.create_schema(conn, table=CUSTOM_TABLE)
|
|
222
306
|
>>>
|
|
223
|
-
>>> custom_kv = tinykv.TinyKV(
|
|
307
|
+
>>> custom_kv = tinykv.TinyKV(
|
|
308
|
+
... conn,
|
|
309
|
+
... table=CUSTOM_TABLE,
|
|
310
|
+
... allow_pickle=True,
|
|
311
|
+
... )
|
|
312
|
+
|
|
313
|
+
Table names must match the pattern `[a-zA-Z_][a-zA-Z0-9_]*`. Invalid
|
|
314
|
+
names raise `ValueError`.
|
|
224
315
|
|
|
225
316
|
|
|
226
317
|
Questions? Bugs? Suggestions?
|
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
TinyKV
|
|
2
|
-
|
|
1
|
+
TinyKV: Python SQLite key-value store
|
|
2
|
+
=====================================
|
|
3
3
|
|
|
4
|
-
TinyKV
|
|
4
|
+
TinyKV is a lightweight Python SQLite key-value store built on top of
|
|
5
5
|
the [sqlite3](https://docs.python.org/3/library/sqlite3.html) module
|
|
6
|
-
from the standard library.
|
|
7
|
-
|
|
6
|
+
from the standard library. It provides a small key-value database API
|
|
7
|
+
for Python applications that need persistent local storage without
|
|
8
|
+
external dependencies.
|
|
8
9
|
|
|
9
|
-
TinyKV
|
|
10
|
+
Use TinyKV when you want an embedded SQLite-backed key-value database
|
|
11
|
+
for configuration data, local caches, application state, or small
|
|
12
|
+
metadata stores.
|
|
13
|
+
|
|
14
|
+
TinyKV requires Python 3.11 or above.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Why TinyKV?
|
|
18
|
+
-----------
|
|
19
|
+
|
|
20
|
+
- Python key-value store backed by SQLite
|
|
21
|
+
- Uses the standard library `sqlite3` module
|
|
22
|
+
- Works with in-memory and file-based SQLite databases
|
|
23
|
+
- Stores strings, numbers, bytes, and other Python objects
|
|
24
|
+
- Keeps connection and transaction control with the caller
|
|
10
25
|
|
|
11
26
|
|
|
12
27
|
Installation
|
|
@@ -15,7 +30,7 @@ Installation
|
|
|
15
30
|
pip install tinykv
|
|
16
31
|
|
|
17
32
|
|
|
18
|
-
|
|
33
|
+
Quick start
|
|
19
34
|
-----------
|
|
20
35
|
|
|
21
36
|
First let’s import _sqlite3_ and the TinyKV module:
|
|
@@ -23,15 +38,15 @@ First let’s import _sqlite3_ and the TinyKV module:
|
|
|
23
38
|
>>> import sqlite3
|
|
24
39
|
>>> import tinykv
|
|
25
40
|
|
|
26
|
-
TinyKV does not create a
|
|
27
|
-
|
|
28
|
-
database
|
|
41
|
+
TinyKV does not create a SQLite database connection for you. Instead,
|
|
42
|
+
it operates on connections managed by the caller. So let’s create a
|
|
43
|
+
database to use in the examples:
|
|
29
44
|
|
|
30
45
|
>>> conn = sqlite3.connect(':memory:')
|
|
31
46
|
|
|
32
47
|
This is how you create a TinyKV object:
|
|
33
48
|
|
|
34
|
-
>>> kv = tinykv.TinyKV(conn)
|
|
49
|
+
>>> kv = tinykv.TinyKV(conn, allow_pickle=True)
|
|
35
50
|
Traceback (most recent call last):
|
|
36
51
|
...
|
|
37
52
|
RuntimeError: Table 'kv' not found in the database
|
|
@@ -43,12 +58,20 @@ that the application can use `create_schema()`:
|
|
|
43
58
|
|
|
44
59
|
Let’s try again:
|
|
45
60
|
|
|
46
|
-
>>> kv = tinykv.TinyKV(conn)
|
|
61
|
+
>>> kv = tinykv.TinyKV(conn, allow_pickle=True)
|
|
47
62
|
>>> kv # doctest: +ELLIPSIS
|
|
48
63
|
<tinykv.TinyKV object at 0x...>
|
|
49
64
|
|
|
50
65
|
Now it works!
|
|
51
66
|
|
|
67
|
+
By default, TinyKV currently allows pickle-based value
|
|
68
|
+
serialization/deserialization for compatibility.
|
|
69
|
+
If `allow_pickle` is omitted, TinyKV currently behaves as if
|
|
70
|
+
`allow_pickle=True` and emits a `FutureWarning`.
|
|
71
|
+
Applications should not rely on this behavior and should explicitly set
|
|
72
|
+
`allow_pickle=False` when handling databases that might be untrusted.
|
|
73
|
+
The default is expected to change to `False` in a future release.
|
|
74
|
+
|
|
52
75
|
|
|
53
76
|
## Storing and retrieving data
|
|
54
77
|
|
|
@@ -89,13 +112,19 @@ You can store any regular Python scalar in the key-value database:
|
|
|
89
112
|
>>> kv.get('pi')
|
|
90
113
|
3.1415926
|
|
91
114
|
|
|
115
|
+
>>> kv.set('nan', float('nan'))
|
|
116
|
+
>>> import math
|
|
117
|
+
>>> math.isnan(kv.get('nan'))
|
|
118
|
+
True
|
|
119
|
+
|
|
92
120
|
Same for container objects:
|
|
93
121
|
|
|
94
122
|
>>> kv.set('a_list', ['one', 'two', 'three'])
|
|
95
123
|
>>> kv.get('a_list')
|
|
96
124
|
['one', 'two', 'three']
|
|
97
125
|
|
|
98
|
-
In fact, you can store any pickable object
|
|
126
|
+
In fact, you can store any pickable object when
|
|
127
|
+
`allow_pickle=True` (the current default):
|
|
99
128
|
|
|
100
129
|
>>> import datetime
|
|
101
130
|
>>>
|
|
@@ -109,6 +138,10 @@ In fact, you can store any pickable object:
|
|
|
109
138
|
>>> type(a_long_time_ago)
|
|
110
139
|
<class 'datetime.datetime'>
|
|
111
140
|
|
|
141
|
+
For safer behavior with untrusted database contents, disable pickle:
|
|
142
|
+
|
|
143
|
+
>>> safe_kv = tinykv.TinyKV(conn, allow_pickle=False)
|
|
144
|
+
|
|
112
145
|
|
|
113
146
|
## Removing entries
|
|
114
147
|
|
|
@@ -157,11 +190,11 @@ You can also use `get_glob()` to fetch entries based a glob pattern:
|
|
|
157
190
|
{'bar:123': 3}
|
|
158
191
|
|
|
159
192
|
Notice that `get_many()` and `get_glob()` never raise _KeyError_ for
|
|
160
|
-
nonexistent keys. Instead, those keys are
|
|
193
|
+
nonexistent keys. Instead, those keys are simply not present in the
|
|
161
194
|
returned _dict_.
|
|
162
195
|
|
|
163
196
|
You can also remove many entries in one call with
|
|
164
|
-
`remove_many()`. Nonexistent keys are silently ignored
|
|
197
|
+
`remove_many()`. Nonexistent keys are silently ignored.
|
|
165
198
|
|
|
166
199
|
>>> kv.get('one')
|
|
167
200
|
1
|
|
@@ -173,10 +206,54 @@ You can also remove many entries in one call with
|
|
|
173
206
|
KeyError: 'one'
|
|
174
207
|
|
|
175
208
|
|
|
209
|
+
## Using glob patterns
|
|
210
|
+
|
|
211
|
+
Use `get_glob()` to fetch entries using a shell-like wildcard pattern
|
|
212
|
+
from the SQLite key-value store:
|
|
213
|
+
|
|
214
|
+
The pattern uses SQLite's [GLOB](https://www.sqlite.org/lang_expr.html#glob)
|
|
215
|
+
syntax: `*` matches any sequence of characters, and `?` matches a single
|
|
216
|
+
character. Note that patterns are case-sensitive and use literal character
|
|
217
|
+
matching (not regex).
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
## Database setup
|
|
221
|
+
|
|
222
|
+
TinyKV requires the database table to exist before use. Create it with
|
|
223
|
+
`create_schema()`:
|
|
224
|
+
|
|
225
|
+
>>> import sqlite3
|
|
226
|
+
>>> conn = sqlite3.connect(':memory:')
|
|
227
|
+
>>> tinykv.create_schema(conn)
|
|
228
|
+
|
|
229
|
+
If you need idempotent setup, pass `if_not_exists=True`:
|
|
230
|
+
|
|
231
|
+
>>> tinykv.create_schema(conn, if_not_exists=True)
|
|
232
|
+
|
|
233
|
+
You can use a custom table name:
|
|
234
|
+
|
|
235
|
+
>>> tinykv.create_schema(conn, table='my_keys')
|
|
236
|
+
|
|
237
|
+
See the Miscellaneous section for table name requirements.
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
## Use cases
|
|
241
|
+
|
|
242
|
+
TinyKV is a good fit when you need a Python SQLite key-value store for:
|
|
243
|
+
|
|
244
|
+
- application configuration and settings
|
|
245
|
+
- local cache data
|
|
246
|
+
- lightweight metadata storage
|
|
247
|
+
- persistent state for command-line tools
|
|
248
|
+
- embedded storage in desktop scripts or services
|
|
249
|
+
|
|
250
|
+
|
|
176
251
|
Miscellaneous
|
|
177
252
|
-------------
|
|
178
253
|
|
|
179
|
-
- TinyKV keys must be string scalars.
|
|
254
|
+
- TinyKV keys must be non-empty string scalars. Non-string keys raise
|
|
255
|
+
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
256
|
+
(since v0.1.4).
|
|
180
257
|
|
|
181
258
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
182
259
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -198,7 +275,14 @@ Miscellaneous
|
|
|
198
275
|
>>>
|
|
199
276
|
>>> tinykv.create_schema(conn, table=CUSTOM_TABLE)
|
|
200
277
|
>>>
|
|
201
|
-
>>> custom_kv = tinykv.TinyKV(
|
|
278
|
+
>>> custom_kv = tinykv.TinyKV(
|
|
279
|
+
... conn,
|
|
280
|
+
... table=CUSTOM_TABLE,
|
|
281
|
+
... allow_pickle=True,
|
|
282
|
+
... )
|
|
283
|
+
|
|
284
|
+
Table names must match the pattern `[a-zA-Z_][a-zA-Z0-9_]*`. Invalid
|
|
285
|
+
names raise `ValueError`.
|
|
202
286
|
|
|
203
287
|
|
|
204
288
|
Questions? Bugs? Suggestions?
|
|
@@ -7,13 +7,22 @@ name = "tinykv"
|
|
|
7
7
|
authors = [{name = "Flavio Veloso Soares", email = "flaviovs@magnux.com"}]
|
|
8
8
|
license = {file = "LICENSE"}
|
|
9
9
|
readme = "README.md"
|
|
10
|
+
keywords = ["python", "sqlite", "sqlite3", "key-value store", "kv"]
|
|
10
11
|
classifiers = [
|
|
11
12
|
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Database",
|
|
15
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
12
16
|
]
|
|
13
|
-
requires-python = ">=3.
|
|
17
|
+
requires-python = ">=3.11"
|
|
14
18
|
|
|
15
19
|
dynamic = ["version", "description"]
|
|
16
20
|
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/flaviovs/tinykv"
|
|
23
|
+
Repository = "https://github.com/flaviovs/tinykv"
|
|
24
|
+
Issues = "https://github.com/flaviovs/tinykv/issues"
|
|
25
|
+
|
|
17
26
|
[project.optional-dependencies]
|
|
18
27
|
dev = [
|
|
19
28
|
"codespell",
|
|
@@ -40,7 +49,7 @@ pre-commit = "task spellcheck && task test && task lint"
|
|
|
40
49
|
|
|
41
50
|
[tool.mypy]
|
|
42
51
|
files = "."
|
|
43
|
-
python_version = "3.
|
|
52
|
+
python_version = "3.11"
|
|
44
53
|
exclude = [".git", ".venv"]
|
|
45
54
|
strict = true
|
|
46
55
|
warn_redundant_casts = true
|
|
@@ -72,4 +81,5 @@ disable = [
|
|
|
72
81
|
"missing-function-docstring",
|
|
73
82
|
"missing-module-docstring",
|
|
74
83
|
"missing-class-docstring",
|
|
84
|
+
"too-many-return-statements",
|
|
75
85
|
]
|
|
@@ -1,32 +1,66 @@
|
|
|
1
|
-
"""A
|
|
1
|
+
"""A lightweight Python SQLite key-value store built on sqlite3."""
|
|
2
2
|
import enum
|
|
3
3
|
import logging
|
|
4
|
+
import math
|
|
4
5
|
import pickle
|
|
6
|
+
import re
|
|
5
7
|
import sqlite3
|
|
8
|
+
import warnings
|
|
6
9
|
|
|
7
|
-
from
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from typing import Any
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
_TABLE_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate_table_name(table: str) -> None:
|
|
17
|
+
if not isinstance(table, str):
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f'table name must be a string, got {type(table).__name__}'
|
|
20
|
+
)
|
|
21
|
+
if not _TABLE_NAME_RE.match(table):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f'Invalid table name {table!r}: must match pattern '
|
|
24
|
+
r'[a-zA-Z_][a-zA-Z0-9_]*'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_key(key: str) -> None:
|
|
29
|
+
if not isinstance(key, str):
|
|
30
|
+
raise TypeError(
|
|
31
|
+
f'key must be a string, got {type(key).__name__}'
|
|
32
|
+
)
|
|
33
|
+
if not key:
|
|
34
|
+
raise ValueError('key must not be empty')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__version__ = '0.2.1'
|
|
10
38
|
|
|
11
39
|
_DEF_TABLE = 'kv'
|
|
40
|
+
_ALLOW_PICKLE_DEFAULT = object()
|
|
12
41
|
|
|
13
42
|
logger = logging.getLogger(__name__)
|
|
14
43
|
|
|
15
44
|
|
|
16
|
-
def create_schema(conn: sqlite3.Connection, table: str = _DEF_TABLE
|
|
45
|
+
def create_schema(conn: sqlite3.Connection, table: str = _DEF_TABLE,
|
|
46
|
+
if_not_exists: bool = False) -> None:
|
|
17
47
|
"""Create a database table for use with tinykv.
|
|
18
48
|
|
|
19
49
|
Args:
|
|
20
50
|
conn: The SQLite3 connection object.
|
|
21
51
|
table: The table name (default: 'kv').
|
|
52
|
+
if_not_exists: If true, include SQLite's `IF NOT EXISTS` clause so
|
|
53
|
+
repeated schema creation is a no-op.
|
|
22
54
|
|
|
23
55
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
_validate_table_name(table)
|
|
57
|
+
if_not_exists_sql = 'IF NOT EXISTS ' if if_not_exists else ''
|
|
58
|
+
conn.execute(f'CREATE TABLE {if_not_exists_sql}{table} ('
|
|
59
|
+
'k TEXT NOT NULL, '
|
|
60
|
+
't TINYINT NOT NULL CHECK (t BETWEEN 1 AND 7), '
|
|
27
61
|
'v BLOB, '
|
|
28
62
|
'PRIMARY KEY (k)'
|
|
29
|
-
')')
|
|
63
|
+
') WITHOUT ROWID')
|
|
30
64
|
|
|
31
65
|
|
|
32
66
|
class _DType(enum.IntEnum):
|
|
@@ -36,6 +70,7 @@ class _DType(enum.IntEnum):
|
|
|
36
70
|
BOOL = 4
|
|
37
71
|
NUMBER = 5
|
|
38
72
|
PICKLE = 6
|
|
73
|
+
LONG = 7
|
|
39
74
|
|
|
40
75
|
|
|
41
76
|
class TinyKV:
|
|
@@ -49,16 +84,35 @@ class TinyKV:
|
|
|
49
84
|
>>>
|
|
50
85
|
>>> create_schema(conn)
|
|
51
86
|
>>>
|
|
52
|
-
>>> kv = TinyKV(conn)
|
|
87
|
+
>>> kv = TinyKV(conn, allow_pickle=True)
|
|
53
88
|
|
|
54
89
|
Args:
|
|
55
90
|
conn: The SQLite3 connection object.
|
|
56
91
|
table: The table name (default: 'kv').
|
|
92
|
+
allow_pickle: If true, unsupported values are serialized with pickle.
|
|
93
|
+
If omitted, TinyKV currently behaves as true and emits a
|
|
94
|
+
FutureWarning because the default is expected to change in a
|
|
95
|
+
future release. Set to false to disable pickle-based
|
|
96
|
+
storage/deserialization for safer handling of untrusted database
|
|
97
|
+
content.
|
|
57
98
|
|
|
58
99
|
"""
|
|
59
100
|
|
|
60
|
-
def __init__(self, conn: sqlite3.Connection, table: str = _DEF_TABLE
|
|
61
|
-
|
|
101
|
+
def __init__(self, conn: sqlite3.Connection, table: str = _DEF_TABLE,
|
|
102
|
+
allow_pickle: bool | object = _ALLOW_PICKLE_DEFAULT) -> None:
|
|
103
|
+
"""Initialize the key-value object.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
conn: The SQLite3 connection object.
|
|
107
|
+
table: The table name (default: 'kv').
|
|
108
|
+
allow_pickle: If true, fallback to pickle for unsupported value
|
|
109
|
+
types and unpickle stored pickle rows. If omitted, TinyKV
|
|
110
|
+
currently behaves as true and emits a FutureWarning because the
|
|
111
|
+
default is expected to change to false in a future release. If
|
|
112
|
+
false, both operations raise ValueError.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
_validate_table_name(table)
|
|
62
116
|
cur = conn.execute('SELECT name FROM sqlite_master '
|
|
63
117
|
"WHERE type = 'table' AND name = ?",
|
|
64
118
|
(table,))
|
|
@@ -67,6 +121,15 @@ class TinyKV:
|
|
|
67
121
|
|
|
68
122
|
self._conn = conn
|
|
69
123
|
self._table = table
|
|
124
|
+
if allow_pickle is _ALLOW_PICKLE_DEFAULT:
|
|
125
|
+
warnings.warn('allow_pickle currently defaults to True but this '
|
|
126
|
+
'will change to False in a future release; pass '
|
|
127
|
+
'allow_pickle explicitly',
|
|
128
|
+
FutureWarning,
|
|
129
|
+
stacklevel=2)
|
|
130
|
+
self._allow_pickle = True
|
|
131
|
+
else:
|
|
132
|
+
self._allow_pickle = bool(allow_pickle)
|
|
70
133
|
|
|
71
134
|
@property
|
|
72
135
|
def conn(self) -> sqlite3.Connection:
|
|
@@ -77,8 +140,7 @@ class TinyKV:
|
|
|
77
140
|
"""
|
|
78
141
|
return self._conn
|
|
79
142
|
|
|
80
|
-
def _serialize(self, data: Any) ->
|
|
81
|
-
Optional[Union[float, bytes]]]:
|
|
143
|
+
def _serialize(self, data: Any) -> tuple[_DType, float | bytes | None]:
|
|
82
144
|
if data is None:
|
|
83
145
|
return (_DType.NONE, None)
|
|
84
146
|
|
|
@@ -91,10 +153,20 @@ class TinyKV:
|
|
|
91
153
|
if isinstance(data, bool):
|
|
92
154
|
return (_DType.BOOL, int(data))
|
|
93
155
|
|
|
94
|
-
if isinstance(data,
|
|
156
|
+
if isinstance(data, int):
|
|
157
|
+
return (_DType.LONG, str(data).encode('utf-8'))
|
|
158
|
+
|
|
159
|
+
if isinstance(data, float):
|
|
160
|
+
if math.isnan(data):
|
|
161
|
+
return (_DType.PICKLE, pickle.dumps(data))
|
|
95
162
|
return (_DType.NUMBER, data)
|
|
96
163
|
|
|
97
|
-
|
|
164
|
+
if self._allow_pickle:
|
|
165
|
+
return (_DType.PICKLE, pickle.dumps(data))
|
|
166
|
+
|
|
167
|
+
raise ValueError('Cannot store value type without pickle support; '
|
|
168
|
+
'initialize TinyKV with allow_pickle=True to enable '
|
|
169
|
+
'legacy pickled values')
|
|
98
170
|
|
|
99
171
|
def _unserialize(self, dtype: _DType, data: bytes) -> Any:
|
|
100
172
|
if dtype == _DType.NONE:
|
|
@@ -110,16 +182,35 @@ class TinyKV:
|
|
|
110
182
|
return bool(data)
|
|
111
183
|
|
|
112
184
|
if dtype == _DType.NUMBER:
|
|
113
|
-
|
|
114
|
-
|
|
185
|
+
return float(data)
|
|
186
|
+
|
|
187
|
+
if dtype == _DType.LONG:
|
|
188
|
+
return int(data.decode('utf-8'))
|
|
115
189
|
|
|
116
190
|
if dtype == _DType.PICKLE:
|
|
191
|
+
if not self._allow_pickle:
|
|
192
|
+
value = pickle.loads(data)
|
|
193
|
+
if isinstance(value, float) and math.isnan(value):
|
|
194
|
+
return value
|
|
195
|
+
raise ValueError('Cannot deserialize pickled value with '
|
|
196
|
+
'allow_pickle=False')
|
|
117
197
|
return pickle.loads(data)
|
|
118
198
|
|
|
119
|
-
raise ValueError('Unsupported data type {dtype}')
|
|
199
|
+
raise ValueError(f'Unsupported data type {dtype}')
|
|
120
200
|
|
|
121
201
|
def set(self, key: str, value: Any) -> None:
|
|
122
|
-
"""Store a value in the database.
|
|
202
|
+
"""Store a value in the database.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
key: The key to store the value under.
|
|
206
|
+
value: The value to store.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
TypeError: If key is not a string.
|
|
210
|
+
ValueError: If key is an empty string.
|
|
211
|
+
|
|
212
|
+
"""
|
|
213
|
+
_validate_key(key)
|
|
123
214
|
assert self._conn
|
|
124
215
|
dtype, data = self._serialize(value)
|
|
125
216
|
self._conn.execute(f'INSERT OR REPLACE INTO {self._table} (k, t, v) '
|
|
@@ -136,7 +227,7 @@ class TinyKV:
|
|
|
136
227
|
>>> conn = sqlite3.connect(':memory:')
|
|
137
228
|
>>>
|
|
138
229
|
>>> create_schema(conn)
|
|
139
|
-
>>> kv = TinyKV(conn)
|
|
230
|
+
>>> kv = TinyKV(conn, allow_pickle=True)
|
|
140
231
|
>>>
|
|
141
232
|
>>> kv.set('foo', 'bar')
|
|
142
233
|
>>> kv.get('foo')
|
|
@@ -152,8 +243,11 @@ class TinyKV:
|
|
|
152
243
|
Raises:
|
|
153
244
|
KeyError: if the key does not exist and no default is
|
|
154
245
|
provided.
|
|
246
|
+
TypeError: If key is not a string.
|
|
247
|
+
ValueError: If key is an empty string.
|
|
155
248
|
|
|
156
249
|
"""
|
|
250
|
+
_validate_key(key)
|
|
157
251
|
assert self._conn
|
|
158
252
|
cur = self._conn.execute(f'SELECT t, v FROM {self._table} '
|
|
159
253
|
'WHERE k = ?', (key,))
|
|
@@ -164,7 +258,7 @@ class TinyKV:
|
|
|
164
258
|
raise KeyError(key)
|
|
165
259
|
return self._unserialize(_DType(row[0]), row[1])
|
|
166
260
|
|
|
167
|
-
def get_many(self, keys: Iterable[str]) ->
|
|
261
|
+
def get_many(self, keys: Iterable[str]) -> dict[str, Any]:
|
|
168
262
|
"""Get many values from the database.
|
|
169
263
|
|
|
170
264
|
Args:
|
|
@@ -174,16 +268,22 @@ class TinyKV:
|
|
|
174
268
|
A dict where with only the keys found on the database, and their
|
|
175
269
|
respective values.
|
|
176
270
|
|
|
271
|
+
Raises:
|
|
272
|
+
TypeError: If any key is not a string.
|
|
273
|
+
ValueError: If any key is an empty string.
|
|
274
|
+
|
|
177
275
|
"""
|
|
178
276
|
assert self._conn
|
|
179
277
|
tkeys = tuple(keys)
|
|
278
|
+
for k in tkeys:
|
|
279
|
+
_validate_key(k)
|
|
180
280
|
rows = self._conn.execute(f'SELECT k, t, v FROM {self._table} WHERE '
|
|
181
281
|
f'k IN ({", ".join(["?"] * len(tkeys))})',
|
|
182
282
|
tkeys)
|
|
183
283
|
return {r[0]: self._unserialize(_DType(r[1]), r[2])
|
|
184
284
|
for r in rows.fetchall()}
|
|
185
285
|
|
|
186
|
-
def get_glob(self, glob_key: str) ->
|
|
286
|
+
def get_glob(self, glob_key: str) -> dict[str, Any]:
|
|
187
287
|
"""Get many values using a glob pattern.
|
|
188
288
|
|
|
189
289
|
Similar to `get_many()`, but using a glob pattern.
|
|
@@ -195,7 +295,12 @@ class TinyKV:
|
|
|
195
295
|
A dict where keys are the keys matching `glob_key` found
|
|
196
296
|
on the database, and their respective values.
|
|
197
297
|
|
|
298
|
+
Raises:
|
|
299
|
+
TypeError: If glob_key is not a string.
|
|
300
|
+
ValueError: If glob_key is an empty string.
|
|
301
|
+
|
|
198
302
|
"""
|
|
303
|
+
_validate_key(glob_key)
|
|
199
304
|
assert self._conn
|
|
200
305
|
rows = self._conn.execute('SELECT k, t, v '
|
|
201
306
|
f'FROM {self._table} '
|
|
@@ -209,8 +314,16 @@ class TinyKV:
|
|
|
209
314
|
Args:
|
|
210
315
|
kvdict: A mapping of keys to values.
|
|
211
316
|
|
|
317
|
+
Raises:
|
|
318
|
+
TypeError: If any key is not a string.
|
|
319
|
+
ValueError: If any key is an empty string.
|
|
320
|
+
|
|
212
321
|
"""
|
|
213
322
|
assert self._conn
|
|
323
|
+
if not kvdict:
|
|
324
|
+
return
|
|
325
|
+
for k in kvdict:
|
|
326
|
+
_validate_key(k)
|
|
214
327
|
self._conn.execute(f'INSERT OR REPLACE INTO {self._table} (k, t, v) '
|
|
215
328
|
f'VALUES {", ".join(["(?, ?, ?)"] * len(kvdict))}',
|
|
216
329
|
tuple(p[i]
|
|
@@ -228,8 +341,11 @@ class TinyKV:
|
|
|
228
341
|
|
|
229
342
|
Raises:
|
|
230
343
|
KeyError: If the key is not found.
|
|
344
|
+
TypeError: If key is not a string.
|
|
345
|
+
ValueError: If key is an empty string.
|
|
231
346
|
|
|
232
347
|
"""
|
|
348
|
+
_validate_key(key)
|
|
233
349
|
assert self._conn
|
|
234
350
|
cur = self._conn.execute(f'DELETE FROM {self._table} WHERE k = ?',
|
|
235
351
|
(key,))
|
|
@@ -243,9 +359,17 @@ class TinyKV:
|
|
|
243
359
|
|
|
244
360
|
Args:
|
|
245
361
|
keys: Iterable of keys to remove.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
TypeError: If any key is not a string.
|
|
365
|
+
ValueError: If any key is an empty string.
|
|
246
366
|
"""
|
|
247
367
|
assert self._conn
|
|
248
368
|
tkeys = tuple(keys)
|
|
369
|
+
if not tkeys:
|
|
370
|
+
return
|
|
371
|
+
for k in tkeys:
|
|
372
|
+
_validate_key(k)
|
|
249
373
|
self._conn.execute(f'DELETE FROM {self._table} WHERE '
|
|
250
374
|
f'k IN ({", ".join(["?"] * len(tkeys))})',
|
|
251
375
|
tkeys)
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import math
|
|
3
|
+
import pickle
|
|
4
|
+
import sqlite3
|
|
5
|
+
import secrets
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
import warnings
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tinykv import TinyKV, create_schema
|
|
11
|
+
|
|
12
|
+
_TEST_DATA = {
|
|
13
|
+
'none': None,
|
|
14
|
+
'foo': 'bar',
|
|
15
|
+
'bytes': secrets.token_bytes(20),
|
|
16
|
+
'maybe': True,
|
|
17
|
+
'one': 1,
|
|
18
|
+
'pi': 3.1415926,
|
|
19
|
+
'complex': complex(1, 2),
|
|
20
|
+
'now': datetime.datetime.now(),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestKV(unittest.TestCase): # pylint: disable=too-many-public-methods
|
|
25
|
+
|
|
26
|
+
def setUp(self) -> None:
|
|
27
|
+
# pylint: disable-next=consider-using-with
|
|
28
|
+
self._tempdir = tempfile.TemporaryDirectory()
|
|
29
|
+
self._path = Path(self._tempdir.name) / 'db.sqlite3'
|
|
30
|
+
self._conn = sqlite3.connect(self._path)
|
|
31
|
+
create_schema(self._conn)
|
|
32
|
+
|
|
33
|
+
def tearDown(self) -> None:
|
|
34
|
+
self._conn.close()
|
|
35
|
+
self._tempdir.cleanup()
|
|
36
|
+
|
|
37
|
+
def test_set_get(self) -> None:
|
|
38
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
39
|
+
|
|
40
|
+
for k, v in _TEST_DATA.items():
|
|
41
|
+
with self.subTest(k=k):
|
|
42
|
+
db.set(k, v)
|
|
43
|
+
self.assertEqual(db.get(k), v)
|
|
44
|
+
|
|
45
|
+
def test_create_schema_existing_table_raises_by_default(self) -> None:
|
|
46
|
+
with self.assertRaises(sqlite3.OperationalError):
|
|
47
|
+
create_schema(self._conn)
|
|
48
|
+
|
|
49
|
+
def test_create_schema_if_not_exists(self) -> None:
|
|
50
|
+
create_schema(self._conn, if_not_exists=True)
|
|
51
|
+
|
|
52
|
+
def test_set_replace(self) -> None:
|
|
53
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
54
|
+
|
|
55
|
+
db.set('foo', 1)
|
|
56
|
+
db.set('foo', 'bar')
|
|
57
|
+
|
|
58
|
+
self.assertEqual(db.get('foo'), 'bar')
|
|
59
|
+
|
|
60
|
+
def test_get_default(self) -> None:
|
|
61
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
62
|
+
self.assertEqual(db.get('foo', 'bar'), 'bar')
|
|
63
|
+
self.assertIsNone(db.get('foo', None))
|
|
64
|
+
|
|
65
|
+
def test_set_get_persist(self) -> None:
|
|
66
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
67
|
+
|
|
68
|
+
for k, v in _TEST_DATA.items():
|
|
69
|
+
db.set(k, v)
|
|
70
|
+
|
|
71
|
+
self._conn.commit()
|
|
72
|
+
self._conn.close()
|
|
73
|
+
|
|
74
|
+
self._conn = sqlite3.connect(self._path)
|
|
75
|
+
db2 = TinyKV(self._conn, allow_pickle=True)
|
|
76
|
+
for k, v in _TEST_DATA.items():
|
|
77
|
+
with self.subTest(k=k):
|
|
78
|
+
self.assertEqual(db2.get(k), v)
|
|
79
|
+
|
|
80
|
+
def test_get_many(self) -> None:
|
|
81
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
82
|
+
|
|
83
|
+
for k, v in _TEST_DATA.items():
|
|
84
|
+
db.set(k, v)
|
|
85
|
+
|
|
86
|
+
self.assertEqual(db.get_many(_TEST_DATA.keys()), _TEST_DATA)
|
|
87
|
+
|
|
88
|
+
def test_get_many_nonexisting(self) -> None:
|
|
89
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
90
|
+
|
|
91
|
+
db.set('foo', 1)
|
|
92
|
+
db.set('bar', 2)
|
|
93
|
+
|
|
94
|
+
self.assertEqual(db.get_many(('foo', 'bar', 'not-there')),
|
|
95
|
+
{'foo': 1, 'bar': 2})
|
|
96
|
+
|
|
97
|
+
def test_get_glob(self) -> None:
|
|
98
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
99
|
+
|
|
100
|
+
db.set('foo:abc', 1)
|
|
101
|
+
db.set('foo:xyz', 2)
|
|
102
|
+
db.set('bar:abc', 3)
|
|
103
|
+
|
|
104
|
+
self.assertEqual(db.get_glob('foo:*'), {'foo:abc': 1, 'foo:xyz': 2})
|
|
105
|
+
|
|
106
|
+
def test_set_many(self) -> None:
|
|
107
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
108
|
+
|
|
109
|
+
db.set_many(_TEST_DATA)
|
|
110
|
+
|
|
111
|
+
for k, v in _TEST_DATA.items():
|
|
112
|
+
with self.subTest(k=k):
|
|
113
|
+
self.assertEqual(db.get(k), v)
|
|
114
|
+
|
|
115
|
+
def test_set_many_empty_mapping(self) -> None:
|
|
116
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
117
|
+
|
|
118
|
+
db.set_many({})
|
|
119
|
+
|
|
120
|
+
def test_remove(self) -> None:
|
|
121
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
122
|
+
|
|
123
|
+
db.set('foo', 'bar')
|
|
124
|
+
|
|
125
|
+
db.remove('foo')
|
|
126
|
+
|
|
127
|
+
with self.assertRaises(KeyError):
|
|
128
|
+
db.get('foo')
|
|
129
|
+
|
|
130
|
+
def test_remove_nonexistent(self) -> None:
|
|
131
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
132
|
+
with self.assertRaises(KeyError):
|
|
133
|
+
db.remove('nonexistent')
|
|
134
|
+
|
|
135
|
+
def test_remove_many(self) -> None:
|
|
136
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
137
|
+
|
|
138
|
+
db.set('foo', 'bar')
|
|
139
|
+
db.set('bar', 'bar')
|
|
140
|
+
|
|
141
|
+
db.remove_many(('foo', 'bar'))
|
|
142
|
+
|
|
143
|
+
with self.assertRaises(KeyError):
|
|
144
|
+
db.get('foo')
|
|
145
|
+
|
|
146
|
+
with self.assertRaises(KeyError):
|
|
147
|
+
db.get('bar')
|
|
148
|
+
|
|
149
|
+
def test_remove_many_empty(self) -> None:
|
|
150
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
151
|
+
|
|
152
|
+
db.remove_many([])
|
|
153
|
+
|
|
154
|
+
def test_safe_mode_rejects_pickle_on_set(self) -> None:
|
|
155
|
+
db = TinyKV(self._conn, allow_pickle=False)
|
|
156
|
+
|
|
157
|
+
with self.assertRaisesRegex(ValueError, 'allow_pickle=True'):
|
|
158
|
+
db.set('now', datetime.datetime.now())
|
|
159
|
+
|
|
160
|
+
def test_safe_mode_rejects_existing_pickled_row(self) -> None:
|
|
161
|
+
payload = pickle.dumps(datetime.datetime(2022, 3, 19, 20, 15, 5))
|
|
162
|
+
self._conn.execute('INSERT INTO kv (k, t, v) VALUES (?, ?, ?)',
|
|
163
|
+
('pickled', 6, payload))
|
|
164
|
+
|
|
165
|
+
db = TinyKV(self._conn, allow_pickle=False)
|
|
166
|
+
with self.assertRaisesRegex(ValueError, 'allow_pickle=False'):
|
|
167
|
+
db.get('pickled')
|
|
168
|
+
|
|
169
|
+
def test_compat_mode_allows_pickle_roundtrip(self) -> None:
|
|
170
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
171
|
+
dt = datetime.datetime(2022, 3, 19, 20, 15, 5)
|
|
172
|
+
|
|
173
|
+
db.set('dt', dt)
|
|
174
|
+
|
|
175
|
+
self.assertEqual(db.get('dt'), dt)
|
|
176
|
+
|
|
177
|
+
def test_implicit_allow_pickle_warns(self) -> None:
|
|
178
|
+
with self.assertWarnsRegex(FutureWarning, 'allow_pickle'):
|
|
179
|
+
TinyKV(self._conn)
|
|
180
|
+
|
|
181
|
+
def test_explicit_allow_pickle_true_does_not_warn(self) -> None:
|
|
182
|
+
with warnings.catch_warnings(record=True) as warns:
|
|
183
|
+
warnings.simplefilter('always')
|
|
184
|
+
TinyKV(self._conn, allow_pickle=True)
|
|
185
|
+
self.assertEqual(warns, [])
|
|
186
|
+
|
|
187
|
+
def test_explicit_allow_pickle_false_does_not_warn(self) -> None:
|
|
188
|
+
with warnings.catch_warnings(record=True) as warns:
|
|
189
|
+
warnings.simplefilter('always')
|
|
190
|
+
TinyKV(self._conn, allow_pickle=False)
|
|
191
|
+
self.assertEqual(warns, [])
|
|
192
|
+
|
|
193
|
+
def test_large_int_roundtrip(self) -> None:
|
|
194
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
195
|
+
|
|
196
|
+
test_cases = [
|
|
197
|
+
(2**53 - 1, 'max safe integer'),
|
|
198
|
+
(2**53, 'just over max safe integer'),
|
|
199
|
+
(2**53 + 1, 'larger than max safe integer'),
|
|
200
|
+
(10**20, 'very large positive'),
|
|
201
|
+
(-(10**20), 'very large negative'),
|
|
202
|
+
(0, 'zero'),
|
|
203
|
+
(1, 'small positive'),
|
|
204
|
+
(-1, 'small negative'),
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
for value, description in test_cases:
|
|
208
|
+
with self.subTest(value=value, description=description):
|
|
209
|
+
db.set('large_int', value)
|
|
210
|
+
self.assertEqual(db.get('large_int'), value)
|
|
211
|
+
|
|
212
|
+
def test_integral_float_roundtrip(self) -> None:
|
|
213
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
214
|
+
|
|
215
|
+
test_cases = [
|
|
216
|
+
(1.0, 'one as float'),
|
|
217
|
+
(0.0, 'zero as float'),
|
|
218
|
+
(-0.0, 'negative zero as float'),
|
|
219
|
+
(1.5, 'one point five'),
|
|
220
|
+
(-2.5, 'negative two point five'),
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
for value, description in test_cases:
|
|
224
|
+
with self.subTest(value=value, description=description):
|
|
225
|
+
db.set('integral_float', value)
|
|
226
|
+
result = db.get('integral_float')
|
|
227
|
+
self.assertEqual(result, value)
|
|
228
|
+
self.assertIsInstance(result, float)
|
|
229
|
+
|
|
230
|
+
def test_nan_roundtrip(self) -> None:
|
|
231
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
232
|
+
|
|
233
|
+
db.set('nan', float('nan'))
|
|
234
|
+
result = db.get('nan')
|
|
235
|
+
self.assertTrue(math.isnan(result))
|
|
236
|
+
|
|
237
|
+
def test_nan_roundtrip_safe_mode(self) -> None:
|
|
238
|
+
db = TinyKV(self._conn, allow_pickle=False)
|
|
239
|
+
|
|
240
|
+
db.set('nan', float('nan'))
|
|
241
|
+
result = db.get('nan')
|
|
242
|
+
self.assertTrue(math.isnan(result))
|
|
243
|
+
|
|
244
|
+
def test_inf_roundtrip(self) -> None:
|
|
245
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
246
|
+
|
|
247
|
+
db.set('inf', float('inf'))
|
|
248
|
+
self.assertEqual(db.get('inf'), float('inf'))
|
|
249
|
+
|
|
250
|
+
db.set('neg_inf', float('-inf'))
|
|
251
|
+
self.assertEqual(db.get('neg_inf'), float('-inf'))
|
|
252
|
+
|
|
253
|
+
def test_set_rejects_non_string_key(self) -> None:
|
|
254
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
255
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
256
|
+
db.set(123, 'value') # type: ignore[arg-type]
|
|
257
|
+
|
|
258
|
+
def test_set_rejects_empty_key(self) -> None:
|
|
259
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
260
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
261
|
+
db.set('', 'value')
|
|
262
|
+
|
|
263
|
+
def test_get_rejects_non_string_key(self) -> None:
|
|
264
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
265
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
266
|
+
db.get(123) # type: ignore[arg-type]
|
|
267
|
+
|
|
268
|
+
def test_get_rejects_empty_key(self) -> None:
|
|
269
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
270
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
271
|
+
db.get('')
|
|
272
|
+
|
|
273
|
+
def test_remove_rejects_non_string_key(self) -> None:
|
|
274
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
275
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
276
|
+
db.remove(123) # type: ignore[arg-type]
|
|
277
|
+
|
|
278
|
+
def test_remove_rejects_empty_key(self) -> None:
|
|
279
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
280
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
281
|
+
db.remove('')
|
|
282
|
+
|
|
283
|
+
def test_get_many_rejects_non_string_key(self) -> None:
|
|
284
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
285
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
286
|
+
db.get_many([123]) # type: ignore[list-item]
|
|
287
|
+
|
|
288
|
+
def test_get_many_rejects_empty_key(self) -> None:
|
|
289
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
290
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
291
|
+
db.get_many([''])
|
|
292
|
+
|
|
293
|
+
def test_set_many_rejects_non_string_key(self) -> None:
|
|
294
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
295
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
296
|
+
db.set_many({123: 'value'}) # type: ignore[dict-item]
|
|
297
|
+
|
|
298
|
+
def test_set_many_rejects_empty_key(self) -> None:
|
|
299
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
300
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
301
|
+
db.set_many({'': 'value'})
|
|
302
|
+
|
|
303
|
+
def test_remove_many_rejects_non_string_key(self) -> None:
|
|
304
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
305
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
306
|
+
db.remove_many([123]) # type: ignore[list-item]
|
|
307
|
+
|
|
308
|
+
def test_remove_many_rejects_empty_key(self) -> None:
|
|
309
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
310
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
311
|
+
db.remove_many([''])
|
|
312
|
+
|
|
313
|
+
def test_get_glob_rejects_non_string_key(self) -> None:
|
|
314
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
315
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
316
|
+
db.get_glob(123) # type: ignore[arg-type]
|
|
317
|
+
|
|
318
|
+
def test_get_glob_rejects_empty_key(self) -> None:
|
|
319
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
320
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
321
|
+
db.get_glob('')
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import datetime
|
|
2
|
-
import unittest
|
|
3
|
-
import sqlite3
|
|
4
|
-
import secrets
|
|
5
|
-
import tempfile
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from tinykv import TinyKV, create_schema
|
|
8
|
-
|
|
9
|
-
_TEST_DATA = {
|
|
10
|
-
'none': None,
|
|
11
|
-
'foo': 'bar',
|
|
12
|
-
'bytes': secrets.token_bytes(20),
|
|
13
|
-
'maybe': True,
|
|
14
|
-
'one': 1,
|
|
15
|
-
'pi': 3.1415926,
|
|
16
|
-
'complex': complex(1, 2),
|
|
17
|
-
'now': datetime.datetime.now(),
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class TestKV(unittest.TestCase):
|
|
22
|
-
|
|
23
|
-
def setUp(self) -> None:
|
|
24
|
-
# pylint: disable-next=consider-using-with
|
|
25
|
-
self._tempdir = tempfile.TemporaryDirectory()
|
|
26
|
-
self._path = Path(self._tempdir.name) / 'db.sqlite3'
|
|
27
|
-
self._conn = sqlite3.connect(self._path)
|
|
28
|
-
create_schema(self._conn)
|
|
29
|
-
|
|
30
|
-
def tearDown(self) -> None:
|
|
31
|
-
self._conn.close()
|
|
32
|
-
self._tempdir.cleanup()
|
|
33
|
-
|
|
34
|
-
def test_set_get(self) -> None:
|
|
35
|
-
db = TinyKV(self._conn)
|
|
36
|
-
|
|
37
|
-
for k, v in _TEST_DATA.items():
|
|
38
|
-
with self.subTest(k=k):
|
|
39
|
-
db.set(k, v)
|
|
40
|
-
self.assertEqual(db.get(k), v)
|
|
41
|
-
|
|
42
|
-
def test_set_replace(self) -> None:
|
|
43
|
-
db = TinyKV(self._conn)
|
|
44
|
-
|
|
45
|
-
db.set('foo', 1)
|
|
46
|
-
db.set('foo', 'bar')
|
|
47
|
-
|
|
48
|
-
self.assertEqual(db.get('foo'), 'bar')
|
|
49
|
-
|
|
50
|
-
def test_get_default(self) -> None:
|
|
51
|
-
db = TinyKV(self._conn)
|
|
52
|
-
self.assertEqual(db.get('foo', 'bar'), 'bar')
|
|
53
|
-
self.assertIsNone(db.get('foo', None))
|
|
54
|
-
|
|
55
|
-
def test_set_get_persist(self) -> None:
|
|
56
|
-
db = TinyKV(self._conn)
|
|
57
|
-
|
|
58
|
-
for k, v in _TEST_DATA.items():
|
|
59
|
-
db.set(k, v)
|
|
60
|
-
|
|
61
|
-
self._conn.commit()
|
|
62
|
-
self._conn.close()
|
|
63
|
-
|
|
64
|
-
self._conn = sqlite3.connect(self._path)
|
|
65
|
-
db2 = TinyKV(self._conn)
|
|
66
|
-
for k, v in _TEST_DATA.items():
|
|
67
|
-
with self.subTest(k=k):
|
|
68
|
-
self.assertEqual(db2.get(k), v)
|
|
69
|
-
|
|
70
|
-
def test_get_many(self) -> None:
|
|
71
|
-
db = TinyKV(self._conn)
|
|
72
|
-
|
|
73
|
-
for k, v in _TEST_DATA.items():
|
|
74
|
-
db.set(k, v)
|
|
75
|
-
|
|
76
|
-
self.assertEqual(db.get_many(_TEST_DATA.keys()), _TEST_DATA)
|
|
77
|
-
|
|
78
|
-
def test_get_many_nonexisting(self) -> None:
|
|
79
|
-
db = TinyKV(self._conn)
|
|
80
|
-
|
|
81
|
-
db.set('foo', 1)
|
|
82
|
-
db.set('bar', 2)
|
|
83
|
-
|
|
84
|
-
self.assertEqual(db.get_many(('foo', 'bar', 'not-there')),
|
|
85
|
-
{'foo': 1, 'bar': 2})
|
|
86
|
-
|
|
87
|
-
def test_get_glob(self) -> None:
|
|
88
|
-
db = TinyKV(self._conn)
|
|
89
|
-
|
|
90
|
-
db.set('foo:abc', 1)
|
|
91
|
-
db.set('foo:xyz', 2)
|
|
92
|
-
db.set('bar:abc', 3)
|
|
93
|
-
|
|
94
|
-
self.assertEqual(db.get_glob('foo:*'), {'foo:abc': 1, 'foo:xyz': 2})
|
|
95
|
-
|
|
96
|
-
def test_set_many(self) -> None:
|
|
97
|
-
db = TinyKV(self._conn)
|
|
98
|
-
|
|
99
|
-
db.set_many(_TEST_DATA)
|
|
100
|
-
|
|
101
|
-
for k, v in _TEST_DATA.items():
|
|
102
|
-
with self.subTest(k=k):
|
|
103
|
-
self.assertEqual(db.get(k), v)
|
|
104
|
-
|
|
105
|
-
def test_remove(self) -> None:
|
|
106
|
-
db = TinyKV(self._conn)
|
|
107
|
-
|
|
108
|
-
db.set('foo', 'bar')
|
|
109
|
-
|
|
110
|
-
db.remove('foo')
|
|
111
|
-
|
|
112
|
-
with self.assertRaises(KeyError):
|
|
113
|
-
db.get('foo')
|
|
114
|
-
|
|
115
|
-
def test_remove_nonexistent(self) -> None:
|
|
116
|
-
db = TinyKV(self._conn)
|
|
117
|
-
with self.assertRaises(KeyError):
|
|
118
|
-
db.remove('nonexistent')
|
|
119
|
-
|
|
120
|
-
def test_remove_many(self) -> None:
|
|
121
|
-
db = TinyKV(self._conn)
|
|
122
|
-
|
|
123
|
-
db.set('foo', 'bar')
|
|
124
|
-
db.set('bar', 'bar')
|
|
125
|
-
|
|
126
|
-
db.remove_many(('foo', 'bar'))
|
|
127
|
-
|
|
128
|
-
with self.assertRaises(KeyError):
|
|
129
|
-
db.get('foo')
|
|
130
|
-
|
|
131
|
-
with self.assertRaises(KeyError):
|
|
132
|
-
db.get('bar')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|