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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tinykv
3
- Version: 0.4.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 the format `__tinykv_XXXXX__` are reserved for TinyKV internal
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 the format `__tinykv_XXXXX__` are reserved for TinyKV internal
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
- 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,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
- __version__ = "0.4.0"
66
-
67
- _DEF_TABLE = "kv"
68
- _NAN_PICKLE = pickle.dumps(float("nan"))
69
- _MISSING = object()
70
- _SAVEPOINT_IDS = itertools.count()
71
-
72
- 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
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
- conn.execute(
114
- f"CREATE TABLE {if_not_exists_sql}{quoted_table} ("
115
- "k TEXT NOT NULL, "
116
- "t TINYINT NOT NULL CHECK (t BETWEEN 1 AND 7), "
117
- "v BLOB, "
118
- "PRIMARY KEY (k)"
119
- ") WITHOUT ROWID"
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(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:
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 an empty string or the database row is
314
- 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.
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(self, key: str, n: int) -> int:
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 ``n`` has an invalid type, or if the
343
- existing value is not an integer.
344
- ValueError: If ``key`` is empty or the stored row is malformed.
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 = ?", (key,)
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
- dtype, data = self._serialize(result)
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(self, key: str, n: int = 1) -> int:
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 ``n`` has an invalid type, or if the
383
- existing value is not an integer.
384
- ValueError: If ``key`` is empty, ``n`` is negative, or the stored
385
- row is malformed.
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(self, key: str, n: int = 1) -> int:
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 ``n`` has an invalid type, or if the
405
- existing value is not an integer.
406
- ValueError: If ``key`` is empty, ``n`` is negative, or the stored
407
- row is malformed.
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 = ?", (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,),
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 an empty string or a database row 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 {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
+ }
529
699
 
530
- 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:
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 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.
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
- 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
+ )
557
740
  self._conn.execute(
558
- f"INSERT OR REPLACE INTO {self._quoted_table} (k, t, v) "
559
- f"VALUES {', '.join(['(?, ?, ?)'] * len(chunk))}",
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 = ?", (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,),
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