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.
Files changed (48) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_bootstrap.py +124 -0
  3. librus_python_api/_notification_codec.py +396 -0
  4. librus_python_api/_storage.py +403 -0
  5. librus_python_api/_windows_filesystem.py +390 -0
  6. librus_python_api/announcements.py +159 -0
  7. librus_python_api/attachment_routes.py +114 -0
  8. librus_python_api/attachments.py +297 -0
  9. librus_python_api/attendance.py +182 -0
  10. librus_python_api/attendance_frequency.py +112 -0
  11. librus_python_api/budget.py +79 -0
  12. librus_python_api/checkpoint.py +61 -0
  13. librus_python_api/completed_lessons.py +216 -0
  14. librus_python_api/config.py +1410 -0
  15. librus_python_api/detail_fields.py +50 -0
  16. librus_python_api/diagnostics.py +25 -0
  17. librus_python_api/exceptions.py +172 -0
  18. librus_python_api/files.py +242 -0
  19. librus_python_api/grade_parsers.py +169 -0
  20. librus_python_api/grade_records.py +454 -0
  21. librus_python_api/homework_range.py +41 -0
  22. librus_python_api/lifecycle.py +24 -0
  23. librus_python_api/markup.py +147 -0
  24. librus_python_api/message_content.py +230 -0
  25. librus_python_api/messages.py +288 -0
  26. librus_python_api/models.py +1160 -0
  27. librus_python_api/modern_body.py +75 -0
  28. librus_python_api/modern_mailbox.py +459 -0
  29. librus_python_api/modern_messages.py +276 -0
  30. librus_python_api/notification_models.py +145 -0
  31. librus_python_api/notification_persistence.py +1243 -0
  32. librus_python_api/notification_workflow.py +337 -0
  33. librus_python_api/notifications.py +216 -0
  34. librus_python_api/parsers.py +232 -0
  35. librus_python_api/parsing.py +49 -0
  36. librus_python_api/persistence.py +419 -0
  37. librus_python_api/py.typed +0 -0
  38. librus_python_api/recipients.py +271 -0
  39. librus_python_api/scheduler.py +287 -0
  40. librus_python_api/school_reads.py +400 -0
  41. librus_python_api/sending.py +125 -0
  42. librus_python_api/service.py +2285 -0
  43. librus_python_api/timetable.py +261 -0
  44. librus_python_api/transport.py +956 -0
  45. librus_python_api-1.0.0.dist-info/METADATA +262 -0
  46. librus_python_api-1.0.0.dist-info/RECORD +48 -0
  47. librus_python_api-1.0.0.dist-info/WHEEL +4 -0
  48. 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