tinykv 0.3.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.3.0 → tinykv-0.5.0}/PKG-INFO +50 -3
- {tinykv-0.3.0 → tinykv-0.5.0}/README.md +49 -2
- {tinykv-0.3.0 → tinykv-0.5.0}/pyproject.toml +9 -1
- {tinykv-0.3.0 → tinykv-0.5.0}/src/tinykv/__init__.py +367 -58
- tinykv-0.5.0/src/tinykv/_migrations.py +52 -0
- {tinykv-0.3.0 → tinykv-0.5.0}/LICENSE +0 -0
- {tinykv-0.3.0 → tinykv-0.5.0}/src/tinykv/__init__.py~ +0 -0
- {tinykv-0.3.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,47 @@ 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
|
+
|
|
115
|
+
Use `inc()`, `dec()`, and `add()` to update integer counters atomically:
|
|
116
|
+
|
|
117
|
+
>>> kv.inc('visits')
|
|
118
|
+
1
|
|
119
|
+
>>> kv.inc('visits', 2)
|
|
120
|
+
3
|
|
121
|
+
>>> kv.dec('visits')
|
|
122
|
+
2
|
|
123
|
+
>>> kv.add('visits', -5)
|
|
124
|
+
-3
|
|
125
|
+
|
|
126
|
+
Missing keys start at zero. Amounts must be integers (not booleans), and
|
|
127
|
+
`inc()` and `dec()` require nonnegative amounts. An existing value must be an
|
|
128
|
+
integer; otherwise the operation raises `TypeError`.
|
|
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
|
+
|
|
100
141
|
Trying to get a nonexistent key raises _KeyError_:
|
|
101
142
|
|
|
102
143
|
>>> kv.get('not-there')
|
|
@@ -185,6 +226,10 @@ once:
|
|
|
185
226
|
>>> kv.get('two')
|
|
186
227
|
2
|
|
187
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
|
+
|
|
188
233
|
Call `get_many()` to retrieve many entries. The function returns a
|
|
189
234
|
_dict_ of all keys-values found in the database:
|
|
190
235
|
|
|
@@ -246,6 +291,9 @@ If you need idempotent setup, pass `if_not_exists=True`:
|
|
|
246
291
|
|
|
247
292
|
>>> tinykv.create_schema(conn, if_not_exists=True)
|
|
248
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
|
+
|
|
249
297
|
You can use a custom table name:
|
|
250
298
|
|
|
251
299
|
>>> tinykv.create_schema(conn, table='my_keys')
|
|
@@ -271,8 +319,7 @@ Miscellaneous
|
|
|
271
319
|
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
272
320
|
(since v0.1.4).
|
|
273
321
|
|
|
274
|
-
- Keys with
|
|
275
|
-
use.
|
|
322
|
+
- Keys starting with `__tinykv_` are reserved for TinyKV internal use.
|
|
276
323
|
|
|
277
324
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
278
325
|
in autocommit and non-autocommit mode. All operations are atomic.
|
|
@@ -79,6 +79,47 @@ 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
|
+
|
|
97
|
+
Use `inc()`, `dec()`, and `add()` to update integer counters atomically:
|
|
98
|
+
|
|
99
|
+
>>> kv.inc('visits')
|
|
100
|
+
1
|
|
101
|
+
>>> kv.inc('visits', 2)
|
|
102
|
+
3
|
|
103
|
+
>>> kv.dec('visits')
|
|
104
|
+
2
|
|
105
|
+
>>> kv.add('visits', -5)
|
|
106
|
+
-3
|
|
107
|
+
|
|
108
|
+
Missing keys start at zero. Amounts must be integers (not booleans), and
|
|
109
|
+
`inc()` and `dec()` require nonnegative amounts. An existing value must be an
|
|
110
|
+
integer; otherwise the operation raises `TypeError`.
|
|
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
|
+
|
|
82
123
|
Trying to get a nonexistent key raises _KeyError_:
|
|
83
124
|
|
|
84
125
|
>>> kv.get('not-there')
|
|
@@ -167,6 +208,10 @@ once:
|
|
|
167
208
|
>>> kv.get('two')
|
|
168
209
|
2
|
|
169
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
|
+
|
|
170
215
|
Call `get_many()` to retrieve many entries. The function returns a
|
|
171
216
|
_dict_ of all keys-values found in the database:
|
|
172
217
|
|
|
@@ -228,6 +273,9 @@ If you need idempotent setup, pass `if_not_exists=True`:
|
|
|
228
273
|
|
|
229
274
|
>>> tinykv.create_schema(conn, if_not_exists=True)
|
|
230
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
|
+
|
|
231
279
|
You can use a custom table name:
|
|
232
280
|
|
|
233
281
|
>>> tinykv.create_schema(conn, table='my_keys')
|
|
@@ -253,8 +301,7 @@ Miscellaneous
|
|
|
253
301
|
`TypeError`, and empty strings raise `ValueError`. Keys are case-sensitive
|
|
254
302
|
(since v0.1.4).
|
|
255
303
|
|
|
256
|
-
- Keys with
|
|
257
|
-
use.
|
|
304
|
+
- Keys starting with `__tinykv_` are reserved for TinyKV internal use.
|
|
258
305
|
|
|
259
306
|
- TinyKV does not open or manage transactions. Also, it operates both
|
|
260
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,10 +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
|
|
12
|
+
from types import EllipsisType, TracebackType
|
|
11
13
|
from typing import Any
|
|
12
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()
|
|
13
24
|
_TABLE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*\Z")
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
14
26
|
|
|
15
27
|
|
|
16
28
|
def _split_table_name(table: str) -> tuple[str | None, str]:
|
|
@@ -52,15 +64,75 @@ def _validate_key(key: str) -> None:
|
|
|
52
64
|
raise TypeError(f"key must be a string, got {type(key).__name__}")
|
|
53
65
|
if not key:
|
|
54
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
|
+
)
|
|
55
71
|
|
|
56
72
|
|
|
57
|
-
|
|
73
|
+
def _validate_int(value: Any, name: str) -> None: # noqa: ANN401
|
|
74
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"{name} must be an integer, got {type(value).__name__}"
|
|
77
|
+
)
|
|
58
78
|
|
|
59
|
-
_DEF_TABLE = "kv"
|
|
60
|
-
_NAN_PICKLE = pickle.dumps(float("nan"))
|
|
61
|
-
_MISSING = object()
|
|
62
79
|
|
|
63
|
-
|
|
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
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Savepoint:
|
|
93
|
+
"""Manage an atomic operation using a uniquely named savepoint."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
96
|
+
self._conn = conn
|
|
97
|
+
self._name = f"__tinykv_{next(_SAVEPOINT_IDS)}"
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> "_Savepoint":
|
|
100
|
+
self._conn.execute(f"SAVEPOINT {self._name}")
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc_value: BaseException | None,
|
|
107
|
+
traceback: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
if exc_type is not None:
|
|
110
|
+
self._conn.execute(f"ROLLBACK TO {self._name}")
|
|
111
|
+
self._conn.execute(f"RELEASE {self._name}")
|
|
112
|
+
|
|
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)
|
|
64
136
|
|
|
65
137
|
|
|
66
138
|
def create_schema(
|
|
@@ -77,16 +149,39 @@ def create_schema(
|
|
|
77
149
|
repeated schema creation is a no-op.
|
|
78
150
|
|
|
79
151
|
"""
|
|
152
|
+
schema, name = _split_table_name(table)
|
|
80
153
|
quoted_table = _quote_table_name(table)
|
|
81
154
|
if_not_exists_sql = "IF NOT EXISTS " if if_not_exists else ""
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"k TEXT NOT NULL, "
|
|
85
|
-
"t TINYINT NOT NULL CHECK (t BETWEEN 1 AND 7), "
|
|
86
|
-
"v BLOB, "
|
|
87
|
-
"PRIMARY KEY (k)"
|
|
88
|
-
") WITHOUT ROWID"
|
|
155
|
+
catalog = (
|
|
156
|
+
"sqlite_master" if schema is None else f'"{schema}".sqlite_master'
|
|
89
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
|
|
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
|
+
)
|
|
90
185
|
|
|
91
186
|
|
|
92
187
|
class _DType(enum.IntEnum):
|
|
@@ -159,6 +254,7 @@ class TinyKV:
|
|
|
159
254
|
self._table = table
|
|
160
255
|
self._quoted_table = quoted_table
|
|
161
256
|
self._allow_pickle = allow_pickle
|
|
257
|
+
_migrate_schema(conn, table, quoted_table)
|
|
162
258
|
|
|
163
259
|
@property
|
|
164
260
|
def conn(self) -> sqlite3.Connection:
|
|
@@ -270,29 +366,213 @@ class TinyKV:
|
|
|
270
366
|
raise ValueError(f"Unsupported data type {dtype}") from exc
|
|
271
367
|
return self._unserialize(row_type, data)
|
|
272
368
|
|
|
273
|
-
def set(
|
|
369
|
+
def set(
|
|
370
|
+
self,
|
|
371
|
+
key: str,
|
|
372
|
+
value: Any, # noqa: ANN401
|
|
373
|
+
ttl: int | None = None,
|
|
374
|
+
) -> None:
|
|
274
375
|
"""Store a value in the database.
|
|
275
376
|
|
|
276
377
|
Args:
|
|
277
378
|
key: The key to store the value under.
|
|
278
379
|
value: The value to store.
|
|
380
|
+
ttl: Optional nonnegative lifetime in seconds. ``None`` keeps the
|
|
381
|
+
key indefinitely.
|
|
279
382
|
|
|
280
383
|
Raises:
|
|
281
384
|
TypeError: If key is not a string.
|
|
282
|
-
ValueError: If key is
|
|
283
|
-
|
|
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.
|
|
284
408
|
|
|
285
409
|
"""
|
|
286
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."""
|
|
287
428
|
assert self._conn
|
|
288
429
|
dtype, data = self._serialize(value)
|
|
289
430
|
self._conn.execute(
|
|
290
431
|
f"INSERT OR REPLACE INTO {self._quoted_table} "
|
|
291
|
-
"(k, t, v) "
|
|
292
|
-
"VALUES (?, ?, ?)",
|
|
293
|
-
(key, dtype, data),
|
|
432
|
+
"(k, t, v, e) "
|
|
433
|
+
"VALUES (?, ?, ?, ?)",
|
|
434
|
+
(key, dtype, data, expires_at),
|
|
294
435
|
)
|
|
295
436
|
|
|
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:
|
|
445
|
+
"""Add an integer amount to the value stored under ``key``.
|
|
446
|
+
|
|
447
|
+
If the key does not exist, it is treated as having value zero.
|
|
448
|
+
Existing values must be integers; booleans and other types are not
|
|
449
|
+
accepted. The operation is atomic and returns the new value.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
key: The key to update.
|
|
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.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
The updated integer value.
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
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.
|
|
469
|
+
|
|
470
|
+
"""
|
|
471
|
+
_validate_key(key)
|
|
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)
|
|
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."""
|
|
487
|
+
assert self._conn
|
|
488
|
+
with _Savepoint(self._conn):
|
|
489
|
+
cur = self._conn.execute(
|
|
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,),
|
|
493
|
+
)
|
|
494
|
+
row = cur.fetchone()
|
|
495
|
+
if row is None:
|
|
496
|
+
value = 0
|
|
497
|
+
expires_at = create_expires_at
|
|
498
|
+
else:
|
|
499
|
+
value = self._decode_row(row[0], row[1])
|
|
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
|
|
505
|
+
|
|
506
|
+
result = value + n
|
|
507
|
+
self._set(key, result, expires_at)
|
|
508
|
+
return result
|
|
509
|
+
|
|
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:
|
|
518
|
+
"""Increment the integer value stored under ``key`` by ``n``.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
key: The key to update.
|
|
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.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
The updated integer value.
|
|
529
|
+
|
|
530
|
+
Raises:
|
|
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.
|
|
536
|
+
|
|
537
|
+
"""
|
|
538
|
+
_validate_int(n, "n")
|
|
539
|
+
if n < 0:
|
|
540
|
+
raise ValueError("n must be nonnegative")
|
|
541
|
+
return self.add(key, n, create_ttl=create_ttl, update_ttl=update_ttl)
|
|
542
|
+
|
|
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:
|
|
551
|
+
"""Decrement the integer value stored under ``key`` by ``n``.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
key: The key to update.
|
|
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.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
The updated integer value.
|
|
562
|
+
|
|
563
|
+
Raises:
|
|
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.
|
|
569
|
+
|
|
570
|
+
"""
|
|
571
|
+
_validate_int(n, "n")
|
|
572
|
+
if n < 0:
|
|
573
|
+
raise ValueError("n must be nonnegative")
|
|
574
|
+
return self.add(key, -n, create_ttl=create_ttl, update_ttl=update_ttl)
|
|
575
|
+
|
|
296
576
|
def get(
|
|
297
577
|
self,
|
|
298
578
|
key: str,
|
|
@@ -330,7 +610,9 @@ class TinyKV:
|
|
|
330
610
|
_validate_key(key)
|
|
331
611
|
assert self._conn
|
|
332
612
|
cur = self._conn.execute(
|
|
333
|
-
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,),
|
|
334
616
|
)
|
|
335
617
|
row = cur.fetchone()
|
|
336
618
|
if not row:
|
|
@@ -351,8 +633,8 @@ class TinyKV:
|
|
|
351
633
|
|
|
352
634
|
Raises:
|
|
353
635
|
TypeError: If any key is not a string.
|
|
354
|
-
ValueError: If any key is
|
|
355
|
-
malformed.
|
|
636
|
+
ValueError: If any key is empty or starts with the reserved
|
|
637
|
+
prefix, or a database row is malformed.
|
|
356
638
|
|
|
357
639
|
"""
|
|
358
640
|
assert self._conn
|
|
@@ -362,20 +644,26 @@ class TinyKV:
|
|
|
362
644
|
1,
|
|
363
645
|
self._conn.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER),
|
|
364
646
|
)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
647
|
+
with _Savepoint(self._conn):
|
|
648
|
+
while True:
|
|
649
|
+
tkeys = tuple(itertools.islice(key_iter, chunk_size))
|
|
650
|
+
if not tkeys:
|
|
651
|
+
break
|
|
652
|
+
for k in tkeys:
|
|
653
|
+
_validate_key(k)
|
|
654
|
+
rows = self._conn.execute(
|
|
655
|
+
f"SELECT k, t, v FROM {self._quoted_table} WHERE "
|
|
656
|
+
f"k IN ({', '.join(['?'] * len(tkeys))}) "
|
|
657
|
+
"AND (e IS NULL OR e > "
|
|
658
|
+
"CAST(strftime('%s', 'now') AS INT))",
|
|
659
|
+
tkeys,
|
|
660
|
+
)
|
|
661
|
+
result.update(
|
|
662
|
+
{
|
|
663
|
+
r[0]: self._decode_row(r[1], r[2])
|
|
664
|
+
for r in rows.fetchall()
|
|
665
|
+
}
|
|
666
|
+
)
|
|
379
667
|
return result
|
|
380
668
|
|
|
381
669
|
def get_glob(self, glob_key: str) -> dict[str, Any]:
|
|
@@ -399,50 +687,62 @@ class TinyKV:
|
|
|
399
687
|
_validate_key(glob_key)
|
|
400
688
|
assert self._conn
|
|
401
689
|
rows = self._conn.execute(
|
|
402
|
-
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))",
|
|
403
692
|
(glob_key,),
|
|
404
693
|
)
|
|
405
|
-
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
|
+
}
|
|
406
699
|
|
|
407
|
-
def set_many(
|
|
700
|
+
def set_many(
|
|
701
|
+
self,
|
|
702
|
+
kvdict: Mapping[str, Any],
|
|
703
|
+
ttl: int | None = None,
|
|
704
|
+
) -> None:
|
|
408
705
|
"""Set many values at once.
|
|
409
706
|
|
|
410
707
|
Args:
|
|
411
708
|
kvdict: A mapping of keys to values.
|
|
709
|
+
ttl: Optional nonnegative lifetime in seconds for each key.
|
|
710
|
+
``None`` stores keys without expiration.
|
|
412
711
|
|
|
413
712
|
Raises:
|
|
414
713
|
TypeError: If any key is not a string.
|
|
415
|
-
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.
|
|
416
716
|
|
|
417
717
|
"""
|
|
718
|
+
expires_at = _ttl_expiration(ttl)
|
|
418
719
|
assert self._conn
|
|
419
720
|
item_iter = iter(kvdict.items())
|
|
420
721
|
first_chunk = list(itertools.islice(item_iter, 1))
|
|
421
722
|
if not first_chunk:
|
|
422
723
|
return
|
|
724
|
+
expiration_sql = "NULL" if expires_at is None else str(expires_at)
|
|
423
725
|
chunk_size = max(
|
|
424
726
|
1,
|
|
425
727
|
self._conn.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // 3,
|
|
426
728
|
)
|
|
427
|
-
self._conn
|
|
428
|
-
try:
|
|
729
|
+
with _Savepoint(self._conn):
|
|
429
730
|
chunk = first_chunk
|
|
430
731
|
while chunk:
|
|
431
732
|
params: list[Any] = []
|
|
432
733
|
for k, value in chunk:
|
|
433
734
|
_validate_key(k)
|
|
434
|
-
|
|
735
|
+
dtype, data = self._serialize(value)
|
|
736
|
+
params.extend((k, dtype, data))
|
|
737
|
+
values_sql = ", ".join(
|
|
738
|
+
[f"(?, ?, ?, {expiration_sql})"] * len(chunk)
|
|
739
|
+
)
|
|
435
740
|
self._conn.execute(
|
|
436
|
-
f"INSERT OR REPLACE INTO {self._quoted_table}
|
|
437
|
-
f"
|
|
741
|
+
f"INSERT OR REPLACE INTO {self._quoted_table} "
|
|
742
|
+
f"(k, t, v, e) VALUES {values_sql}",
|
|
438
743
|
params,
|
|
439
744
|
)
|
|
440
745
|
chunk = list(itertools.islice(item_iter, chunk_size))
|
|
441
|
-
except Exception:
|
|
442
|
-
self._conn.execute("ROLLBACK TO tinykv_batch")
|
|
443
|
-
self._conn.execute("RELEASE tinykv_batch")
|
|
444
|
-
raise
|
|
445
|
-
self._conn.execute("RELEASE tinykv_batch")
|
|
446
746
|
|
|
447
747
|
def remove(self, key: str) -> None:
|
|
448
748
|
"""Remove a key from the database.
|
|
@@ -459,7 +759,9 @@ class TinyKV:
|
|
|
459
759
|
_validate_key(key)
|
|
460
760
|
assert self._conn
|
|
461
761
|
cur = self._conn.execute(
|
|
462
|
-
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,),
|
|
463
765
|
)
|
|
464
766
|
if cur.rowcount == 0:
|
|
465
767
|
raise KeyError(key)
|
|
@@ -485,20 +787,27 @@ class TinyKV:
|
|
|
485
787
|
1,
|
|
486
788
|
self._conn.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER),
|
|
487
789
|
)
|
|
488
|
-
self._conn
|
|
489
|
-
try:
|
|
790
|
+
with _Savepoint(self._conn):
|
|
490
791
|
tkeys = first_chunk
|
|
491
792
|
while tkeys:
|
|
492
793
|
for k in tkeys:
|
|
493
794
|
_validate_key(k)
|
|
494
795
|
self._conn.execute(
|
|
495
796
|
f"DELETE FROM {self._quoted_table} WHERE "
|
|
496
|
-
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))",
|
|
497
800
|
tkeys,
|
|
498
801
|
)
|
|
499
802
|
tkeys = tuple(itertools.islice(key_iter, chunk_size))
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
self._conn
|
|
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
|