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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tinykv
3
- Version: 0.3.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 the format `__tinykv_XXXXX__` are reserved for TinyKV internal
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 the format `__tinykv_XXXXX__` are reserved for TinyKV internal
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
- known-first-party = ["traderworkbench"]
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
- __version__ = "0.3.0"
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
- logger = logging.getLogger(__name__)
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
- conn.execute(
83
- f"CREATE TABLE {if_not_exists_sql}{quoted_table} ("
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(self, key: str, value: Any) -> None: # noqa: ANN401
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 an empty string or the database row is
283
- malformed.
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 = ?", (key,)
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 an empty string or a database row 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
- while True:
366
- tkeys = tuple(itertools.islice(key_iter, chunk_size))
367
- if not tkeys:
368
- break
369
- for k in tkeys:
370
- _validate_key(k)
371
- rows = self._conn.execute(
372
- f"SELECT k, t, v FROM {self._quoted_table} WHERE "
373
- f"k IN ({', '.join(['?'] * len(tkeys))})",
374
- tkeys,
375
- )
376
- result.update(
377
- {r[0]: self._decode_row(r[1], r[2]) for r in rows.fetchall()}
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 {r[0]: self._decode_row(r[1], r[2]) for r in rows.fetchall()}
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(self, kvdict: Mapping[str, Any]) -> None:
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 an empty string.
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.execute("SAVEPOINT tinykv_batch")
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
- params.extend((k, *self._serialize(value)))
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} (k, t, v) "
437
- f"VALUES {', '.join(['(?, ?, ?)'] * len(chunk))}",
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 = ?", (key,)
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.execute("SAVEPOINT tinykv_batch")
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
- except Exception:
501
- self._conn.execute("ROLLBACK TO tinykv_batch")
502
- self._conn.execute("RELEASE tinykv_batch")
503
- raise
504
- self._conn.execute("RELEASE tinykv_batch")
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