tinykv 0.1.2__tar.gz → 0.2.0__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.0/CHANGELOG.md +58 -0
- {tinykv-0.1.2 → tinykv-0.2.0}/PKG-INFO +107 -20
- {tinykv-0.1.2 → tinykv-0.2.0}/README.md +97 -17
- {tinykv-0.1.2 → tinykv-0.2.0}/pyproject.toml +12 -2
- {tinykv-0.1.2 → tinykv-0.2.0}/src/tinykv/__init__.py +140 -20
- tinykv-0.2.0/tests/test_tinykv.py +314 -0
- tinykv-0.1.2/tests/test_tinykv.py +0 -132
- {tinykv-0.1.2 → tinykv-0.2.0}/.flake8 +0 -0
- {tinykv-0.1.2 → tinykv-0.2.0}/.gitignore +0 -0
- {tinykv-0.1.2 → tinykv-0.2.0}/LICENSE +0 -0
- {tinykv-0.1.2 → tinykv-0.2.0}/src/tinykv/py.typed +0 -0
- {tinykv-0.1.2 → tinykv-0.2.0}/tests/__init__.py +0 -0
- {tinykv-0.1.2 → tinykv-0.2.0}/tests/test_doc.py +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
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.0] - 2026-03-11
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **BC BREAK** Minimum Python version is now 3.11 (was 3.7). This enables
|
|
16
|
+
modern type hint syntax and removes compatibility code for older Python
|
|
17
|
+
versions.
|
|
18
|
+
- **BC BREAK** Keys are now case-sensitive. Previously, the database used
|
|
19
|
+
case-insensitive collation, so `'Foo'` and `'foo'` referred to the same key.
|
|
20
|
+
Existing tables using the old schema will continue to work with the old
|
|
21
|
+
behavior, but new tables created with `create_schema()` will use
|
|
22
|
+
case-sensitive keys.
|
|
23
|
+
- Tables created with `create_schema()` now use SQLite `WITHOUT ROWID` storage
|
|
24
|
+
for better performance and smaller database size
|
|
25
|
+
|
|
26
|
+
## [0.1.3] - 2026-03-11
|
|
27
|
+
|
|
28
|
+
Pickle safety improvements and bug fixes.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Add explicit pickle safety mode with user-facing warning
|
|
32
|
+
- Enforce string key contract: non-string keys raise `TypeError`, empty
|
|
33
|
+
strings raise `ValueError`
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
- Validate table names to prevent SQL injection via table identifier
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- Fix precision loss when roundtripping large integers through the database
|
|
40
|
+
- Fix NaN roundtrip crash by storing as pickle and always deserializing
|
|
41
|
+
- Fix `set_many({})` crash by making empty batch a no-op
|
|
42
|
+
- Fix `remove_many([])` crash by making empty batch a no-op
|
|
43
|
+
- Fix integral float type loss (e.g., `1.0` returned as `1`)
|
|
44
|
+
|
|
45
|
+
## [0.1.2] - 2025-07-28
|
|
46
|
+
|
|
47
|
+
Package reorganization for better pip compatibility.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Reorganize package into standard "src" layout for better pip install
|
|
51
|
+
experience
|
|
52
|
+
|
|
53
|
+
## [0.1.1] - 2023-03-14
|
|
54
|
+
|
|
55
|
+
Initial release of tinykv.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- Initial release
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinykv
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: A
|
|
3
|
+
Version: 0.2.0
|
|
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,50 @@ 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
|
+
You can use a custom table name:
|
|
259
|
+
|
|
260
|
+
>>> tinykv.create_schema(conn, table='my_keys')
|
|
261
|
+
|
|
262
|
+
See the Miscellaneous section for table name requirements.
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
## Use cases
|
|
266
|
+
|
|
267
|
+
TinyKV is a good fit when you need a Python SQLite key-value store for:
|
|
268
|
+
|
|
269
|
+
- application configuration and settings
|
|
270
|
+
- local cache data
|
|
271
|
+
- lightweight metadata storage
|
|
272
|
+
- persistent state for command-line tools
|
|
273
|
+
- embedded storage in desktop scripts or services
|
|
274
|
+
|
|
275
|
+
|
|
198
276
|
Miscellaneous
|
|
199
277
|
-------------
|
|
200
278
|
|
|
201
|
-
- TinyKV keys must be string scalars.
|
|
279
|
+
- TinyKV keys must be non-empty string scalars. Non-string keys raise
|
|
280
|
+
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
281
|
+
(since v0.1.4).
|
|
202
282
|
|
|
203
283
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
204
284
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -220,7 +300,14 @@ Miscellaneous
|
|
|
220
300
|
>>>
|
|
221
301
|
>>> tinykv.create_schema(conn, table=CUSTOM_TABLE)
|
|
222
302
|
>>>
|
|
223
|
-
>>> custom_kv = tinykv.TinyKV(
|
|
303
|
+
>>> custom_kv = tinykv.TinyKV(
|
|
304
|
+
... conn,
|
|
305
|
+
... table=CUSTOM_TABLE,
|
|
306
|
+
... allow_pickle=True,
|
|
307
|
+
... )
|
|
308
|
+
|
|
309
|
+
Table names must match the pattern `[a-zA-Z_][a-zA-Z0-9_]*`. Invalid
|
|
310
|
+
names raise `ValueError`.
|
|
224
311
|
|
|
225
312
|
|
|
226
313
|
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,50 @@ 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
|
+
You can use a custom table name:
|
|
230
|
+
|
|
231
|
+
>>> tinykv.create_schema(conn, table='my_keys')
|
|
232
|
+
|
|
233
|
+
See the Miscellaneous section for table name requirements.
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
## Use cases
|
|
237
|
+
|
|
238
|
+
TinyKV is a good fit when you need a Python SQLite key-value store for:
|
|
239
|
+
|
|
240
|
+
- application configuration and settings
|
|
241
|
+
- local cache data
|
|
242
|
+
- lightweight metadata storage
|
|
243
|
+
- persistent state for command-line tools
|
|
244
|
+
- embedded storage in desktop scripts or services
|
|
245
|
+
|
|
246
|
+
|
|
176
247
|
Miscellaneous
|
|
177
248
|
-------------
|
|
178
249
|
|
|
179
|
-
- TinyKV keys must be string scalars.
|
|
250
|
+
- TinyKV keys must be non-empty string scalars. Non-string keys raise
|
|
251
|
+
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
252
|
+
(since v0.1.4).
|
|
180
253
|
|
|
181
254
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
182
255
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -198,7 +271,14 @@ Miscellaneous
|
|
|
198
271
|
>>>
|
|
199
272
|
>>> tinykv.create_schema(conn, table=CUSTOM_TABLE)
|
|
200
273
|
>>>
|
|
201
|
-
>>> custom_kv = tinykv.TinyKV(
|
|
274
|
+
>>> custom_kv = tinykv.TinyKV(
|
|
275
|
+
... conn,
|
|
276
|
+
... table=CUSTOM_TABLE,
|
|
277
|
+
... allow_pickle=True,
|
|
278
|
+
... )
|
|
279
|
+
|
|
280
|
+
Table names must match the pattern `[a-zA-Z_][a-zA-Z0-9_]*`. Invalid
|
|
281
|
+
names raise `ValueError`.
|
|
202
282
|
|
|
203
283
|
|
|
204
284
|
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,14 +1,43 @@
|
|
|
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.0'
|
|
10
38
|
|
|
11
39
|
_DEF_TABLE = 'kv'
|
|
40
|
+
_ALLOW_PICKLE_DEFAULT = object()
|
|
12
41
|
|
|
13
42
|
logger = logging.getLogger(__name__)
|
|
14
43
|
|
|
@@ -21,12 +50,13 @@ def create_schema(conn: sqlite3.Connection, table: str = _DEF_TABLE) -> None:
|
|
|
21
50
|
table: The table name (default: 'kv').
|
|
22
51
|
|
|
23
52
|
"""
|
|
53
|
+
_validate_table_name(table)
|
|
24
54
|
conn.execute(f'CREATE TABLE {table} ('
|
|
25
|
-
'k TEXT NOT NULL
|
|
26
|
-
't TINYINT NOT NULL CHECK (t BETWEEN 1 AND
|
|
55
|
+
'k TEXT NOT NULL, '
|
|
56
|
+
't TINYINT NOT NULL CHECK (t BETWEEN 1 AND 7), '
|
|
27
57
|
'v BLOB, '
|
|
28
58
|
'PRIMARY KEY (k)'
|
|
29
|
-
')')
|
|
59
|
+
') WITHOUT ROWID')
|
|
30
60
|
|
|
31
61
|
|
|
32
62
|
class _DType(enum.IntEnum):
|
|
@@ -36,6 +66,7 @@ class _DType(enum.IntEnum):
|
|
|
36
66
|
BOOL = 4
|
|
37
67
|
NUMBER = 5
|
|
38
68
|
PICKLE = 6
|
|
69
|
+
LONG = 7
|
|
39
70
|
|
|
40
71
|
|
|
41
72
|
class TinyKV:
|
|
@@ -49,16 +80,35 @@ class TinyKV:
|
|
|
49
80
|
>>>
|
|
50
81
|
>>> create_schema(conn)
|
|
51
82
|
>>>
|
|
52
|
-
>>> kv = TinyKV(conn)
|
|
83
|
+
>>> kv = TinyKV(conn, allow_pickle=True)
|
|
53
84
|
|
|
54
85
|
Args:
|
|
55
86
|
conn: The SQLite3 connection object.
|
|
56
87
|
table: The table name (default: 'kv').
|
|
88
|
+
allow_pickle: If true, unsupported values are serialized with pickle.
|
|
89
|
+
If omitted, TinyKV currently behaves as true and emits a
|
|
90
|
+
FutureWarning because the default is expected to change in a
|
|
91
|
+
future release. Set to false to disable pickle-based
|
|
92
|
+
storage/deserialization for safer handling of untrusted database
|
|
93
|
+
content.
|
|
57
94
|
|
|
58
95
|
"""
|
|
59
96
|
|
|
60
|
-
def __init__(self, conn: sqlite3.Connection, table: str = _DEF_TABLE
|
|
61
|
-
|
|
97
|
+
def __init__(self, conn: sqlite3.Connection, table: str = _DEF_TABLE,
|
|
98
|
+
allow_pickle: bool | object = _ALLOW_PICKLE_DEFAULT) -> None:
|
|
99
|
+
"""Initialize the key-value object.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
conn: The SQLite3 connection object.
|
|
103
|
+
table: The table name (default: 'kv').
|
|
104
|
+
allow_pickle: If true, fallback to pickle for unsupported value
|
|
105
|
+
types and unpickle stored pickle rows. If omitted, TinyKV
|
|
106
|
+
currently behaves as true and emits a FutureWarning because the
|
|
107
|
+
default is expected to change to false in a future release. If
|
|
108
|
+
false, both operations raise ValueError.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
_validate_table_name(table)
|
|
62
112
|
cur = conn.execute('SELECT name FROM sqlite_master '
|
|
63
113
|
"WHERE type = 'table' AND name = ?",
|
|
64
114
|
(table,))
|
|
@@ -67,6 +117,15 @@ class TinyKV:
|
|
|
67
117
|
|
|
68
118
|
self._conn = conn
|
|
69
119
|
self._table = table
|
|
120
|
+
if allow_pickle is _ALLOW_PICKLE_DEFAULT:
|
|
121
|
+
warnings.warn('allow_pickle currently defaults to True but this '
|
|
122
|
+
'will change to False in a future release; pass '
|
|
123
|
+
'allow_pickle explicitly',
|
|
124
|
+
FutureWarning,
|
|
125
|
+
stacklevel=2)
|
|
126
|
+
self._allow_pickle = True
|
|
127
|
+
else:
|
|
128
|
+
self._allow_pickle = bool(allow_pickle)
|
|
70
129
|
|
|
71
130
|
@property
|
|
72
131
|
def conn(self) -> sqlite3.Connection:
|
|
@@ -77,8 +136,7 @@ class TinyKV:
|
|
|
77
136
|
"""
|
|
78
137
|
return self._conn
|
|
79
138
|
|
|
80
|
-
def _serialize(self, data: Any) ->
|
|
81
|
-
Optional[Union[float, bytes]]]:
|
|
139
|
+
def _serialize(self, data: Any) -> tuple[_DType, float | bytes | None]:
|
|
82
140
|
if data is None:
|
|
83
141
|
return (_DType.NONE, None)
|
|
84
142
|
|
|
@@ -91,10 +149,20 @@ class TinyKV:
|
|
|
91
149
|
if isinstance(data, bool):
|
|
92
150
|
return (_DType.BOOL, int(data))
|
|
93
151
|
|
|
94
|
-
if isinstance(data,
|
|
152
|
+
if isinstance(data, int):
|
|
153
|
+
return (_DType.LONG, str(data).encode('utf-8'))
|
|
154
|
+
|
|
155
|
+
if isinstance(data, float):
|
|
156
|
+
if math.isnan(data):
|
|
157
|
+
return (_DType.PICKLE, pickle.dumps(data))
|
|
95
158
|
return (_DType.NUMBER, data)
|
|
96
159
|
|
|
97
|
-
|
|
160
|
+
if self._allow_pickle:
|
|
161
|
+
return (_DType.PICKLE, pickle.dumps(data))
|
|
162
|
+
|
|
163
|
+
raise ValueError('Cannot store value type without pickle support; '
|
|
164
|
+
'initialize TinyKV with allow_pickle=True to enable '
|
|
165
|
+
'legacy pickled values')
|
|
98
166
|
|
|
99
167
|
def _unserialize(self, dtype: _DType, data: bytes) -> Any:
|
|
100
168
|
if dtype == _DType.NONE:
|
|
@@ -110,16 +178,35 @@ class TinyKV:
|
|
|
110
178
|
return bool(data)
|
|
111
179
|
|
|
112
180
|
if dtype == _DType.NUMBER:
|
|
113
|
-
|
|
114
|
-
|
|
181
|
+
return float(data)
|
|
182
|
+
|
|
183
|
+
if dtype == _DType.LONG:
|
|
184
|
+
return int(data.decode('utf-8'))
|
|
115
185
|
|
|
116
186
|
if dtype == _DType.PICKLE:
|
|
187
|
+
if not self._allow_pickle:
|
|
188
|
+
value = pickle.loads(data)
|
|
189
|
+
if isinstance(value, float) and math.isnan(value):
|
|
190
|
+
return value
|
|
191
|
+
raise ValueError('Cannot deserialize pickled value with '
|
|
192
|
+
'allow_pickle=False')
|
|
117
193
|
return pickle.loads(data)
|
|
118
194
|
|
|
119
|
-
raise ValueError('Unsupported data type {dtype}')
|
|
195
|
+
raise ValueError(f'Unsupported data type {dtype}')
|
|
120
196
|
|
|
121
197
|
def set(self, key: str, value: Any) -> None:
|
|
122
|
-
"""Store a value in the database.
|
|
198
|
+
"""Store a value in the database.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
key: The key to store the value under.
|
|
202
|
+
value: The value to store.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
TypeError: If key is not a string.
|
|
206
|
+
ValueError: If key is an empty string.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
_validate_key(key)
|
|
123
210
|
assert self._conn
|
|
124
211
|
dtype, data = self._serialize(value)
|
|
125
212
|
self._conn.execute(f'INSERT OR REPLACE INTO {self._table} (k, t, v) '
|
|
@@ -136,7 +223,7 @@ class TinyKV:
|
|
|
136
223
|
>>> conn = sqlite3.connect(':memory:')
|
|
137
224
|
>>>
|
|
138
225
|
>>> create_schema(conn)
|
|
139
|
-
>>> kv = TinyKV(conn)
|
|
226
|
+
>>> kv = TinyKV(conn, allow_pickle=True)
|
|
140
227
|
>>>
|
|
141
228
|
>>> kv.set('foo', 'bar')
|
|
142
229
|
>>> kv.get('foo')
|
|
@@ -152,8 +239,11 @@ class TinyKV:
|
|
|
152
239
|
Raises:
|
|
153
240
|
KeyError: if the key does not exist and no default is
|
|
154
241
|
provided.
|
|
242
|
+
TypeError: If key is not a string.
|
|
243
|
+
ValueError: If key is an empty string.
|
|
155
244
|
|
|
156
245
|
"""
|
|
246
|
+
_validate_key(key)
|
|
157
247
|
assert self._conn
|
|
158
248
|
cur = self._conn.execute(f'SELECT t, v FROM {self._table} '
|
|
159
249
|
'WHERE k = ?', (key,))
|
|
@@ -164,7 +254,7 @@ class TinyKV:
|
|
|
164
254
|
raise KeyError(key)
|
|
165
255
|
return self._unserialize(_DType(row[0]), row[1])
|
|
166
256
|
|
|
167
|
-
def get_many(self, keys: Iterable[str]) ->
|
|
257
|
+
def get_many(self, keys: Iterable[str]) -> dict[str, Any]:
|
|
168
258
|
"""Get many values from the database.
|
|
169
259
|
|
|
170
260
|
Args:
|
|
@@ -174,16 +264,22 @@ class TinyKV:
|
|
|
174
264
|
A dict where with only the keys found on the database, and their
|
|
175
265
|
respective values.
|
|
176
266
|
|
|
267
|
+
Raises:
|
|
268
|
+
TypeError: If any key is not a string.
|
|
269
|
+
ValueError: If any key is an empty string.
|
|
270
|
+
|
|
177
271
|
"""
|
|
178
272
|
assert self._conn
|
|
179
273
|
tkeys = tuple(keys)
|
|
274
|
+
for k in tkeys:
|
|
275
|
+
_validate_key(k)
|
|
180
276
|
rows = self._conn.execute(f'SELECT k, t, v FROM {self._table} WHERE '
|
|
181
277
|
f'k IN ({", ".join(["?"] * len(tkeys))})',
|
|
182
278
|
tkeys)
|
|
183
279
|
return {r[0]: self._unserialize(_DType(r[1]), r[2])
|
|
184
280
|
for r in rows.fetchall()}
|
|
185
281
|
|
|
186
|
-
def get_glob(self, glob_key: str) ->
|
|
282
|
+
def get_glob(self, glob_key: str) -> dict[str, Any]:
|
|
187
283
|
"""Get many values using a glob pattern.
|
|
188
284
|
|
|
189
285
|
Similar to `get_many()`, but using a glob pattern.
|
|
@@ -195,7 +291,12 @@ class TinyKV:
|
|
|
195
291
|
A dict where keys are the keys matching `glob_key` found
|
|
196
292
|
on the database, and their respective values.
|
|
197
293
|
|
|
294
|
+
Raises:
|
|
295
|
+
TypeError: If glob_key is not a string.
|
|
296
|
+
ValueError: If glob_key is an empty string.
|
|
297
|
+
|
|
198
298
|
"""
|
|
299
|
+
_validate_key(glob_key)
|
|
199
300
|
assert self._conn
|
|
200
301
|
rows = self._conn.execute('SELECT k, t, v '
|
|
201
302
|
f'FROM {self._table} '
|
|
@@ -209,8 +310,16 @@ class TinyKV:
|
|
|
209
310
|
Args:
|
|
210
311
|
kvdict: A mapping of keys to values.
|
|
211
312
|
|
|
313
|
+
Raises:
|
|
314
|
+
TypeError: If any key is not a string.
|
|
315
|
+
ValueError: If any key is an empty string.
|
|
316
|
+
|
|
212
317
|
"""
|
|
213
318
|
assert self._conn
|
|
319
|
+
if not kvdict:
|
|
320
|
+
return
|
|
321
|
+
for k in kvdict:
|
|
322
|
+
_validate_key(k)
|
|
214
323
|
self._conn.execute(f'INSERT OR REPLACE INTO {self._table} (k, t, v) '
|
|
215
324
|
f'VALUES {", ".join(["(?, ?, ?)"] * len(kvdict))}',
|
|
216
325
|
tuple(p[i]
|
|
@@ -228,8 +337,11 @@ class TinyKV:
|
|
|
228
337
|
|
|
229
338
|
Raises:
|
|
230
339
|
KeyError: If the key is not found.
|
|
340
|
+
TypeError: If key is not a string.
|
|
341
|
+
ValueError: If key is an empty string.
|
|
231
342
|
|
|
232
343
|
"""
|
|
344
|
+
_validate_key(key)
|
|
233
345
|
assert self._conn
|
|
234
346
|
cur = self._conn.execute(f'DELETE FROM {self._table} WHERE k = ?',
|
|
235
347
|
(key,))
|
|
@@ -243,9 +355,17 @@ class TinyKV:
|
|
|
243
355
|
|
|
244
356
|
Args:
|
|
245
357
|
keys: Iterable of keys to remove.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
TypeError: If any key is not a string.
|
|
361
|
+
ValueError: If any key is an empty string.
|
|
246
362
|
"""
|
|
247
363
|
assert self._conn
|
|
248
364
|
tkeys = tuple(keys)
|
|
365
|
+
if not tkeys:
|
|
366
|
+
return
|
|
367
|
+
for k in tkeys:
|
|
368
|
+
_validate_key(k)
|
|
249
369
|
self._conn.execute(f'DELETE FROM {self._table} WHERE '
|
|
250
370
|
f'k IN ({", ".join(["?"] * len(tkeys))})',
|
|
251
371
|
tkeys)
|
|
@@ -0,0 +1,314 @@
|
|
|
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):
|
|
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_set_replace(self) -> None:
|
|
46
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
47
|
+
|
|
48
|
+
db.set('foo', 1)
|
|
49
|
+
db.set('foo', 'bar')
|
|
50
|
+
|
|
51
|
+
self.assertEqual(db.get('foo'), 'bar')
|
|
52
|
+
|
|
53
|
+
def test_get_default(self) -> None:
|
|
54
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
55
|
+
self.assertEqual(db.get('foo', 'bar'), 'bar')
|
|
56
|
+
self.assertIsNone(db.get('foo', None))
|
|
57
|
+
|
|
58
|
+
def test_set_get_persist(self) -> None:
|
|
59
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
60
|
+
|
|
61
|
+
for k, v in _TEST_DATA.items():
|
|
62
|
+
db.set(k, v)
|
|
63
|
+
|
|
64
|
+
self._conn.commit()
|
|
65
|
+
self._conn.close()
|
|
66
|
+
|
|
67
|
+
self._conn = sqlite3.connect(self._path)
|
|
68
|
+
db2 = TinyKV(self._conn, allow_pickle=True)
|
|
69
|
+
for k, v in _TEST_DATA.items():
|
|
70
|
+
with self.subTest(k=k):
|
|
71
|
+
self.assertEqual(db2.get(k), v)
|
|
72
|
+
|
|
73
|
+
def test_get_many(self) -> None:
|
|
74
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
75
|
+
|
|
76
|
+
for k, v in _TEST_DATA.items():
|
|
77
|
+
db.set(k, v)
|
|
78
|
+
|
|
79
|
+
self.assertEqual(db.get_many(_TEST_DATA.keys()), _TEST_DATA)
|
|
80
|
+
|
|
81
|
+
def test_get_many_nonexisting(self) -> None:
|
|
82
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
83
|
+
|
|
84
|
+
db.set('foo', 1)
|
|
85
|
+
db.set('bar', 2)
|
|
86
|
+
|
|
87
|
+
self.assertEqual(db.get_many(('foo', 'bar', 'not-there')),
|
|
88
|
+
{'foo': 1, 'bar': 2})
|
|
89
|
+
|
|
90
|
+
def test_get_glob(self) -> None:
|
|
91
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
92
|
+
|
|
93
|
+
db.set('foo:abc', 1)
|
|
94
|
+
db.set('foo:xyz', 2)
|
|
95
|
+
db.set('bar:abc', 3)
|
|
96
|
+
|
|
97
|
+
self.assertEqual(db.get_glob('foo:*'), {'foo:abc': 1, 'foo:xyz': 2})
|
|
98
|
+
|
|
99
|
+
def test_set_many(self) -> None:
|
|
100
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
101
|
+
|
|
102
|
+
db.set_many(_TEST_DATA)
|
|
103
|
+
|
|
104
|
+
for k, v in _TEST_DATA.items():
|
|
105
|
+
with self.subTest(k=k):
|
|
106
|
+
self.assertEqual(db.get(k), v)
|
|
107
|
+
|
|
108
|
+
def test_set_many_empty_mapping(self) -> None:
|
|
109
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
110
|
+
|
|
111
|
+
db.set_many({})
|
|
112
|
+
|
|
113
|
+
def test_remove(self) -> None:
|
|
114
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
115
|
+
|
|
116
|
+
db.set('foo', 'bar')
|
|
117
|
+
|
|
118
|
+
db.remove('foo')
|
|
119
|
+
|
|
120
|
+
with self.assertRaises(KeyError):
|
|
121
|
+
db.get('foo')
|
|
122
|
+
|
|
123
|
+
def test_remove_nonexistent(self) -> None:
|
|
124
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
125
|
+
with self.assertRaises(KeyError):
|
|
126
|
+
db.remove('nonexistent')
|
|
127
|
+
|
|
128
|
+
def test_remove_many(self) -> None:
|
|
129
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
130
|
+
|
|
131
|
+
db.set('foo', 'bar')
|
|
132
|
+
db.set('bar', 'bar')
|
|
133
|
+
|
|
134
|
+
db.remove_many(('foo', 'bar'))
|
|
135
|
+
|
|
136
|
+
with self.assertRaises(KeyError):
|
|
137
|
+
db.get('foo')
|
|
138
|
+
|
|
139
|
+
with self.assertRaises(KeyError):
|
|
140
|
+
db.get('bar')
|
|
141
|
+
|
|
142
|
+
def test_remove_many_empty(self) -> None:
|
|
143
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
144
|
+
|
|
145
|
+
db.remove_many([])
|
|
146
|
+
|
|
147
|
+
def test_safe_mode_rejects_pickle_on_set(self) -> None:
|
|
148
|
+
db = TinyKV(self._conn, allow_pickle=False)
|
|
149
|
+
|
|
150
|
+
with self.assertRaisesRegex(ValueError, 'allow_pickle=True'):
|
|
151
|
+
db.set('now', datetime.datetime.now())
|
|
152
|
+
|
|
153
|
+
def test_safe_mode_rejects_existing_pickled_row(self) -> None:
|
|
154
|
+
payload = pickle.dumps(datetime.datetime(2022, 3, 19, 20, 15, 5))
|
|
155
|
+
self._conn.execute('INSERT INTO kv (k, t, v) VALUES (?, ?, ?)',
|
|
156
|
+
('pickled', 6, payload))
|
|
157
|
+
|
|
158
|
+
db = TinyKV(self._conn, allow_pickle=False)
|
|
159
|
+
with self.assertRaisesRegex(ValueError, 'allow_pickle=False'):
|
|
160
|
+
db.get('pickled')
|
|
161
|
+
|
|
162
|
+
def test_compat_mode_allows_pickle_roundtrip(self) -> None:
|
|
163
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
164
|
+
dt = datetime.datetime(2022, 3, 19, 20, 15, 5)
|
|
165
|
+
|
|
166
|
+
db.set('dt', dt)
|
|
167
|
+
|
|
168
|
+
self.assertEqual(db.get('dt'), dt)
|
|
169
|
+
|
|
170
|
+
def test_implicit_allow_pickle_warns(self) -> None:
|
|
171
|
+
with self.assertWarnsRegex(FutureWarning, 'allow_pickle'):
|
|
172
|
+
TinyKV(self._conn)
|
|
173
|
+
|
|
174
|
+
def test_explicit_allow_pickle_true_does_not_warn(self) -> None:
|
|
175
|
+
with warnings.catch_warnings(record=True) as warns:
|
|
176
|
+
warnings.simplefilter('always')
|
|
177
|
+
TinyKV(self._conn, allow_pickle=True)
|
|
178
|
+
self.assertEqual(warns, [])
|
|
179
|
+
|
|
180
|
+
def test_explicit_allow_pickle_false_does_not_warn(self) -> None:
|
|
181
|
+
with warnings.catch_warnings(record=True) as warns:
|
|
182
|
+
warnings.simplefilter('always')
|
|
183
|
+
TinyKV(self._conn, allow_pickle=False)
|
|
184
|
+
self.assertEqual(warns, [])
|
|
185
|
+
|
|
186
|
+
def test_large_int_roundtrip(self) -> None:
|
|
187
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
188
|
+
|
|
189
|
+
test_cases = [
|
|
190
|
+
(2**53 - 1, 'max safe integer'),
|
|
191
|
+
(2**53, 'just over max safe integer'),
|
|
192
|
+
(2**53 + 1, 'larger than max safe integer'),
|
|
193
|
+
(10**20, 'very large positive'),
|
|
194
|
+
(-(10**20), 'very large negative'),
|
|
195
|
+
(0, 'zero'),
|
|
196
|
+
(1, 'small positive'),
|
|
197
|
+
(-1, 'small negative'),
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
for value, description in test_cases:
|
|
201
|
+
with self.subTest(value=value, description=description):
|
|
202
|
+
db.set('large_int', value)
|
|
203
|
+
self.assertEqual(db.get('large_int'), value)
|
|
204
|
+
|
|
205
|
+
def test_integral_float_roundtrip(self) -> None:
|
|
206
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
207
|
+
|
|
208
|
+
test_cases = [
|
|
209
|
+
(1.0, 'one as float'),
|
|
210
|
+
(0.0, 'zero as float'),
|
|
211
|
+
(-0.0, 'negative zero as float'),
|
|
212
|
+
(1.5, 'one point five'),
|
|
213
|
+
(-2.5, 'negative two point five'),
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
for value, description in test_cases:
|
|
217
|
+
with self.subTest(value=value, description=description):
|
|
218
|
+
db.set('integral_float', value)
|
|
219
|
+
result = db.get('integral_float')
|
|
220
|
+
self.assertEqual(result, value)
|
|
221
|
+
self.assertIsInstance(result, float)
|
|
222
|
+
|
|
223
|
+
def test_nan_roundtrip(self) -> None:
|
|
224
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
225
|
+
|
|
226
|
+
db.set('nan', float('nan'))
|
|
227
|
+
result = db.get('nan')
|
|
228
|
+
self.assertTrue(math.isnan(result))
|
|
229
|
+
|
|
230
|
+
def test_nan_roundtrip_safe_mode(self) -> None:
|
|
231
|
+
db = TinyKV(self._conn, allow_pickle=False)
|
|
232
|
+
|
|
233
|
+
db.set('nan', float('nan'))
|
|
234
|
+
result = db.get('nan')
|
|
235
|
+
self.assertTrue(math.isnan(result))
|
|
236
|
+
|
|
237
|
+
def test_inf_roundtrip(self) -> None:
|
|
238
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
239
|
+
|
|
240
|
+
db.set('inf', float('inf'))
|
|
241
|
+
self.assertEqual(db.get('inf'), float('inf'))
|
|
242
|
+
|
|
243
|
+
db.set('neg_inf', float('-inf'))
|
|
244
|
+
self.assertEqual(db.get('neg_inf'), float('-inf'))
|
|
245
|
+
|
|
246
|
+
def test_set_rejects_non_string_key(self) -> None:
|
|
247
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
248
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
249
|
+
db.set(123, 'value') # type: ignore[arg-type]
|
|
250
|
+
|
|
251
|
+
def test_set_rejects_empty_key(self) -> None:
|
|
252
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
253
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
254
|
+
db.set('', 'value')
|
|
255
|
+
|
|
256
|
+
def test_get_rejects_non_string_key(self) -> None:
|
|
257
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
258
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
259
|
+
db.get(123) # type: ignore[arg-type]
|
|
260
|
+
|
|
261
|
+
def test_get_rejects_empty_key(self) -> None:
|
|
262
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
263
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
264
|
+
db.get('')
|
|
265
|
+
|
|
266
|
+
def test_remove_rejects_non_string_key(self) -> None:
|
|
267
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
268
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
269
|
+
db.remove(123) # type: ignore[arg-type]
|
|
270
|
+
|
|
271
|
+
def test_remove_rejects_empty_key(self) -> None:
|
|
272
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
273
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
274
|
+
db.remove('')
|
|
275
|
+
|
|
276
|
+
def test_get_many_rejects_non_string_key(self) -> None:
|
|
277
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
278
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
279
|
+
db.get_many([123]) # type: ignore[list-item]
|
|
280
|
+
|
|
281
|
+
def test_get_many_rejects_empty_key(self) -> None:
|
|
282
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
283
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
284
|
+
db.get_many([''])
|
|
285
|
+
|
|
286
|
+
def test_set_many_rejects_non_string_key(self) -> None:
|
|
287
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
288
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
289
|
+
db.set_many({123: 'value'}) # type: ignore[dict-item]
|
|
290
|
+
|
|
291
|
+
def test_set_many_rejects_empty_key(self) -> None:
|
|
292
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
293
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
294
|
+
db.set_many({'': 'value'})
|
|
295
|
+
|
|
296
|
+
def test_remove_many_rejects_non_string_key(self) -> None:
|
|
297
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
298
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
299
|
+
db.remove_many([123]) # type: ignore[list-item]
|
|
300
|
+
|
|
301
|
+
def test_remove_many_rejects_empty_key(self) -> None:
|
|
302
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
303
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
304
|
+
db.remove_many([''])
|
|
305
|
+
|
|
306
|
+
def test_get_glob_rejects_non_string_key(self) -> None:
|
|
307
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
308
|
+
with self.assertRaisesRegex(TypeError, 'must be a string'):
|
|
309
|
+
db.get_glob(123) # type: ignore[arg-type]
|
|
310
|
+
|
|
311
|
+
def test_get_glob_rejects_empty_key(self) -> None:
|
|
312
|
+
db = TinyKV(self._conn, allow_pickle=True)
|
|
313
|
+
with self.assertRaisesRegex(ValueError, 'must not be empty'):
|
|
314
|
+
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
|