litsdb 0.1.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.
- litsdb-0.1.0/PKG-INFO +23 -0
- litsdb-0.1.0/litsdb/__init__.py +61 -0
- litsdb-0.1.0/litsdb/_collection.py +267 -0
- litsdb-0.1.0/litsdb/_database.py +308 -0
- litsdb-0.1.0/litsdb/_exceptions.py +98 -0
- litsdb-0.1.0/litsdb/_map.py +366 -0
- litsdb-0.1.0/litsdb/_seq.py +358 -0
- litsdb-0.1.0/litsdb/_set.py +226 -0
- litsdb-0.1.0/litsdb.egg-info/PKG-INFO +23 -0
- litsdb-0.1.0/litsdb.egg-info/SOURCES.txt +13 -0
- litsdb-0.1.0/litsdb.egg-info/dependency_links.txt +1 -0
- litsdb-0.1.0/litsdb.egg-info/requires.txt +6 -0
- litsdb-0.1.0/litsdb.egg-info/top_level.txt +1 -0
- litsdb-0.1.0/setup.cfg +4 -0
- litsdb-0.1.0/setup.py +52 -0
litsdb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: litsdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent, type-safe collections backed by SQLite and pylits serialization.
|
|
5
|
+
Author: Romashka
|
|
6
|
+
Keywords: easy database
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: Database
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
13
|
+
Requires-Dist: pylits>=0.1.1
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: keywords
|
|
20
|
+
Dynamic: provides-extra
|
|
21
|
+
Dynamic: requires-dist
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
Dynamic: summary
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent, type-safe collections for Python, backed by SQLite and
|
|
3
|
+
serialized with `pylits <https://pypi.org/project/pylits/>`_.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
import litsdb
|
|
8
|
+
|
|
9
|
+
db = litsdb.Database("mydb.db")
|
|
10
|
+
|
|
11
|
+
users: litsdb.LitSeq = db.seq("users")
|
|
12
|
+
tags: litsdb.LitSet = db.set("tags")
|
|
13
|
+
names: litsdb.LitMap = db.map("names")
|
|
14
|
+
|
|
15
|
+
users.append(123)
|
|
16
|
+
tags.add("python")
|
|
17
|
+
names[123] = "Romashka"
|
|
18
|
+
|
|
19
|
+
# Transactions
|
|
20
|
+
try:
|
|
21
|
+
with names.transaction() as names_t:
|
|
22
|
+
names_t.set(123, "Vasyl")
|
|
23
|
+
names_t.pop(999) # raises → rolls back
|
|
24
|
+
except litsdb.TransactionFailed as exc:
|
|
25
|
+
print(exc.failed.method) # "pop"
|
|
26
|
+
print(exc.failed.msg) # "KeyError: 999"
|
|
27
|
+
|
|
28
|
+
Collection types
|
|
29
|
+
----------------
|
|
30
|
+
|
|
31
|
+
+------------------+-------------------+--------------------------------+
|
|
32
|
+
| Class | Python analogue | Primary key |
|
|
33
|
+
+==================+===================+================================+
|
|
34
|
+
| :class:`LitSeq` | :class:`list` | Auto-increment integer ``id`` |
|
|
35
|
+
+------------------+-------------------+--------------------------------+
|
|
36
|
+
| :class:`LitSet` | :class:`set` | The encoded value itself |
|
|
37
|
+
+------------------+-------------------+--------------------------------+
|
|
38
|
+
| :class:`LitMap` | :class:`dict` | The encoded key |
|
|
39
|
+
+------------------+-------------------+--------------------------------+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from ._database import Database
|
|
43
|
+
from ._collection import LitCollection
|
|
44
|
+
from ._seq import LitSeq
|
|
45
|
+
from ._set import LitSet
|
|
46
|
+
from ._map import LitMap
|
|
47
|
+
from ._exceptions import LitsDBError, TransactionFailed, FailedOperation
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"Database",
|
|
51
|
+
"LitCollection",
|
|
52
|
+
"LitSeq",
|
|
53
|
+
"LitSet",
|
|
54
|
+
"LitMap",
|
|
55
|
+
"LitsDBError",
|
|
56
|
+
"TransactionFailed",
|
|
57
|
+
"FailedOperation",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
__version__ = "0.1.0"
|
|
61
|
+
__author__ = "Romashka"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
litsdb._collection
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Abstract base class shared by all persistent collection types.
|
|
6
|
+
Provides thread-safe statement execution and the transaction context manager.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import Any, Generator, TYPE_CHECKING, Self
|
|
14
|
+
|
|
15
|
+
import pylits
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from sqlalchemy import Table
|
|
19
|
+
from sqlalchemy.engine import Connection, Engine, CursorResult
|
|
20
|
+
|
|
21
|
+
from ._exceptions import FailedOperation, TransactionFailed
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _compile_sql(stmt, engine) -> str:
|
|
25
|
+
"""Return a human-readable SQL string for *stmt*.
|
|
26
|
+
|
|
27
|
+
Falls back to :func:`repr` if compilation is not possible.
|
|
28
|
+
|
|
29
|
+
:param stmt: Any SQLAlchemy Core statement.
|
|
30
|
+
:param engine: Engine whose dialect is used for compilation.
|
|
31
|
+
:return: Compiled SQL string.
|
|
32
|
+
:rtype: str
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from sqlalchemy.dialects import sqlite as _d
|
|
36
|
+
return str(
|
|
37
|
+
stmt.compile(
|
|
38
|
+
dialect=_d.dialect(),
|
|
39
|
+
compile_kwargs={"literal_binds": True},
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
except Exception:
|
|
43
|
+
return repr(stmt)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LitCollection(ABC):
|
|
47
|
+
"""Abstract base class for all persistent litsdb collections.
|
|
48
|
+
|
|
49
|
+
Subclasses (:class:`~litsdb.LitSeq`, :class:`~litsdb.LitSet`,
|
|
50
|
+
:class:`~litsdb.LitMap`) inherit thread-safe statement execution,
|
|
51
|
+
pylits-based serialization, and the :meth:`transaction` context
|
|
52
|
+
manager from this class.
|
|
53
|
+
|
|
54
|
+
:param name: Logical name of the collection (used as part of the
|
|
55
|
+
underlying table name).
|
|
56
|
+
:type name: str
|
|
57
|
+
:param engine: SQLAlchemy :class:`~sqlalchemy.engine.Engine` shared
|
|
58
|
+
with the parent :class:`~litsdb.Database`.
|
|
59
|
+
:type engine: sqlalchemy.engine.Engine
|
|
60
|
+
:param table: Bound :class:`~sqlalchemy.Table` that backs this
|
|
61
|
+
collection.
|
|
62
|
+
:type table: sqlalchemy.Table
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
engine: "Engine",
|
|
69
|
+
table: "Table",
|
|
70
|
+
) -> None:
|
|
71
|
+
self._name = name
|
|
72
|
+
self._engine = engine
|
|
73
|
+
self._table = table
|
|
74
|
+
# Reentrant so that nested internal calls within the same thread work.
|
|
75
|
+
self._lock = threading.RLock()
|
|
76
|
+
# Per-thread state: active transaction connection + last operation info.
|
|
77
|
+
self._local = threading.local()
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Public read-only properties
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def name(self) -> str:
|
|
85
|
+
"""Logical name of this collection.
|
|
86
|
+
|
|
87
|
+
:rtype: str
|
|
88
|
+
"""
|
|
89
|
+
return self._name
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Serialization helpers
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _encode(self, value: Any) -> str:
|
|
96
|
+
"""Serialize *value* to a pylits string.
|
|
97
|
+
|
|
98
|
+
:param value: Any type supported by pylits
|
|
99
|
+
(:class:`bool`, :class:`int`, :class:`float`,
|
|
100
|
+
:class:`str`, :class:`list`, :class:`tuple`,
|
|
101
|
+
:class:`set`, :class:`dict`, ``None``).
|
|
102
|
+
:return: Encoded string.
|
|
103
|
+
:rtype: str
|
|
104
|
+
:raises TypeError: If *value* is not a supported pylits type.
|
|
105
|
+
"""
|
|
106
|
+
return pylits.encode(value)
|
|
107
|
+
|
|
108
|
+
def _decode(self, raw: str) -> Any:
|
|
109
|
+
"""Deserialize a pylits-encoded string back to a Python value.
|
|
110
|
+
|
|
111
|
+
:param raw: Encoded string stored in the database.
|
|
112
|
+
:return: Original Python value.
|
|
113
|
+
"""
|
|
114
|
+
return pylits.decode(raw)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Execution layer
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _get_tx_conn(self) -> "Connection | None":
|
|
121
|
+
"""Return the active transaction connection for the current thread.
|
|
122
|
+
|
|
123
|
+
:return: Open :class:`~sqlalchemy.engine.Connection` if this thread
|
|
124
|
+
is inside a :meth:`transaction` block, otherwise ``None``.
|
|
125
|
+
"""
|
|
126
|
+
return getattr(self._local, "tx_conn", None)
|
|
127
|
+
|
|
128
|
+
def _track(self, method_name: str, *args) -> None:
|
|
129
|
+
"""Record the current operation for error reporting.
|
|
130
|
+
|
|
131
|
+
Call at the **start** of every public method so that
|
|
132
|
+
:meth:`transaction` can populate :class:`~litsdb.FailedOperation`
|
|
133
|
+
even when the method raises before :meth:`_execute` is reached.
|
|
134
|
+
|
|
135
|
+
:param method_name: Name of the calling method.
|
|
136
|
+
:param args: Arguments passed to the calling method.
|
|
137
|
+
"""
|
|
138
|
+
self._local.last_method = method_name
|
|
139
|
+
self._local.last_args = args
|
|
140
|
+
self._local.last_stmt = "" # overwritten by _execute if reached
|
|
141
|
+
|
|
142
|
+
def _execute(
|
|
143
|
+
self,
|
|
144
|
+
stmt,
|
|
145
|
+
*,
|
|
146
|
+
method_name: str = "<unknown>",
|
|
147
|
+
method_args: tuple = (),
|
|
148
|
+
) -> "CursorResult":
|
|
149
|
+
"""Execute *stmt* using the transaction connection when active.
|
|
150
|
+
|
|
151
|
+
When called inside a :meth:`transaction` block the statement is sent
|
|
152
|
+
to the shared transaction connection without an implicit commit.
|
|
153
|
+
Otherwise a fresh connection is obtained from the pool and the
|
|
154
|
+
statement is committed immediately.
|
|
155
|
+
|
|
156
|
+
The method also stores ``method_name``, ``method_args``, and the
|
|
157
|
+
compiled SQL in thread-local storage so that :meth:`transaction`
|
|
158
|
+
can populate :class:`~litsdb.FailedOperation` on rollback.
|
|
159
|
+
|
|
160
|
+
:param stmt: SQLAlchemy Core statement to execute.
|
|
161
|
+
:param method_name: Caller method name — used for error reporting.
|
|
162
|
+
:type method_name: str
|
|
163
|
+
:param method_args: Arguments passed by the caller — used for error
|
|
164
|
+
reporting.
|
|
165
|
+
:type method_args: tuple
|
|
166
|
+
:return: SQLAlchemy :class:`~sqlalchemy.engine.CursorResult`.
|
|
167
|
+
"""
|
|
168
|
+
tx_conn = self._get_tx_conn()
|
|
169
|
+
|
|
170
|
+
if tx_conn is not None:
|
|
171
|
+
# Update error context only when inside a transaction —
|
|
172
|
+
# this is the only place where the info will be consumed.
|
|
173
|
+
self._local.last_method = method_name
|
|
174
|
+
self._local.last_args = method_args
|
|
175
|
+
self._local.last_stmt = _compile_sql(stmt, self._engine)
|
|
176
|
+
return tx_conn.execute(stmt)
|
|
177
|
+
|
|
178
|
+
# Acquire the collection lock so that concurrent non-transaction calls
|
|
179
|
+
# from different threads do not interleave on a shared connection
|
|
180
|
+
# (relevant for StaticPool / in-memory databases).
|
|
181
|
+
with self._lock:
|
|
182
|
+
with self._engine.connect() as conn:
|
|
183
|
+
result = conn.execute(stmt)
|
|
184
|
+
conn.commit()
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Transaction context manager
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
@contextmanager
|
|
192
|
+
def transaction(self) -> Generator["Self", None, None]:
|
|
193
|
+
"""Atomic, thread-safe transaction context manager.
|
|
194
|
+
|
|
195
|
+
Acquires an exclusive lock on the collection for the calling thread,
|
|
196
|
+
opens a single database connection, and begins a transaction. All
|
|
197
|
+
collection operations performed on the *yielded* object share that
|
|
198
|
+
connection.
|
|
199
|
+
|
|
200
|
+
On successful exit the transaction is **committed**.
|
|
201
|
+
On any exception the transaction is **rolled back** and a
|
|
202
|
+
:exc:`~litsdb.TransactionFailed` exception is raised, carrying a
|
|
203
|
+
:class:`~litsdb.FailedOperation` record with the method name,
|
|
204
|
+
compiled SQL, arguments, and error message.
|
|
205
|
+
|
|
206
|
+
:yields: This collection instance, bound to the open transaction.
|
|
207
|
+
:raises TransactionFailed: When any operation inside the block fails.
|
|
208
|
+
|
|
209
|
+
.. note::
|
|
210
|
+
The lock is reentrant (:class:`threading.RLock`), so nested
|
|
211
|
+
:meth:`transaction` calls from the **same thread** are safe.
|
|
212
|
+
|
|
213
|
+
Example::
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
with names.transaction() as names_t:
|
|
217
|
+
names_t.set(123, "Romashka")
|
|
218
|
+
names_t.pop(456)
|
|
219
|
+
except TransactionFailed as exc:
|
|
220
|
+
print(exc.failed.method) # e.g. "pop"
|
|
221
|
+
print(exc.failed.rawsql) # compiled SQL
|
|
222
|
+
print(exc.failed.args) # (456,)
|
|
223
|
+
print(exc.failed.msg) # "KeyError: 456"
|
|
224
|
+
"""
|
|
225
|
+
with self._lock:
|
|
226
|
+
with self._engine.connect() as conn:
|
|
227
|
+
# Explicit transaction: never rely on SA 2.x autobegin
|
|
228
|
+
# implicit commit — we call commit/rollback ourselves.
|
|
229
|
+
txn = conn.begin()
|
|
230
|
+
self._local.tx_conn = conn
|
|
231
|
+
try:
|
|
232
|
+
yield self
|
|
233
|
+
txn.commit()
|
|
234
|
+
except TransactionFailed:
|
|
235
|
+
txn.rollback()
|
|
236
|
+
raise
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
txn.rollback()
|
|
239
|
+
raise TransactionFailed(
|
|
240
|
+
FailedOperation(
|
|
241
|
+
method=getattr(self._local, "last_method", "<unknown>"),
|
|
242
|
+
rawsql=getattr(self._local, "last_stmt", ""),
|
|
243
|
+
args=getattr(self._local, "last_args", ()),
|
|
244
|
+
msg=str(exc),
|
|
245
|
+
)
|
|
246
|
+
) from exc
|
|
247
|
+
finally:
|
|
248
|
+
self._local.tx_conn = None
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Abstract interface every collection must implement
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
@abstractmethod
|
|
255
|
+
def clear(self) -> None:
|
|
256
|
+
"""Remove **all** entries from the collection.
|
|
257
|
+
|
|
258
|
+
Thread-safe; can be called inside a :meth:`transaction` block.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
@abstractmethod
|
|
263
|
+
def count(self) -> int:
|
|
264
|
+
"""Total number of entries in the collection.
|
|
265
|
+
|
|
266
|
+
:rtype: int
|
|
267
|
+
"""
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
litsdb._database
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Top-level ``Database`` class — the single entry point for creating and
|
|
6
|
+
managing persistent collections.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Dict, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import create_engine, MetaData, inspect, text
|
|
16
|
+
|
|
17
|
+
from ._collection import LitCollection
|
|
18
|
+
from ._seq import LitSeq
|
|
19
|
+
from ._set import LitSet
|
|
20
|
+
from ._map import LitMap
|
|
21
|
+
from ._exceptions import LitsDBError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Database:
|
|
25
|
+
"""Top-level interface for a litsdb SQLite database file.
|
|
26
|
+
|
|
27
|
+
A single :class:`Database` instance manages one SQLite file and acts as
|
|
28
|
+
the factory for all persistent collections (:class:`~litsdb.LitSeq`,
|
|
29
|
+
:class:`~litsdb.LitSet`, :class:`~litsdb.LitMap`).
|
|
30
|
+
|
|
31
|
+
The underlying :class:`~sqlalchemy.engine.Engine` is configured with
|
|
32
|
+
``check_same_thread=False`` and a reentrant lock so that the same
|
|
33
|
+
:class:`Database` handle can be safely shared across threads.
|
|
34
|
+
|
|
35
|
+
:param path: File path to the SQLite database. Parent directories are
|
|
36
|
+
created automatically. Use ``":memory:"`` for an in-memory
|
|
37
|
+
database (not recommended for production use).
|
|
38
|
+
:type path: str
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
import litsdb
|
|
43
|
+
|
|
44
|
+
db = litsdb.Database("mydb.db")
|
|
45
|
+
users: litsdb.LitSeq = db.seq("users")
|
|
46
|
+
names: litsdb.LitMap = db.map("names")
|
|
47
|
+
|
|
48
|
+
users.append(123)
|
|
49
|
+
names[123] = "Romashka"
|
|
50
|
+
|
|
51
|
+
Context-manager usage::
|
|
52
|
+
|
|
53
|
+
with litsdb.Database("mydb.db") as db:
|
|
54
|
+
users = db.seq("users")
|
|
55
|
+
users.append(1)
|
|
56
|
+
# engine is disposed automatically on exit
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, path: str) -> None:
|
|
60
|
+
if path != ":memory:":
|
|
61
|
+
dir_path = os.path.dirname(path)
|
|
62
|
+
if dir_path:
|
|
63
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
self._path = path
|
|
66
|
+
|
|
67
|
+
if path == ":memory:":
|
|
68
|
+
# StaticPool keeps a single connection so every thread sees the
|
|
69
|
+
# same in-memory database (SQLite :memory: is per-connection).
|
|
70
|
+
from sqlalchemy.pool import StaticPool
|
|
71
|
+
self._engine = create_engine(
|
|
72
|
+
"sqlite:///:memory:",
|
|
73
|
+
connect_args={"check_same_thread": False},
|
|
74
|
+
poolclass=StaticPool,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
self._engine = create_engine(
|
|
78
|
+
f"sqlite:///{path}",
|
|
79
|
+
connect_args={"check_same_thread": False},
|
|
80
|
+
)
|
|
81
|
+
self._metadata = MetaData()
|
|
82
|
+
self._lock = threading.RLock()
|
|
83
|
+
# Cache of already-opened collections keyed by their SQL table name.
|
|
84
|
+
self._collections: Dict[str, LitCollection] = {}
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Collection factories
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def seq(self, name: str) -> LitSeq:
|
|
91
|
+
"""Return (or open) a :class:`~litsdb.LitSeq` collection.
|
|
92
|
+
|
|
93
|
+
The backing SQL table is created on first access.
|
|
94
|
+
|
|
95
|
+
:param name: Logical name of the sequence.
|
|
96
|
+
:type name: str
|
|
97
|
+
:rtype: LitSeq
|
|
98
|
+
|
|
99
|
+
Example::
|
|
100
|
+
|
|
101
|
+
users: LitSeq = db.seq("users")
|
|
102
|
+
"""
|
|
103
|
+
return self._get_or_create(name, LitSeq) # pyright: ignore[reportReturnType]
|
|
104
|
+
|
|
105
|
+
def set(self, name: str) -> LitSet:
|
|
106
|
+
"""Return (or open) a :class:`~litsdb.LitSet` collection.
|
|
107
|
+
|
|
108
|
+
The backing SQL table is created on first access.
|
|
109
|
+
|
|
110
|
+
:param name: Logical name of the set.
|
|
111
|
+
:type name: str
|
|
112
|
+
:rtype: LitSet
|
|
113
|
+
|
|
114
|
+
Example::
|
|
115
|
+
|
|
116
|
+
tags: LitSet = db.set("tags")
|
|
117
|
+
"""
|
|
118
|
+
return self._get_or_create(name, LitSet) # pyright: ignore[reportReturnType]
|
|
119
|
+
|
|
120
|
+
def map(self, name: str) -> LitMap:
|
|
121
|
+
"""Return (or open) a :class:`~litsdb.LitMap` collection.
|
|
122
|
+
|
|
123
|
+
The backing SQL table is created on first access.
|
|
124
|
+
|
|
125
|
+
:param name: Logical name of the mapping.
|
|
126
|
+
:type name: str
|
|
127
|
+
:rtype: LitMap
|
|
128
|
+
|
|
129
|
+
Example::
|
|
130
|
+
|
|
131
|
+
names: LitMap = db.map("names")
|
|
132
|
+
"""
|
|
133
|
+
return self._get_or_create(name, LitMap) # pyright: ignore[reportReturnType]
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Database management
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def drop(self, name: str) -> bool:
|
|
140
|
+
"""Drop the collection named *name* and its underlying table.
|
|
141
|
+
|
|
142
|
+
Removes the collection from the in-memory cache and issues a
|
|
143
|
+
``DROP TABLE IF EXISTS`` statement.
|
|
144
|
+
|
|
145
|
+
.. warning::
|
|
146
|
+
This operation is **irreversible**. All data in the collection
|
|
147
|
+
is permanently deleted.
|
|
148
|
+
|
|
149
|
+
:param name: Logical name of the collection to drop.
|
|
150
|
+
:type name: str
|
|
151
|
+
:return: ``True`` if a table was dropped, ``False`` if nothing was
|
|
152
|
+
found.
|
|
153
|
+
:rtype: bool
|
|
154
|
+
|
|
155
|
+
Example::
|
|
156
|
+
|
|
157
|
+
db.drop("users")
|
|
158
|
+
"""
|
|
159
|
+
with self._lock:
|
|
160
|
+
# Check cached collections first (all possible type prefixes).
|
|
161
|
+
for prefix in ("_seq_", "_set_", "_map_"):
|
|
162
|
+
table_name = f"{prefix}{name}"
|
|
163
|
+
cached_key = table_name
|
|
164
|
+
self._collections.pop(cached_key, None)
|
|
165
|
+
|
|
166
|
+
# Inspect the actual database for any matching table.
|
|
167
|
+
insp = inspect(self._engine)
|
|
168
|
+
dropped = False
|
|
169
|
+
for prefix in ("_seq_", "_set_", "_map_"):
|
|
170
|
+
table_name = f"{prefix}{name}"
|
|
171
|
+
if insp.has_table(table_name):
|
|
172
|
+
with self._engine.connect() as conn:
|
|
173
|
+
conn.execute(text(f'DROP TABLE IF EXISTS "{table_name}"'))
|
|
174
|
+
conn.commit()
|
|
175
|
+
# Refresh metadata so SQLAlchemy stops tracking it.
|
|
176
|
+
self._metadata.remove(self._metadata.tables[table_name])
|
|
177
|
+
dropped = True
|
|
178
|
+
return dropped
|
|
179
|
+
|
|
180
|
+
def tables(self) -> Dict[str, LitCollection]:
|
|
181
|
+
"""Return all collection handles that have been opened in this session.
|
|
182
|
+
|
|
183
|
+
The mapping key is the SQL table name (e.g. ``"_seq_users"``).
|
|
184
|
+
To discover tables that exist on disk but have not been opened yet,
|
|
185
|
+
use :meth:`inspect_tables`.
|
|
186
|
+
|
|
187
|
+
:rtype: dict[str, LitCollection]
|
|
188
|
+
"""
|
|
189
|
+
with self._lock:
|
|
190
|
+
return dict(self._collections)
|
|
191
|
+
|
|
192
|
+
def inspect_tables(self) -> Dict[str, str]:
|
|
193
|
+
"""Scan the database file and return all litsdb-managed tables.
|
|
194
|
+
|
|
195
|
+
:return: ``{logical_name: collection_type}`` where *collection_type*
|
|
196
|
+
is one of ``"seq"``, ``"set"``, or ``"map"``.
|
|
197
|
+
:rtype: dict[str, str]
|
|
198
|
+
|
|
199
|
+
Example::
|
|
200
|
+
|
|
201
|
+
db.inspect_tables()
|
|
202
|
+
# {"users": "seq", "names": "map", "tags": "set"}
|
|
203
|
+
"""
|
|
204
|
+
insp = inspect(self._engine)
|
|
205
|
+
result: Dict[str, str] = {}
|
|
206
|
+
for tname in insp.get_table_names():
|
|
207
|
+
for prefix, kind in (
|
|
208
|
+
("_seq_", "seq"),
|
|
209
|
+
("_set_", "set"),
|
|
210
|
+
("_map_", "map"),
|
|
211
|
+
):
|
|
212
|
+
if tname.startswith(prefix):
|
|
213
|
+
result[tname[len(prefix):]] = kind
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
def backup(self, path: str) -> None:
|
|
217
|
+
"""Create a consistent backup of the database at *path*.
|
|
218
|
+
|
|
219
|
+
Uses the `SQLite online backup API`_ which is safe to call while the
|
|
220
|
+
database is open and actively used by other threads.
|
|
221
|
+
|
|
222
|
+
Parent directories of *path* are created automatically.
|
|
223
|
+
|
|
224
|
+
:param path: Destination path for the backup file.
|
|
225
|
+
:type path: str
|
|
226
|
+
:raises LitsDBError: If the backup fails for any reason.
|
|
227
|
+
:raises ValueError: If called on an in-memory database.
|
|
228
|
+
|
|
229
|
+
Example::
|
|
230
|
+
|
|
231
|
+
db.backup("backups/21_04_2026.db")
|
|
232
|
+
|
|
233
|
+
.. _SQLite online backup API:
|
|
234
|
+
https://www.sqlite.org/backup.html
|
|
235
|
+
"""
|
|
236
|
+
dir_path = os.path.dirname(path)
|
|
237
|
+
if dir_path:
|
|
238
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
dst = sqlite3.connect(path)
|
|
242
|
+
try:
|
|
243
|
+
if self._path == ":memory:":
|
|
244
|
+
# Obtain the raw DBAPI connection from the pool;
|
|
245
|
+
# works for both StaticPool and regular pool.
|
|
246
|
+
raw = self._engine.raw_connection()
|
|
247
|
+
try:
|
|
248
|
+
raw.backup(dst)
|
|
249
|
+
finally:
|
|
250
|
+
raw.close()
|
|
251
|
+
else:
|
|
252
|
+
with sqlite3.connect(self._path) as src_conn:
|
|
253
|
+
src_conn.backup(dst)
|
|
254
|
+
finally:
|
|
255
|
+
dst.close()
|
|
256
|
+
except LitsDBError:
|
|
257
|
+
raise
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
raise LitsDBError(f"Backup to {path!r} failed: {exc}") from exc
|
|
260
|
+
|
|
261
|
+
def close(self) -> None:
|
|
262
|
+
"""Dispose the engine and release all pooled connections.
|
|
263
|
+
|
|
264
|
+
Called automatically when :class:`Database` is used as a context
|
|
265
|
+
manager. Calling :meth:`close` does **not** invalidate existing
|
|
266
|
+
collection handles — they will simply fail on next use.
|
|
267
|
+
"""
|
|
268
|
+
self._engine.dispose()
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Context manager
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def __enter__(self) -> "Database":
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def __exit__(self, *_) -> None:
|
|
278
|
+
self.close()
|
|
279
|
+
|
|
280
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
281
|
+
return (
|
|
282
|
+
f"Database(path={self._path!r}, "
|
|
283
|
+
f"open_collections={len(self._collections)})"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
# Internal helpers
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _get_or_create(self, name: str, cls) -> LitCollection:
|
|
291
|
+
"""Return a cached collection instance or construct a new one.
|
|
292
|
+
|
|
293
|
+
The cache key is the SQL table name so that a ``seq`` and a ``map``
|
|
294
|
+
with the same logical name can coexist without collision.
|
|
295
|
+
|
|
296
|
+
:param name: Logical collection name.
|
|
297
|
+
:param cls: Concrete :class:`LitCollection` subclass to instantiate.
|
|
298
|
+
:return: Collection instance.
|
|
299
|
+
"""
|
|
300
|
+
prefix_map = {LitSeq: "_seq_", LitSet: "_set_", LitMap: "_map_"}
|
|
301
|
+
table_name = f"{prefix_map[cls]}{name}"
|
|
302
|
+
|
|
303
|
+
with self._lock:
|
|
304
|
+
if table_name not in self._collections:
|
|
305
|
+
self._collections[table_name] = cls(
|
|
306
|
+
name, self._engine, self._metadata
|
|
307
|
+
)
|
|
308
|
+
return self._collections[table_name]
|