tinykv 0.4.0__tar.gz → 0.5.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.4.0 → tinykv-0.5.0}/PKG-INFO +35 -3
- {tinykv-0.4.0 → tinykv-0.5.0}/README.md +34 -2
- {tinykv-0.4.0 → tinykv-0.5.0}/pyproject.toml +9 -1
- {tinykv-0.4.0 → tinykv-0.5.0}/src/tinykv/__init__.py +256 -58
- tinykv-0.5.0/src/tinykv/_migrations.py +52 -0
- {tinykv-0.4.0 → tinykv-0.5.0}/LICENSE +0 -0
- {tinykv-0.4.0 → tinykv-0.5.0}/src/tinykv/__init__.py~ +0 -0
- {tinykv-0.4.0 → tinykv-0.5.0}/src/tinykv/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tinykv
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A lightweight Python SQLite key-value store built on sqlite3.
|
|
5
5
|
Keywords: python,sqlite,sqlite3,key-value store,kv
|
|
6
6
|
Author-email: Flavio Veloso Soares <flaviovs@magnux.com>
|
|
@@ -97,6 +97,21 @@ database, respectively:
|
|
|
97
97
|
>>> kv.get('foo')
|
|
98
98
|
'bar'
|
|
99
99
|
|
|
100
|
+
Pass `ttl` to give a key a lifetime in whole seconds. Expired keys
|
|
101
|
+
behave like missing keys, but are retained until `gc()` is called:
|
|
102
|
+
|
|
103
|
+
>>> kv.set('temporary', 'value', ttl=60)
|
|
104
|
+
>>> kv.get('temporary')
|
|
105
|
+
'value'
|
|
106
|
+
|
|
107
|
+
>>> kv.gc() # delete expired entries; returns the number deleted
|
|
108
|
+
0
|
|
109
|
+
|
|
110
|
+
Update or clear the expiration of an existing key with `set_ttl()`:
|
|
111
|
+
|
|
112
|
+
>>> kv.set_ttl('foo', 60)
|
|
113
|
+
>>> kv.set_ttl('foo', None) # keep the key indefinitely
|
|
114
|
+
|
|
100
115
|
Use `inc()`, `dec()`, and `add()` to update integer counters atomically:
|
|
101
116
|
|
|
102
117
|
>>> kv.inc('visits')
|
|
@@ -112,6 +127,17 @@ Missing keys start at zero. Amounts must be integers (not booleans), and
|
|
|
112
127
|
`inc()` and `dec()` require nonnegative amounts. An existing value must be an
|
|
113
128
|
integer; otherwise the operation raises `TypeError`.
|
|
114
129
|
|
|
130
|
+
Arithmetic operations preserve an existing key's TTL unless `update_ttl` is
|
|
131
|
+
provided. Use `create_ttl` to set a lifetime only when the operation creates a
|
|
132
|
+
missing or expired key. `update_ttl=None` clears expiration on an existing key.
|
|
133
|
+
|
|
134
|
+
>>> kv.inc('temporary-visits', create_ttl=60)
|
|
135
|
+
1
|
|
136
|
+
>>> kv.add('temporary-visits', 2, update_ttl=120)
|
|
137
|
+
3
|
|
138
|
+
>>> kv.dec('temporary-visits', update_ttl=None)
|
|
139
|
+
2
|
|
140
|
+
|
|
115
141
|
Trying to get a nonexistent key raises _KeyError_:
|
|
116
142
|
|
|
117
143
|
>>> kv.get('not-there')
|
|
@@ -200,6 +226,10 @@ once:
|
|
|
200
226
|
>>> kv.get('two')
|
|
201
227
|
2
|
|
202
228
|
|
|
229
|
+
Pass `ttl` to apply the same lifetime to every key in the batch:
|
|
230
|
+
|
|
231
|
+
>>> kv.set_many({'temporary-one': 1, 'temporary-two': 2}, ttl=60)
|
|
232
|
+
|
|
203
233
|
Call `get_many()` to retrieve many entries. The function returns a
|
|
204
234
|
_dict_ of all keys-values found in the database:
|
|
205
235
|
|
|
@@ -261,6 +291,9 @@ If you need idempotent setup, pass `if_not_exists=True`:
|
|
|
261
291
|
|
|
262
292
|
>>> tinykv.create_schema(conn, if_not_exists=True)
|
|
263
293
|
|
|
294
|
+
Tables created by older TinyKV versions are upgraded to schema v2 when opened.
|
|
295
|
+
The version is tracked in the reserved `__tinykv_schema_version` row.
|
|
296
|
+
|
|
264
297
|
You can use a custom table name:
|
|
265
298
|
|
|
266
299
|
>>> tinykv.create_schema(conn, table='my_keys')
|
|
@@ -286,8 +319,7 @@ Miscellaneous
|
|
|
286
319
|
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
287
320
|
(since v0.1.4).
|
|
288
321
|
|
|
289
|
-
- Keys with
|
|
290
|
-
use.
|
|
322
|
+
- Keys starting with `__tinykv_` are reserved for TinyKV internal use.
|
|
291
323
|
|
|
292
324
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
293
325
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -79,6 +79,21 @@ database, respectively:
|
|
|
79
79
|
>>> kv.get('foo')
|
|
80
80
|
'bar'
|
|
81
81
|
|
|
82
|
+
Pass `ttl` to give a key a lifetime in whole seconds. Expired keys
|
|
83
|
+
behave like missing keys, but are retained until `gc()` is called:
|
|
84
|
+
|
|
85
|
+
>>> kv.set('temporary', 'value', ttl=60)
|
|
86
|
+
>>> kv.get('temporary')
|
|
87
|
+
'value'
|
|
88
|
+
|
|
89
|
+
>>> kv.gc() # delete expired entries; returns the number deleted
|
|
90
|
+
0
|
|
91
|
+
|
|
92
|
+
Update or clear the expiration of an existing key with `set_ttl()`:
|
|
93
|
+
|
|
94
|
+
>>> kv.set_ttl('foo', 60)
|
|
95
|
+
>>> kv.set_ttl('foo', None) # keep the key indefinitely
|
|
96
|
+
|
|
82
97
|
Use `inc()`, `dec()`, and `add()` to update integer counters atomically:
|
|
83
98
|
|
|
84
99
|
>>> kv.inc('visits')
|
|
@@ -94,6 +109,17 @@ Missing keys start at zero. Amounts must be integers (not booleans), and
|
|
|
94
109
|
`inc()` and `dec()` require nonnegative amounts. An existing value must be an
|
|
95
110
|
integer; otherwise the operation raises `TypeError`.
|
|
96
111
|
|
|
112
|
+
Arithmetic operations preserve an existing key's TTL unless `update_ttl` is
|
|
113
|
+
provided. Use `create_ttl` to set a lifetime only when the operation creates a
|
|
114
|
+
missing or expired key. `update_ttl=None` clears expiration on an existing key.
|
|
115
|
+
|
|
116
|
+
>>> kv.inc('temporary-visits', create_ttl=60)
|
|
117
|
+
1
|
|
118
|
+
>>> kv.add('temporary-visits', 2, update_ttl=120)
|
|
119
|
+
3
|
|
120
|
+
>>> kv.dec('temporary-visits', update_ttl=None)
|
|
121
|
+
2
|
|
122
|
+
|
|
97
123
|
Trying to get a nonexistent key raises _KeyError_:
|
|
98
124
|
|
|
99
125
|
>>> kv.get('not-there')
|
|
@@ -182,6 +208,10 @@ once:
|
|
|
182
208
|
>>> kv.get('two')
|
|
183
209
|
2
|
|
184
210
|
|
|
211
|
+
Pass `ttl` to apply the same lifetime to every key in the batch:
|
|
212
|
+
|
|
213
|
+
>>> kv.set_many({'temporary-one': 1, 'temporary-two': 2}, ttl=60)
|
|
214
|
+
|
|
185
215
|
Call `get_many()` to retrieve many entries. The function returns a
|
|
186
216
|
_dict_ of all keys-values found in the database:
|
|
187
217
|
|
|
@@ -243,6 +273,9 @@ If you need idempotent setup, pass `if_not_exists=True`:
|
|
|
243
273
|
|
|
244
274
|
>>> tinykv.create_schema(conn, if_not_exists=True)
|
|
245
275
|
|
|
276
|
+
Tables created by older TinyKV versions are upgraded to schema v2 when opened.
|
|
277
|
+
The version is tracked in the reserved `__tinykv_schema_version` row.
|
|
278
|
+
|
|
246
279
|
You can use a custom table name:
|
|
247
280
|
|
|
248
281
|
>>> tinykv.create_schema(conn, table='my_keys')
|
|
@@ -268,8 +301,7 @@ Miscellaneous
|
|
|
268
301
|
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
269
302
|
(since v0.1.4).
|
|
270
303
|
|
|
271
|
-
- Keys with
|
|
272
|
-
use.
|
|
304
|
+
- Keys starting with `__tinykv_` are reserved for TinyKV internal use.
|
|
273
305
|
|
|
274
306
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
275
307
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -61,7 +61,15 @@ ignore = ["TRY003", "COM812"]
|
|
|
61
61
|
|
|
62
62
|
[tool.ruff.lint.isort]
|
|
63
63
|
combine-as-imports = true
|
|
64
|
-
|
|
64
|
+
section-order = [
|
|
65
|
+
"future",
|
|
66
|
+
"standard-library",
|
|
67
|
+
"third-party",
|
|
68
|
+
"first-party",
|
|
69
|
+
"local-folder",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
known-first-party = ["tinykv"]
|
|
65
73
|
|
|
66
74
|
[tool.ruff.lint.pydocstyle]
|
|
67
75
|
# Explicitly enforce Google-style docstrings instead of PEP 257 standard.
|
|
@@ -7,11 +7,22 @@ import math
|
|
|
7
7
|
import pickle
|
|
8
8
|
import re
|
|
9
9
|
import sqlite3
|
|
10
|
+
import time
|
|
10
11
|
from collections.abc import Iterable, Mapping
|
|
11
|
-
from types import TracebackType
|
|
12
|
+
from types import EllipsisType, TracebackType
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
15
|
+
__version__ = "0.5.0"
|
|
16
|
+
|
|
17
|
+
_DEF_TABLE = "kv"
|
|
18
|
+
_SCHEMA_VERSION_KEY = "__tinykv_schema_version"
|
|
19
|
+
_SCHEMA_VERSION = 2
|
|
20
|
+
_MAX_SQLITE_INTEGER = (1 << 63) - 1
|
|
21
|
+
_NAN_PICKLE = pickle.dumps(float("nan"))
|
|
22
|
+
_MISSING = object()
|
|
23
|
+
_SAVEPOINT_IDS = itertools.count()
|
|
14
24
|
_TABLE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*\Z")
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
15
26
|
|
|
16
27
|
|
|
17
28
|
def _split_table_name(table: str) -> tuple[str | None, str]:
|
|
@@ -53,6 +64,10 @@ def _validate_key(key: str) -> None:
|
|
|
53
64
|
raise TypeError(f"key must be a string, got {type(key).__name__}")
|
|
54
65
|
if not key:
|
|
55
66
|
raise ValueError("key must not be empty")
|
|
67
|
+
if key.startswith("__tinykv_"):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"key must not start with the reserved prefix '__tinykv_'"
|
|
70
|
+
)
|
|
56
71
|
|
|
57
72
|
|
|
58
73
|
def _validate_int(value: Any, name: str) -> None: # noqa: ANN401
|
|
@@ -62,14 +77,16 @@ def _validate_int(value: Any, name: str) -> None: # noqa: ANN401
|
|
|
62
77
|
)
|
|
63
78
|
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
def _ttl_expiration(ttl: int | None) -> int | None:
|
|
81
|
+
if ttl is None:
|
|
82
|
+
return None
|
|
83
|
+
_validate_int(ttl, "ttl")
|
|
84
|
+
if ttl < 0:
|
|
85
|
+
raise ValueError("ttl must be nonnegative")
|
|
86
|
+
expires_at = int(time.time()) + ttl
|
|
87
|
+
if expires_at > _MAX_SQLITE_INTEGER:
|
|
88
|
+
raise ValueError("ttl is too large for a SQLite INTEGER timestamp")
|
|
89
|
+
return expires_at
|
|
73
90
|
|
|
74
91
|
|
|
75
92
|
class _Savepoint:
|
|
@@ -94,6 +111,30 @@ class _Savepoint:
|
|
|
94
111
|
self._conn.execute(f"RELEASE {self._name}")
|
|
95
112
|
|
|
96
113
|
|
|
114
|
+
def _migrate_schema(
|
|
115
|
+
conn: sqlite3.Connection,
|
|
116
|
+
table: str,
|
|
117
|
+
quoted_table: str,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Check a table's schema version and migrate it when necessary."""
|
|
120
|
+
version_row = conn.execute(
|
|
121
|
+
f"SELECT v FROM {quoted_table} WHERE k = ?",
|
|
122
|
+
(_SCHEMA_VERSION_KEY,),
|
|
123
|
+
).fetchone()
|
|
124
|
+
schema_version = (
|
|
125
|
+
1 if version_row is None else int(bytes(version_row[0]).decode())
|
|
126
|
+
)
|
|
127
|
+
if schema_version > _SCHEMA_VERSION:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
f"Table {table!r} has unsupported schema version {schema_version}"
|
|
130
|
+
)
|
|
131
|
+
if schema_version < _SCHEMA_VERSION:
|
|
132
|
+
from ._migrations import migrate_schema # noqa: PLC0415
|
|
133
|
+
|
|
134
|
+
with _Savepoint(conn):
|
|
135
|
+
migrate_schema(conn, table, quoted_table, schema_version)
|
|
136
|
+
|
|
137
|
+
|
|
97
138
|
def create_schema(
|
|
98
139
|
conn: sqlite3.Connection,
|
|
99
140
|
table: str = _DEF_TABLE,
|
|
@@ -108,16 +149,39 @@ def create_schema(
|
|
|
108
149
|
repeated schema creation is a no-op.
|
|
109
150
|
|
|
110
151
|
"""
|
|
152
|
+
schema, name = _split_table_name(table)
|
|
111
153
|
quoted_table = _quote_table_name(table)
|
|
112
154
|
if_not_exists_sql = "IF NOT EXISTS " if if_not_exists else ""
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
155
|
+
catalog = (
|
|
156
|
+
"sqlite_master" if schema is None else f'"{schema}".sqlite_master'
|
|
157
|
+
)
|
|
158
|
+
existed = (
|
|
159
|
+
conn.execute(
|
|
160
|
+
f"SELECT 1 FROM {catalog} WHERE type = 'table' AND name = ?",
|
|
161
|
+
(name,),
|
|
162
|
+
).fetchone()
|
|
163
|
+
is not None
|
|
120
164
|
)
|
|
165
|
+
with _Savepoint(conn):
|
|
166
|
+
conn.execute(
|
|
167
|
+
f"CREATE TABLE {if_not_exists_sql}{quoted_table} ("
|
|
168
|
+
"k TEXT NOT NULL, "
|
|
169
|
+
"t TINYINT NOT NULL CHECK (t BETWEEN 1 AND 7), "
|
|
170
|
+
"v BLOB, "
|
|
171
|
+
"e INT CHECK (e > 0), "
|
|
172
|
+
"PRIMARY KEY (k)"
|
|
173
|
+
") WITHOUT ROWID"
|
|
174
|
+
)
|
|
175
|
+
if not existed:
|
|
176
|
+
conn.execute(
|
|
177
|
+
f"INSERT INTO {quoted_table} (k, t, v, e) "
|
|
178
|
+
"VALUES (?, ?, ?, NULL)",
|
|
179
|
+
(
|
|
180
|
+
_SCHEMA_VERSION_KEY,
|
|
181
|
+
_DType.LONG,
|
|
182
|
+
str(_SCHEMA_VERSION).encode(),
|
|
183
|
+
),
|
|
184
|
+
)
|
|
121
185
|
|
|
122
186
|
|
|
123
187
|
class _DType(enum.IntEnum):
|
|
@@ -190,6 +254,7 @@ class TinyKV:
|
|
|
190
254
|
self._table = table
|
|
191
255
|
self._quoted_table = quoted_table
|
|
192
256
|
self._allow_pickle = allow_pickle
|
|
257
|
+
_migrate_schema(conn, table, quoted_table)
|
|
193
258
|
|
|
194
259
|
@property
|
|
195
260
|
def conn(self) -> sqlite3.Connection:
|
|
@@ -301,30 +366,82 @@ class TinyKV:
|
|
|
301
366
|
raise ValueError(f"Unsupported data type {dtype}") from exc
|
|
302
367
|
return self._unserialize(row_type, data)
|
|
303
368
|
|
|
304
|
-
def set(
|
|
369
|
+
def set(
|
|
370
|
+
self,
|
|
371
|
+
key: str,
|
|
372
|
+
value: Any, # noqa: ANN401
|
|
373
|
+
ttl: int | None = None,
|
|
374
|
+
) -> None:
|
|
305
375
|
"""Store a value in the database.
|
|
306
376
|
|
|
307
377
|
Args:
|
|
308
378
|
key: The key to store the value under.
|
|
309
379
|
value: The value to store.
|
|
380
|
+
ttl: Optional nonnegative lifetime in seconds. ``None`` keeps the
|
|
381
|
+
key indefinitely.
|
|
310
382
|
|
|
311
383
|
Raises:
|
|
312
384
|
TypeError: If key is not a string.
|
|
313
|
-
ValueError: If key is
|
|
314
|
-
|
|
385
|
+
ValueError: If key is empty or starts with the reserved prefix, or
|
|
386
|
+
the TTL is negative or exceeds SQLite's integer range.
|
|
387
|
+
|
|
388
|
+
"""
|
|
389
|
+
_validate_key(key)
|
|
390
|
+
expires_at = _ttl_expiration(ttl)
|
|
391
|
+
self._set(key, value, expires_at)
|
|
392
|
+
|
|
393
|
+
def set_ttl(self, key: str, ttl: int | None) -> None:
|
|
394
|
+
"""Set or clear the expiration for an existing key.
|
|
395
|
+
|
|
396
|
+
``ttl`` is a nonnegative lifetime in seconds. Passing ``None`` clears
|
|
397
|
+
the expiration. Missing and expired keys raise ``KeyError``.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
key: The key whose expiration to update.
|
|
401
|
+
ttl: A nonnegative lifetime in seconds, or ``None`` to clear it.
|
|
402
|
+
|
|
403
|
+
Raises:
|
|
404
|
+
KeyError: If the key is missing or expired.
|
|
405
|
+
TypeError: If the key or TTL has an invalid type.
|
|
406
|
+
ValueError: If the key is empty or reserved, or TTL is negative or
|
|
407
|
+
exceeds SQLite's integer range.
|
|
315
408
|
|
|
316
409
|
"""
|
|
317
410
|
_validate_key(key)
|
|
411
|
+
expires_at = _ttl_expiration(ttl)
|
|
412
|
+
assert self._conn
|
|
413
|
+
cursor = self._conn.execute(
|
|
414
|
+
f"UPDATE {self._quoted_table} SET e = ? WHERE k = ? "
|
|
415
|
+
"AND (e IS NULL OR e > CAST(strftime('%s', 'now') AS INT))",
|
|
416
|
+
(expires_at, key),
|
|
417
|
+
)
|
|
418
|
+
if cursor.rowcount == 0:
|
|
419
|
+
raise KeyError(key)
|
|
420
|
+
|
|
421
|
+
def _set(
|
|
422
|
+
self,
|
|
423
|
+
key: str,
|
|
424
|
+
value: Any, # noqa: ANN401
|
|
425
|
+
expires_at: int | None = None,
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Store a value for a key that has already been validated."""
|
|
318
428
|
assert self._conn
|
|
319
429
|
dtype, data = self._serialize(value)
|
|
320
430
|
self._conn.execute(
|
|
321
431
|
f"INSERT OR REPLACE INTO {self._quoted_table} "
|
|
322
|
-
"(k, t, v) "
|
|
323
|
-
"VALUES (?, ?, ?)",
|
|
324
|
-
(key, dtype, data),
|
|
432
|
+
"(k, t, v, e) "
|
|
433
|
+
"VALUES (?, ?, ?, ?)",
|
|
434
|
+
(key, dtype, data, expires_at),
|
|
325
435
|
)
|
|
326
436
|
|
|
327
|
-
def add(
|
|
437
|
+
def add(
|
|
438
|
+
self,
|
|
439
|
+
key: str,
|
|
440
|
+
n: int,
|
|
441
|
+
*,
|
|
442
|
+
create_ttl: int | None = None,
|
|
443
|
+
update_ttl: int | EllipsisType | None = Ellipsis,
|
|
444
|
+
) -> int:
|
|
328
445
|
"""Add an integer amount to the value stored under ``key``.
|
|
329
446
|
|
|
330
447
|
If the key does not exist, it is treated as having value zero.
|
|
@@ -334,83 +451,127 @@ class TinyKV:
|
|
|
334
451
|
Args:
|
|
335
452
|
key: The key to update.
|
|
336
453
|
n: A signed integer amount to add.
|
|
454
|
+
create_ttl: TTL in seconds for a key created by this operation.
|
|
455
|
+
``None`` creates a key without expiration.
|
|
456
|
+
update_ttl: TTL in seconds to assign when updating an existing
|
|
457
|
+
key. If omitted, its current TTL is preserved; ``None`` clears
|
|
458
|
+
its expiration.
|
|
337
459
|
|
|
338
460
|
Returns:
|
|
339
461
|
The updated integer value.
|
|
340
462
|
|
|
341
463
|
Raises:
|
|
342
|
-
TypeError: If ``key`` or
|
|
343
|
-
existing value is not an integer.
|
|
344
|
-
ValueError: If ``key`` is empty
|
|
464
|
+
TypeError: If ``key``, ``n``, or either TTL has an invalid type,
|
|
465
|
+
or if the existing value is not an integer.
|
|
466
|
+
ValueError: If ``key`` is empty, starts with the reserved prefix,
|
|
467
|
+
a TTL is negative or exceeds SQLite's integer range, or the
|
|
468
|
+
stored row is malformed.
|
|
345
469
|
|
|
346
470
|
"""
|
|
347
471
|
_validate_key(key)
|
|
348
472
|
_validate_int(n, "n")
|
|
473
|
+
create_expires_at = _ttl_expiration(create_ttl)
|
|
474
|
+
update_expires_at = (
|
|
475
|
+
Ellipsis if update_ttl is Ellipsis else _ttl_expiration(update_ttl)
|
|
476
|
+
)
|
|
477
|
+
return self._add(key, n, create_expires_at, update_expires_at)
|
|
349
478
|
|
|
479
|
+
def _add(
|
|
480
|
+
self,
|
|
481
|
+
key: str,
|
|
482
|
+
n: int,
|
|
483
|
+
create_expires_at: int | None,
|
|
484
|
+
update_expires_at: int | EllipsisType | None,
|
|
485
|
+
) -> int:
|
|
486
|
+
"""Add to a key whose name and amount have been validated."""
|
|
350
487
|
assert self._conn
|
|
351
488
|
with _Savepoint(self._conn):
|
|
352
489
|
cur = self._conn.execute(
|
|
353
|
-
f"SELECT t, v FROM {self._quoted_table} WHERE k = ?"
|
|
490
|
+
f"SELECT t, v, e FROM {self._quoted_table} WHERE k = ? "
|
|
491
|
+
"AND (e IS NULL OR e > CAST(strftime('%s', 'now') AS INT))",
|
|
492
|
+
(key,),
|
|
354
493
|
)
|
|
355
494
|
row = cur.fetchone()
|
|
356
495
|
if row is None:
|
|
357
496
|
value = 0
|
|
497
|
+
expires_at = create_expires_at
|
|
358
498
|
else:
|
|
359
499
|
value = self._decode_row(row[0], row[1])
|
|
360
500
|
_validate_int(value, f"value for key {key!r}")
|
|
501
|
+
if update_expires_at is Ellipsis:
|
|
502
|
+
expires_at = row[2]
|
|
503
|
+
else:
|
|
504
|
+
expires_at = update_expires_at
|
|
361
505
|
|
|
362
506
|
result = value + n
|
|
363
|
-
|
|
364
|
-
self._conn.execute(
|
|
365
|
-
f"INSERT OR REPLACE INTO {self._quoted_table} (k, t, v) "
|
|
366
|
-
"VALUES (?, ?, ?)",
|
|
367
|
-
(key, dtype, data),
|
|
368
|
-
)
|
|
507
|
+
self._set(key, result, expires_at)
|
|
369
508
|
return result
|
|
370
509
|
|
|
371
|
-
def inc(
|
|
510
|
+
def inc(
|
|
511
|
+
self,
|
|
512
|
+
key: str,
|
|
513
|
+
n: int = 1,
|
|
514
|
+
*,
|
|
515
|
+
create_ttl: int | None = None,
|
|
516
|
+
update_ttl: int | EllipsisType | None = Ellipsis,
|
|
517
|
+
) -> int:
|
|
372
518
|
"""Increment the integer value stored under ``key`` by ``n``.
|
|
373
519
|
|
|
374
520
|
Args:
|
|
375
521
|
key: The key to update.
|
|
376
522
|
n: A nonnegative integer amount to add.
|
|
523
|
+
create_ttl: TTL in seconds for a key created by this operation.
|
|
524
|
+
update_ttl: TTL in seconds for an existing key. If omitted, its
|
|
525
|
+
current TTL is preserved; ``None`` clears its expiration.
|
|
377
526
|
|
|
378
527
|
Returns:
|
|
379
528
|
The updated integer value.
|
|
380
529
|
|
|
381
530
|
Raises:
|
|
382
|
-
TypeError: If ``key`` or
|
|
383
|
-
existing value is not an integer.
|
|
384
|
-
ValueError: If ``key`` is empty, ``n`` is negative,
|
|
385
|
-
|
|
531
|
+
TypeError: If ``key``, ``n``, or either TTL has an invalid type,
|
|
532
|
+
or if the existing value is not an integer.
|
|
533
|
+
ValueError: If ``key`` is empty, ``n`` is negative, a TTL is
|
|
534
|
+
negative or exceeds SQLite's integer range, or the stored row
|
|
535
|
+
is malformed.
|
|
386
536
|
|
|
387
537
|
"""
|
|
388
538
|
_validate_int(n, "n")
|
|
389
539
|
if n < 0:
|
|
390
540
|
raise ValueError("n must be nonnegative")
|
|
391
|
-
return self.add(key, n)
|
|
541
|
+
return self.add(key, n, create_ttl=create_ttl, update_ttl=update_ttl)
|
|
392
542
|
|
|
393
|
-
def dec(
|
|
543
|
+
def dec(
|
|
544
|
+
self,
|
|
545
|
+
key: str,
|
|
546
|
+
n: int = 1,
|
|
547
|
+
*,
|
|
548
|
+
create_ttl: int | None = None,
|
|
549
|
+
update_ttl: int | EllipsisType | None = Ellipsis,
|
|
550
|
+
) -> int:
|
|
394
551
|
"""Decrement the integer value stored under ``key`` by ``n``.
|
|
395
552
|
|
|
396
553
|
Args:
|
|
397
554
|
key: The key to update.
|
|
398
555
|
n: A nonnegative integer amount to subtract.
|
|
556
|
+
create_ttl: TTL in seconds for a key created by this operation.
|
|
557
|
+
update_ttl: TTL in seconds for an existing key. If omitted, its
|
|
558
|
+
current TTL is preserved; ``None`` clears its expiration.
|
|
399
559
|
|
|
400
560
|
Returns:
|
|
401
561
|
The updated integer value.
|
|
402
562
|
|
|
403
563
|
Raises:
|
|
404
|
-
TypeError: If ``key`` or
|
|
405
|
-
existing value is not an integer.
|
|
406
|
-
ValueError: If ``key`` is empty, ``n`` is negative,
|
|
407
|
-
|
|
564
|
+
TypeError: If ``key``, ``n``, or either TTL has an invalid type,
|
|
565
|
+
or if the existing value is not an integer.
|
|
566
|
+
ValueError: If ``key`` is empty, ``n`` is negative, a TTL is
|
|
567
|
+
negative or exceeds SQLite's integer range, or the stored row
|
|
568
|
+
is malformed.
|
|
408
569
|
|
|
409
570
|
"""
|
|
410
571
|
_validate_int(n, "n")
|
|
411
572
|
if n < 0:
|
|
412
573
|
raise ValueError("n must be nonnegative")
|
|
413
|
-
return self.add(key, -n)
|
|
574
|
+
return self.add(key, -n, create_ttl=create_ttl, update_ttl=update_ttl)
|
|
414
575
|
|
|
415
576
|
def get(
|
|
416
577
|
self,
|
|
@@ -449,7 +610,9 @@ class TinyKV:
|
|
|
449
610
|
_validate_key(key)
|
|
450
611
|
assert self._conn
|
|
451
612
|
cur = self._conn.execute(
|
|
452
|
-
f"SELECT t, v FROM {self._quoted_table} WHERE k = ?"
|
|
613
|
+
f"SELECT t, v FROM {self._quoted_table} WHERE k = ? "
|
|
614
|
+
"AND (e IS NULL OR e > CAST(strftime('%s', 'now') AS INT))",
|
|
615
|
+
(key,),
|
|
453
616
|
)
|
|
454
617
|
row = cur.fetchone()
|
|
455
618
|
if not row:
|
|
@@ -470,8 +633,8 @@ class TinyKV:
|
|
|
470
633
|
|
|
471
634
|
Raises:
|
|
472
635
|
TypeError: If any key is not a string.
|
|
473
|
-
ValueError: If any key is
|
|
474
|
-
malformed.
|
|
636
|
+
ValueError: If any key is empty or starts with the reserved
|
|
637
|
+
prefix, or a database row is malformed.
|
|
475
638
|
|
|
476
639
|
"""
|
|
477
640
|
assert self._conn
|
|
@@ -490,7 +653,9 @@ class TinyKV:
|
|
|
490
653
|
_validate_key(k)
|
|
491
654
|
rows = self._conn.execute(
|
|
492
655
|
f"SELECT k, t, v FROM {self._quoted_table} WHERE "
|
|
493
|
-
f"k IN ({', '.join(['?'] * len(tkeys))})"
|
|
656
|
+
f"k IN ({', '.join(['?'] * len(tkeys))}) "
|
|
657
|
+
"AND (e IS NULL OR e > "
|
|
658
|
+
"CAST(strftime('%s', 'now') AS INT))",
|
|
494
659
|
tkeys,
|
|
495
660
|
)
|
|
496
661
|
result.update(
|
|
@@ -522,27 +687,41 @@ class TinyKV:
|
|
|
522
687
|
_validate_key(glob_key)
|
|
523
688
|
assert self._conn
|
|
524
689
|
rows = self._conn.execute(
|
|
525
|
-
f"SELECT k, t, v FROM {self._quoted_table} WHERE k GLOB ?"
|
|
690
|
+
f"SELECT k, t, v FROM {self._quoted_table} WHERE k GLOB ? "
|
|
691
|
+
"AND (e IS NULL OR e > CAST(strftime('%s', 'now') AS INT))",
|
|
526
692
|
(glob_key,),
|
|
527
693
|
)
|
|
528
|
-
return {
|
|
694
|
+
return {
|
|
695
|
+
r[0]: self._decode_row(r[1], r[2])
|
|
696
|
+
for r in rows.fetchall()
|
|
697
|
+
if not r[0].startswith("__tinykv_")
|
|
698
|
+
}
|
|
529
699
|
|
|
530
|
-
def set_many(
|
|
700
|
+
def set_many(
|
|
701
|
+
self,
|
|
702
|
+
kvdict: Mapping[str, Any],
|
|
703
|
+
ttl: int | None = None,
|
|
704
|
+
) -> None:
|
|
531
705
|
"""Set many values at once.
|
|
532
706
|
|
|
533
707
|
Args:
|
|
534
708
|
kvdict: A mapping of keys to values.
|
|
709
|
+
ttl: Optional nonnegative lifetime in seconds for each key.
|
|
710
|
+
``None`` stores keys without expiration.
|
|
535
711
|
|
|
536
712
|
Raises:
|
|
537
713
|
TypeError: If any key is not a string.
|
|
538
|
-
ValueError: If any key is
|
|
714
|
+
ValueError: If any key is empty or starts with the reserved prefix,
|
|
715
|
+
or ``ttl`` is negative or exceeds SQLite's integer range.
|
|
539
716
|
|
|
540
717
|
"""
|
|
718
|
+
expires_at = _ttl_expiration(ttl)
|
|
541
719
|
assert self._conn
|
|
542
720
|
item_iter = iter(kvdict.items())
|
|
543
721
|
first_chunk = list(itertools.islice(item_iter, 1))
|
|
544
722
|
if not first_chunk:
|
|
545
723
|
return
|
|
724
|
+
expiration_sql = "NULL" if expires_at is None else str(expires_at)
|
|
546
725
|
chunk_size = max(
|
|
547
726
|
1,
|
|
548
727
|
self._conn.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // 3,
|
|
@@ -553,10 +732,14 @@ class TinyKV:
|
|
|
553
732
|
params: list[Any] = []
|
|
554
733
|
for k, value in chunk:
|
|
555
734
|
_validate_key(k)
|
|
556
|
-
|
|
735
|
+
dtype, data = self._serialize(value)
|
|
736
|
+
params.extend((k, dtype, data))
|
|
737
|
+
values_sql = ", ".join(
|
|
738
|
+
[f"(?, ?, ?, {expiration_sql})"] * len(chunk)
|
|
739
|
+
)
|
|
557
740
|
self._conn.execute(
|
|
558
|
-
f"INSERT OR REPLACE INTO {self._quoted_table}
|
|
559
|
-
f"
|
|
741
|
+
f"INSERT OR REPLACE INTO {self._quoted_table} "
|
|
742
|
+
f"(k, t, v, e) VALUES {values_sql}",
|
|
560
743
|
params,
|
|
561
744
|
)
|
|
562
745
|
chunk = list(itertools.islice(item_iter, chunk_size))
|
|
@@ -576,7 +759,9 @@ class TinyKV:
|
|
|
576
759
|
_validate_key(key)
|
|
577
760
|
assert self._conn
|
|
578
761
|
cur = self._conn.execute(
|
|
579
|
-
f"DELETE FROM {self._quoted_table} WHERE k = ?"
|
|
762
|
+
f"DELETE FROM {self._quoted_table} WHERE k = ? "
|
|
763
|
+
"AND (e IS NULL OR e > CAST(strftime('%s', 'now') AS INT))",
|
|
764
|
+
(key,),
|
|
580
765
|
)
|
|
581
766
|
if cur.rowcount == 0:
|
|
582
767
|
raise KeyError(key)
|
|
@@ -609,7 +794,20 @@ class TinyKV:
|
|
|
609
794
|
_validate_key(k)
|
|
610
795
|
self._conn.execute(
|
|
611
796
|
f"DELETE FROM {self._quoted_table} WHERE "
|
|
612
|
-
f"k IN ({', '.join(['?'] * len(tkeys))})"
|
|
797
|
+
f"k IN ({', '.join(['?'] * len(tkeys))}) "
|
|
798
|
+
"AND (e IS NULL OR e > "
|
|
799
|
+
"CAST(strftime('%s', 'now') AS INT))",
|
|
613
800
|
tkeys,
|
|
614
801
|
)
|
|
615
802
|
tkeys = tuple(itertools.islice(key_iter, chunk_size))
|
|
803
|
+
|
|
804
|
+
def gc(self) -> int:
|
|
805
|
+
"""Delete expired keys and return the number of rows removed."""
|
|
806
|
+
assert self._conn
|
|
807
|
+
with _Savepoint(self._conn):
|
|
808
|
+
cursor = self._conn.execute(
|
|
809
|
+
f"DELETE FROM {self._quoted_table} WHERE e IS NOT NULL "
|
|
810
|
+
"AND e <= ?",
|
|
811
|
+
(int(time.time()),),
|
|
812
|
+
)
|
|
813
|
+
return cursor.rowcount
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Database schema migrations for TinyKV.
|
|
2
|
+
|
|
3
|
+
This module is imported only when opening a database that needs migration.
|
|
4
|
+
Each migration advances exactly one schema version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
_SCHEMA_VERSION_KEY = "__tinykv_schema_version"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def migrate_v1_to_v2(
|
|
14
|
+
conn: sqlite3.Connection,
|
|
15
|
+
table: str,
|
|
16
|
+
quoted_table: str,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Add the expiration column and mark the table as schema v2."""
|
|
19
|
+
schema, name = (None, table)
|
|
20
|
+
if "." in table:
|
|
21
|
+
schema, name = table.split(".", 1)
|
|
22
|
+
pragma = "table_info" if schema is None else f'"{schema}".table_info'
|
|
23
|
+
columns = {
|
|
24
|
+
row[1] for row in conn.execute(f'PRAGMA {pragma}("{name}")').fetchall()
|
|
25
|
+
}
|
|
26
|
+
if "e" not in columns:
|
|
27
|
+
conn.execute(
|
|
28
|
+
f"ALTER TABLE {quoted_table} ADD COLUMN e INT CHECK (e > 0)"
|
|
29
|
+
)
|
|
30
|
+
conn.execute(
|
|
31
|
+
f"INSERT OR REPLACE INTO {quoted_table} (k, t, v, e) "
|
|
32
|
+
"VALUES (?, ?, ?, NULL)",
|
|
33
|
+
(_SCHEMA_VERSION_KEY, 7, b"2"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MIGRATIONS: dict[int, Callable[[sqlite3.Connection, str, str], None]] = {
|
|
38
|
+
1: migrate_v1_to_v2,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def migrate_schema(
|
|
43
|
+
conn: sqlite3.Connection,
|
|
44
|
+
table: str,
|
|
45
|
+
quoted_table: str,
|
|
46
|
+
current_version: int,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Apply each required migration in ascending version order."""
|
|
49
|
+
version = current_version
|
|
50
|
+
while version in _MIGRATIONS:
|
|
51
|
+
_MIGRATIONS[version](conn, table, quoted_table)
|
|
52
|
+
version += 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|