librus-python-api 1.0.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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_bootstrap.py +124 -0
- librus_python_api/_notification_codec.py +396 -0
- librus_python_api/_storage.py +403 -0
- librus_python_api/_windows_filesystem.py +390 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +242 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +145 -0
- librus_python_api/notification_persistence.py +1243 -0
- librus_python_api/notification_workflow.py +337 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +419 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0.dist-info/METADATA +262 -0
- librus_python_api-1.0.0.dist-info/RECORD +48 -0
- librus_python_api-1.0.0.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Private bounded SQLite ownership shared by explicit optional stores."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import sqlite3
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable, Coroutine, Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
16
|
+
|
|
17
|
+
from pydantic import Field
|
|
18
|
+
|
|
19
|
+
from librus_python_api.config import _ValidatedConfig
|
|
20
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
21
|
+
from librus_python_api.lifecycle import join_owned
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from librus_python_api._windows_filesystem import WindowsDirectory
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _StorageLimits(_ValidatedConfig):
|
|
28
|
+
operations: int = Field(default=8, ge=1, le=64)
|
|
29
|
+
busy_timeout_seconds: float = Field(default=0.1, gt=0, le=5, allow_inf_nan=False)
|
|
30
|
+
# Final saves follow upstream side effects (a dispatched send or a consumed
|
|
31
|
+
# read-once page), so they wait longer for other contexts' write locks.
|
|
32
|
+
final_busy_timeout_seconds: float = Field(
|
|
33
|
+
default=5, gt=0, le=60, allow_inf_nan=False
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Worker threads inherit the calling task's context through asyncio.to_thread.
|
|
38
|
+
_busy_timeout_seconds: contextvars.ContextVar[float | None] = contextvars.ContextVar(
|
|
39
|
+
"_busy_timeout_seconds", default=None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_STORE_META_SCHEMA = """CREATE TABLE store_metadata (
|
|
43
|
+
name TEXT PRIMARY KEY CHECK(name='context_salt'),
|
|
44
|
+
value BLOB NOT NULL CHECK(length(value)=32)
|
|
45
|
+
)"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _SQLiteStore:
|
|
49
|
+
_filename = "state.sqlite3"
|
|
50
|
+
_database_bytes = 8 * 1024 * 1024
|
|
51
|
+
_schema: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
def __init__(self, directory: Path, *, limits: _StorageLimits) -> None:
|
|
54
|
+
if not isinstance(directory, Path) or not directory.is_absolute():
|
|
55
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
56
|
+
self._directory = directory
|
|
57
|
+
self._path = directory / self._filename
|
|
58
|
+
self._storage_limits = limits
|
|
59
|
+
self._opened = self._opening = self._closing = self._closed = False
|
|
60
|
+
self._operations = 0
|
|
61
|
+
self._lock = asyncio.Lock()
|
|
62
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
63
|
+
self._workflows: set[asyncio.Task[Any]] = set()
|
|
64
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
65
|
+
self._file_identity: tuple[int, int] | None = None
|
|
66
|
+
self._context_salt: bytes | None = None
|
|
67
|
+
self._windows_directory: WindowsDirectory | None = None
|
|
68
|
+
|
|
69
|
+
def context_identifier(self, context: str) -> str:
|
|
70
|
+
"""Store-local context pseudonym; requires an explicitly opened store."""
|
|
71
|
+
if not self._opened or self._closed:
|
|
72
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
73
|
+
return self._context_key(context)
|
|
74
|
+
|
|
75
|
+
def _context_key(self, context: str) -> str:
|
|
76
|
+
if type(context) is not str or len(context) != 64:
|
|
77
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
78
|
+
try:
|
|
79
|
+
decoded = bytes.fromhex(context)
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise LibrusError(ErrorKind.INVALID_INPUT) from None
|
|
82
|
+
if len(decoded) != 32 or context != decoded.hex():
|
|
83
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
84
|
+
assert self._context_salt is not None
|
|
85
|
+
return hmac.new(self._context_salt, decoded, hashlib.sha256).hexdigest()
|
|
86
|
+
|
|
87
|
+
async def __aenter__(self) -> Self:
|
|
88
|
+
await self.open()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
async def __aexit__(self, *args: object) -> None:
|
|
92
|
+
await self.aclose()
|
|
93
|
+
|
|
94
|
+
async def open(self) -> None:
|
|
95
|
+
if os.name != "posix" and sys.platform != "win32":
|
|
96
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
97
|
+
if self._opened or self._opening or self._closing or self._closed:
|
|
98
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
99
|
+
self._opening = True
|
|
100
|
+
try:
|
|
101
|
+
await self._io(self._initialise, opening=True)
|
|
102
|
+
self._opened = True
|
|
103
|
+
finally:
|
|
104
|
+
self._opening = False
|
|
105
|
+
if not self._opened and self._windows_directory is not None:
|
|
106
|
+
self._windows_directory.close()
|
|
107
|
+
self._windows_directory = None
|
|
108
|
+
|
|
109
|
+
async def aclose(self) -> None:
|
|
110
|
+
if (
|
|
111
|
+
asyncio.current_task() in self._tasks
|
|
112
|
+
or asyncio.current_task() in self._workflows
|
|
113
|
+
):
|
|
114
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
115
|
+
if self._close_task is None:
|
|
116
|
+
self._closing = True
|
|
117
|
+
self._close_task = asyncio.create_task(self._close())
|
|
118
|
+
interrupted = await join_owned(self._close_task)
|
|
119
|
+
self._close_task.result()
|
|
120
|
+
if interrupted:
|
|
121
|
+
raise asyncio.CancelledError
|
|
122
|
+
|
|
123
|
+
async def _close(self) -> None:
|
|
124
|
+
# Join workflow-owned final saves before cancelling ordinary workers.
|
|
125
|
+
for task in tuple(self._workflows):
|
|
126
|
+
task.cancel()
|
|
127
|
+
for task in tuple(self._workflows):
|
|
128
|
+
await join_owned(task)
|
|
129
|
+
tasks = tuple(self._tasks)
|
|
130
|
+
for task in tasks:
|
|
131
|
+
task.cancel()
|
|
132
|
+
for task in tasks:
|
|
133
|
+
await join_owned(task)
|
|
134
|
+
if self._windows_directory is not None:
|
|
135
|
+
try:
|
|
136
|
+
self._windows_directory.close()
|
|
137
|
+
except OSError:
|
|
138
|
+
raise LibrusError(ErrorKind.STORAGE) from None
|
|
139
|
+
finally:
|
|
140
|
+
self._windows_directory = None
|
|
141
|
+
self._closed = True
|
|
142
|
+
self._opened = False
|
|
143
|
+
|
|
144
|
+
async def _owned[T](self, operation: Callable[[], Coroutine[Any, Any, T]]) -> T:
|
|
145
|
+
if not self._opened or self._closing or self._closed:
|
|
146
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
147
|
+
if len(self._workflows) >= self._storage_limits.operations:
|
|
148
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
149
|
+
task = asyncio.create_task(operation())
|
|
150
|
+
self._workflows.add(task)
|
|
151
|
+
cancelled = False
|
|
152
|
+
try:
|
|
153
|
+
await asyncio.wait((task,))
|
|
154
|
+
return task.result()
|
|
155
|
+
except asyncio.CancelledError:
|
|
156
|
+
cancelled = True
|
|
157
|
+
task.cancel()
|
|
158
|
+
raise
|
|
159
|
+
finally:
|
|
160
|
+
interrupted = await join_owned(task)
|
|
161
|
+
self._workflows.discard(task)
|
|
162
|
+
if cancelled or interrupted:
|
|
163
|
+
raise asyncio.CancelledError
|
|
164
|
+
|
|
165
|
+
async def _io[T](
|
|
166
|
+
self,
|
|
167
|
+
operation: Callable[[], T],
|
|
168
|
+
*,
|
|
169
|
+
opening: bool = False,
|
|
170
|
+
finishing: bool = False,
|
|
171
|
+
) -> T:
|
|
172
|
+
if (
|
|
173
|
+
self._closed
|
|
174
|
+
or (not self._opened and not opening)
|
|
175
|
+
or (self._closing and not finishing)
|
|
176
|
+
):
|
|
177
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
178
|
+
if self._operations >= self._storage_limits.operations:
|
|
179
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
180
|
+
self._operations += 1
|
|
181
|
+
task = asyncio.create_task(
|
|
182
|
+
self._worker(
|
|
183
|
+
operation,
|
|
184
|
+
self._storage_limits.final_busy_timeout_seconds
|
|
185
|
+
if finishing
|
|
186
|
+
else self._storage_limits.busy_timeout_seconds,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
self._tasks.add(task)
|
|
190
|
+
cancelled = False
|
|
191
|
+
try:
|
|
192
|
+
await asyncio.wait((task,))
|
|
193
|
+
return task.result()
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
cancelled = True
|
|
196
|
+
# Final saves remain owned even while waiting for another worker.
|
|
197
|
+
if not finishing:
|
|
198
|
+
task.cancel()
|
|
199
|
+
raise
|
|
200
|
+
finally:
|
|
201
|
+
interrupted = await join_owned(task)
|
|
202
|
+
self._tasks.discard(task)
|
|
203
|
+
self._operations -= 1
|
|
204
|
+
if cancelled or interrupted:
|
|
205
|
+
raise asyncio.CancelledError
|
|
206
|
+
|
|
207
|
+
async def _worker[T](self, operation: Callable[[], T], busy_seconds: float) -> T:
|
|
208
|
+
async with self._lock:
|
|
209
|
+
_busy_timeout_seconds.set(busy_seconds)
|
|
210
|
+
task = asyncio.create_task(asyncio.to_thread(operation))
|
|
211
|
+
interrupted = await join_owned(task)
|
|
212
|
+
if interrupted:
|
|
213
|
+
raise asyncio.CancelledError
|
|
214
|
+
try:
|
|
215
|
+
return task.result()
|
|
216
|
+
except LibrusError:
|
|
217
|
+
raise
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
221
|
+
|
|
222
|
+
def _check_directory(self) -> None:
|
|
223
|
+
# A private immediate parent is the trust boundary. Malicious same-user
|
|
224
|
+
# filesystem writers and unsuitable network filesystems are not isolated.
|
|
225
|
+
if sys.platform == "win32":
|
|
226
|
+
from librus_python_api._windows_filesystem import WindowsDirectory
|
|
227
|
+
|
|
228
|
+
if self._windows_directory is None:
|
|
229
|
+
self._windows_directory = WindowsDirectory(self._directory, create=True)
|
|
230
|
+
else:
|
|
231
|
+
self._windows_directory.validate()
|
|
232
|
+
return
|
|
233
|
+
if self._directory.is_symlink():
|
|
234
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
235
|
+
self._directory.mkdir(mode=0o700, exist_ok=True)
|
|
236
|
+
status = self._directory.stat()
|
|
237
|
+
if not stat.S_ISDIR(status.st_mode):
|
|
238
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
239
|
+
if os.name == "posix" and (
|
|
240
|
+
status.st_uid != os.geteuid() or stat.S_IMODE(status.st_mode) & 0o077
|
|
241
|
+
):
|
|
242
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
243
|
+
|
|
244
|
+
def _check_file(self, *, create: bool = False) -> None:
|
|
245
|
+
if self._windows_directory is not None:
|
|
246
|
+
identity = self._windows_directory.check_file(
|
|
247
|
+
self._filename, self._database_bytes, create=create
|
|
248
|
+
)
|
|
249
|
+
if self._file_identity is not None and identity != self._file_identity:
|
|
250
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
251
|
+
self._file_identity = identity
|
|
252
|
+
return
|
|
253
|
+
if self._path.is_symlink():
|
|
254
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
255
|
+
flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
256
|
+
if create:
|
|
257
|
+
flags |= os.O_CREAT
|
|
258
|
+
descriptor = os.open(self._path, flags, 0o600)
|
|
259
|
+
try:
|
|
260
|
+
status = os.fstat(descriptor)
|
|
261
|
+
if (
|
|
262
|
+
not stat.S_ISREG(status.st_mode)
|
|
263
|
+
or status.st_nlink != 1
|
|
264
|
+
or status.st_size > self._database_bytes
|
|
265
|
+
):
|
|
266
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
267
|
+
if os.name == "posix" and (
|
|
268
|
+
status.st_uid != os.geteuid() or stat.S_IMODE(status.st_mode) & 0o077
|
|
269
|
+
):
|
|
270
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
271
|
+
identity = (status.st_dev, status.st_ino)
|
|
272
|
+
if self._file_identity is not None and self._file_identity != identity:
|
|
273
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
274
|
+
self._file_identity = identity
|
|
275
|
+
finally:
|
|
276
|
+
os.close(descriptor)
|
|
277
|
+
|
|
278
|
+
def _check_sidecars(self) -> None:
|
|
279
|
+
for suffix in ("-journal", "-wal", "-shm"):
|
|
280
|
+
if self._windows_directory is not None:
|
|
281
|
+
self._windows_directory.check_sidecar(
|
|
282
|
+
self._filename + suffix, self._database_bytes + 1024 * 1024
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
path = self._path.with_name(self._path.name + suffix)
|
|
286
|
+
try:
|
|
287
|
+
status = path.lstat()
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
continue
|
|
290
|
+
if (
|
|
291
|
+
not stat.S_ISREG(status.st_mode)
|
|
292
|
+
or status.st_nlink != 1
|
|
293
|
+
or status.st_size > self._database_bytes + 1024 * 1024
|
|
294
|
+
or (
|
|
295
|
+
os.name == "posix"
|
|
296
|
+
and (
|
|
297
|
+
status.st_uid != os.geteuid()
|
|
298
|
+
or stat.S_IMODE(status.st_mode) & 0o077
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
):
|
|
302
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
303
|
+
|
|
304
|
+
@contextmanager
|
|
305
|
+
def _connection(self, *, initialise: bool = False) -> Iterator[sqlite3.Connection]:
|
|
306
|
+
connection = None
|
|
307
|
+
kind: ErrorKind | None = None
|
|
308
|
+
try:
|
|
309
|
+
self._check_directory()
|
|
310
|
+
self._check_file(create=initialise)
|
|
311
|
+
self._check_sidecars()
|
|
312
|
+
busy_seconds = _busy_timeout_seconds.get()
|
|
313
|
+
connection = sqlite3.connect(
|
|
314
|
+
self._path,
|
|
315
|
+
timeout=self._storage_limits.busy_timeout_seconds
|
|
316
|
+
if busy_seconds is None
|
|
317
|
+
else busy_seconds,
|
|
318
|
+
)
|
|
319
|
+
connection.execute("PRAGMA trusted_schema=OFF")
|
|
320
|
+
connection.execute("PRAGMA foreign_keys=ON")
|
|
321
|
+
connection.execute("PRAGMA synchronous=FULL")
|
|
322
|
+
connection.execute(f"PRAGMA max_page_count={self._database_bytes // 4096}")
|
|
323
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
324
|
+
if not initialise:
|
|
325
|
+
self._validate_schema(connection)
|
|
326
|
+
yield connection
|
|
327
|
+
connection.commit()
|
|
328
|
+
except LibrusError:
|
|
329
|
+
raise
|
|
330
|
+
except sqlite3.Error as error:
|
|
331
|
+
kind = (
|
|
332
|
+
ErrorKind.LIMIT
|
|
333
|
+
if getattr(error, "sqlite_errorcode", None)
|
|
334
|
+
in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED)
|
|
335
|
+
else ErrorKind.STORAGE
|
|
336
|
+
)
|
|
337
|
+
except OSError:
|
|
338
|
+
kind = ErrorKind.STORAGE
|
|
339
|
+
finally:
|
|
340
|
+
if connection is not None:
|
|
341
|
+
connection.close()
|
|
342
|
+
if kind is not None:
|
|
343
|
+
raise LibrusError(kind)
|
|
344
|
+
|
|
345
|
+
def _initialise(self) -> None:
|
|
346
|
+
with self._connection(initialise=True) as connection:
|
|
347
|
+
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
348
|
+
if version == 0:
|
|
349
|
+
if connection.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone():
|
|
350
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
351
|
+
for sql in (_STORE_META_SCHEMA, *self._schema):
|
|
352
|
+
connection.execute(sql)
|
|
353
|
+
connection.execute(
|
|
354
|
+
"INSERT INTO store_metadata VALUES (?,?)",
|
|
355
|
+
("context_salt", secrets.token_bytes(32)),
|
|
356
|
+
)
|
|
357
|
+
connection.execute("PRAGMA user_version=3")
|
|
358
|
+
self._validate_schema(connection)
|
|
359
|
+
self._validate_contents(connection)
|
|
360
|
+
if os.name == "posix":
|
|
361
|
+
descriptor = os.open(
|
|
362
|
+
self._directory,
|
|
363
|
+
os.O_RDONLY
|
|
364
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
365
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
366
|
+
)
|
|
367
|
+
try:
|
|
368
|
+
os.fsync(descriptor)
|
|
369
|
+
finally:
|
|
370
|
+
os.close(descriptor)
|
|
371
|
+
|
|
372
|
+
def _validate_schema(self, connection: sqlite3.Connection) -> None:
|
|
373
|
+
expected = []
|
|
374
|
+
for sql in (_STORE_META_SCHEMA, *self._schema):
|
|
375
|
+
name = sql.split()[2]
|
|
376
|
+
expected.extend(
|
|
377
|
+
[("table", name, sql), ("index", f"sqlite_autoindex_{name}_1", None)]
|
|
378
|
+
)
|
|
379
|
+
actual = connection.execute(
|
|
380
|
+
"SELECT type, name, sql FROM sqlite_master ORDER BY name LIMIT 32"
|
|
381
|
+
).fetchall()
|
|
382
|
+
if (
|
|
383
|
+
connection.execute("PRAGMA user_version").fetchone()[0] != 3
|
|
384
|
+
or connection.execute("PRAGMA page_size").fetchone()[0] != 4096
|
|
385
|
+
or connection.execute("PRAGMA journal_mode").fetchone()[0] != "delete"
|
|
386
|
+
or actual != sorted(expected, key=lambda row: row[1])
|
|
387
|
+
):
|
|
388
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
389
|
+
rows = connection.execute(
|
|
390
|
+
"SELECT name,value FROM store_metadata LIMIT 2"
|
|
391
|
+
).fetchall()
|
|
392
|
+
if (
|
|
393
|
+
len(rows) != 1
|
|
394
|
+
or rows[0][0] != "context_salt"
|
|
395
|
+
or type(rows[0][1]) is not bytes
|
|
396
|
+
or len(rows[0][1]) != 32
|
|
397
|
+
or (self._context_salt is not None and self._context_salt != rows[0][1])
|
|
398
|
+
):
|
|
399
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
400
|
+
self._context_salt = rows[0][1]
|
|
401
|
+
|
|
402
|
+
def _validate_contents(self, connection: sqlite3.Connection) -> None:
|
|
403
|
+
raise NotImplementedError
|