litsdb 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"
litsdb/_collection.py ADDED
@@ -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
+ """
litsdb/_database.py ADDED
@@ -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]