librus-python-api 1.0.0rc1__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 (46) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_codec.py +310 -0
  3. librus_python_api/_storage.py +367 -0
  4. librus_python_api/announcements.py +159 -0
  5. librus_python_api/attachment_routes.py +114 -0
  6. librus_python_api/attachments.py +297 -0
  7. librus_python_api/attendance.py +182 -0
  8. librus_python_api/attendance_frequency.py +112 -0
  9. librus_python_api/budget.py +79 -0
  10. librus_python_api/checkpoint.py +61 -0
  11. librus_python_api/completed_lessons.py +216 -0
  12. librus_python_api/config.py +1410 -0
  13. librus_python_api/detail_fields.py +50 -0
  14. librus_python_api/diagnostics.py +25 -0
  15. librus_python_api/exceptions.py +172 -0
  16. librus_python_api/files.py +156 -0
  17. librus_python_api/grade_parsers.py +169 -0
  18. librus_python_api/grade_records.py +454 -0
  19. librus_python_api/homework_range.py +41 -0
  20. librus_python_api/lifecycle.py +24 -0
  21. librus_python_api/markup.py +147 -0
  22. librus_python_api/message_content.py +230 -0
  23. librus_python_api/messages.py +288 -0
  24. librus_python_api/models.py +1160 -0
  25. librus_python_api/modern_body.py +75 -0
  26. librus_python_api/modern_mailbox.py +459 -0
  27. librus_python_api/modern_messages.py +276 -0
  28. librus_python_api/notification_models.py +67 -0
  29. librus_python_api/notification_persistence.py +1044 -0
  30. librus_python_api/notification_workflow.py +303 -0
  31. librus_python_api/notifications.py +216 -0
  32. librus_python_api/parsers.py +232 -0
  33. librus_python_api/parsing.py +49 -0
  34. librus_python_api/persistence.py +405 -0
  35. librus_python_api/py.typed +0 -0
  36. librus_python_api/recipients.py +271 -0
  37. librus_python_api/scheduler.py +287 -0
  38. librus_python_api/school_reads.py +400 -0
  39. librus_python_api/sending.py +125 -0
  40. librus_python_api/service.py +2285 -0
  41. librus_python_api/timetable.py +261 -0
  42. librus_python_api/transport.py +956 -0
  43. librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
  44. librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
  45. librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
  46. librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1044 @@
1
+ """Explicit optional notification state, raw checkpoints and two-phase delivery."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import os
7
+ import sqlite3
8
+ import stat
9
+ from collections.abc import AsyncIterator, Callable, Coroutine
10
+ from contextlib import asynccontextmanager
11
+ from dataclasses import asdict, replace
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import Field
16
+
17
+ from librus_python_api._notification_codec import (
18
+ ARCHIVE_BYTES,
19
+ HEX,
20
+ META_BYTES,
21
+ WIRE_BYTES,
22
+ canonical_notification_id,
23
+ dump,
24
+ empty_state,
25
+ encode_batch,
26
+ encode_envelope,
27
+ load,
28
+ restore_batch,
29
+ restore_envelope,
30
+ restore_state,
31
+ state_bytes,
32
+ )
33
+ from librus_python_api._storage import _SQLiteStore, _StorageLimits
34
+ from librus_python_api.exceptions import ErrorKind, LibrusError
35
+ from librus_python_api.models import (
36
+ AccountContext,
37
+ NotificationCategory,
38
+ ScheduleEventResponse,
39
+ )
40
+ from librus_python_api.notification_models import (
41
+ NotificationArchive,
42
+ NotificationBatch,
43
+ NotificationItem,
44
+ NotificationSeen,
45
+ NotificationState,
46
+ )
47
+ from librus_python_api.notifications import decode_payload, parse_schedule_events
48
+
49
+ try:
50
+ import fcntl
51
+ except ImportError:
52
+ fcntl = None # type: ignore[assignment]
53
+
54
+ _CONTEXT_SCHEMA = """CREATE TABLE notification_contexts (
55
+ context TEXT PRIMARY KEY CHECK(length(context)=64)
56
+ )"""
57
+ _STATE_SCHEMA = """CREATE TABLE notification_state (
58
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
59
+ payload BLOB NOT NULL,
60
+ last_receipt TEXT CHECK(last_receipt IS NULL OR length(last_receipt)=64)
61
+ )"""
62
+ _RAW_SCHEMA = """CREATE TABLE notification_raw (
63
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
64
+ identifier TEXT NOT NULL CHECK(length(identifier)=64),
65
+ metadata BLOB NOT NULL,
66
+ body BLOB NOT NULL,
67
+ cursor INTEGER NOT NULL CHECK(cursor>=0 AND cursor<=1024),
68
+ total INTEGER CHECK(total IS NULL OR (total>=cursor AND total<=1024))
69
+ )"""
70
+ _RESERVATION_SCHEMA = """CREATE TABLE notification_reservations (
71
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
72
+ bytes INTEGER NOT NULL CHECK(bytes>0 AND bytes<=8388608)
73
+ )"""
74
+ _DELIVERY_SCHEMA = """CREATE TABLE notification_deliveries (
75
+ context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
76
+ receipt TEXT NOT NULL CHECK(length(receipt)=64),
77
+ payload BLOB NOT NULL,
78
+ state_after BLOB NOT NULL,
79
+ raw_identifier TEXT CHECK(raw_identifier IS NULL OR length(raw_identifier)=64),
80
+ cursor_after INTEGER CHECK(cursor_after IS NULL OR (
81
+ cursor_after>=0 AND cursor_after<=1024))
82
+ )"""
83
+
84
+
85
+ class NotificationLimits(_StorageLimits):
86
+ contexts: int = Field(default=16, ge=1, le=64)
87
+ checkpoint_records: int = Field(default=32, ge=1, le=256)
88
+ checkpoint_bytes: int = Field(default=16 * 1024 * 1024, ge=1, le=32 * 1024 * 1024)
89
+ seen_ids_per_category: int = Field(default=4096, ge=1, le=4096)
90
+ state_bytes: int = Field(default=4 * 1024 * 1024, ge=1, le=4 * 1024 * 1024)
91
+ batch_items: int = Field(default=500, ge=1, le=4096)
92
+ batch_bytes: int = Field(default=1024 * 1024, ge=1, le=4 * 1024 * 1024)
93
+ replay_events: int = Field(default=500, ge=1, le=1024)
94
+ replay_bytes: int = Field(default=128 * 1024, ge=1, le=1024 * 1024)
95
+
96
+
97
+ def _context_id(context: AccountContext) -> str:
98
+ if (
99
+ not isinstance(context, AccountContext)
100
+ or type(context.identifier) is not str
101
+ or HEX.fullmatch(context.identifier) is None
102
+ or type(context.alias) is not str
103
+ or not 1 <= len(context.alias) <= 80
104
+ ):
105
+ raise LibrusError(ErrorKind.INVALID_INPUT)
106
+ return context.identifier
107
+
108
+
109
+ def _raw_id(context: str, metadata: bytes, body: bytes) -> str:
110
+ digest = hashlib.sha256(context.encode("ascii"))
111
+ digest.update(len(metadata).to_bytes(8, "big"))
112
+ digest.update(metadata)
113
+ digest.update(body)
114
+ return digest.hexdigest()
115
+
116
+
117
+ def _seen_after(
118
+ state: NotificationState, items: tuple[NotificationItem, ...], limit: int
119
+ ) -> NotificationState:
120
+ seen = {entry.category: list(entry.identifiers) for entry in state.seen}
121
+ for item in items:
122
+ identifiers = seen[item.category]
123
+ if item.identifier not in identifiers:
124
+ if len(identifiers) >= limit:
125
+ raise LibrusError(ErrorKind.LIMIT)
126
+ identifiers.append(item.identifier)
127
+ return NotificationState(
128
+ True,
129
+ tuple(
130
+ NotificationSeen(entry.category, tuple(seen[entry.category]))
131
+ for entry in state.seen
132
+ ),
133
+ )
134
+
135
+
136
+ class NotificationStore(_SQLiteStore):
137
+ """Private native recovery store. No MCP files, auto migration or live access.
138
+
139
+ Context locks require POSIX flock. Unsupported platforms fail explicitly.
140
+ Raw checkpoints contain private account/content metadata, never HTTP cookies.
141
+ """
142
+
143
+ _filename = "notifications.sqlite3"
144
+ _database_bytes = 64 * 1024 * 1024
145
+ _schema = (
146
+ _CONTEXT_SCHEMA,
147
+ _STATE_SCHEMA,
148
+ _RAW_SCHEMA,
149
+ _RESERVATION_SCHEMA,
150
+ _DELIVERY_SCHEMA,
151
+ )
152
+
153
+ def __init__(
154
+ self, directory: Path, *, limits: NotificationLimits | None = None
155
+ ) -> None:
156
+ self._notification_limits = (
157
+ limits if limits is not None else NotificationLimits()
158
+ )
159
+ if not isinstance(self.limits, NotificationLimits):
160
+ raise LibrusError(ErrorKind.INVALID_INPUT)
161
+ super().__init__(directory, limits=self.limits)
162
+
163
+ @property
164
+ def limits(self) -> NotificationLimits:
165
+ return self._notification_limits
166
+
167
+ def _validate_contents(self, connection: sqlite3.Connection) -> None:
168
+ contexts = connection.execute(
169
+ "SELECT context FROM notification_contexts LIMIT ?",
170
+ (self.limits.contexts + 1,),
171
+ ).fetchall()
172
+ if len(contexts) > self.limits.contexts:
173
+ raise LibrusError(ErrorKind.LIMIT)
174
+ if any(
175
+ type(row[0]) is not str or HEX.fullmatch(row[0]) is None for row in contexts
176
+ ):
177
+ raise LibrusError(ErrorKind.PARSE)
178
+ if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
179
+ raise LibrusError(ErrorKind.PARSE)
180
+ records, used = connection.execute(
181
+ "SELECT count(*), coalesce(sum(length(metadata)+length(body)),0) "
182
+ "FROM notification_raw"
183
+ ).fetchone()
184
+ reservations, reserved = connection.execute(
185
+ "SELECT count(*), coalesce(sum(bytes),0) FROM notification_reservations"
186
+ ).fetchone()
187
+ if (
188
+ records + reservations > self.limits.checkpoint_records
189
+ or used + reserved > self.limits.checkpoint_bytes
190
+ ):
191
+ raise LibrusError(ErrorKind.LIMIT)
192
+ state_size = connection.execute(
193
+ "SELECT coalesce(sum(length(payload)),0) FROM notification_state"
194
+ ).fetchone()[0]
195
+ delivery_size = connection.execute(
196
+ "SELECT coalesce(sum(length(payload)+length(state_after)),0) "
197
+ "FROM notification_deliveries"
198
+ ).fetchone()[0]
199
+ if state_size > self.limits.state_bytes or delivery_size > 16 * 1024 * 1024:
200
+ raise LibrusError(ErrorKind.LIMIT)
201
+
202
+ def _register(self, context: str) -> None:
203
+ context = self._context_key(context)
204
+ with self._connection() as connection:
205
+ self._validate_contents(connection)
206
+ if (
207
+ connection.execute(
208
+ "SELECT 1 FROM notification_contexts WHERE context=?", (context,)
209
+ ).fetchone()
210
+ is None
211
+ ):
212
+ count = connection.execute(
213
+ "SELECT count(*) FROM notification_contexts"
214
+ ).fetchone()[0]
215
+ if count >= self.limits.contexts:
216
+ raise LibrusError(ErrorKind.LIMIT)
217
+ connection.execute(
218
+ "INSERT INTO notification_contexts VALUES (?)", (context,)
219
+ )
220
+
221
+ def _lock_context(self, context: str, held: list[int]) -> None:
222
+ context = self._context_key(context)
223
+ if fcntl is None:
224
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
225
+ path = self._directory / f"notification-{context}.lock"
226
+ if path.is_symlink():
227
+ raise LibrusError(ErrorKind.STORAGE)
228
+ descriptor = os.open(
229
+ path, os.O_CREAT | os.O_RDWR | os.O_NONBLOCK | os.O_NOFOLLOW, 0o600
230
+ )
231
+ try:
232
+ status = os.fstat(descriptor)
233
+ if (
234
+ not stat.S_ISREG(status.st_mode)
235
+ or status.st_nlink != 1
236
+ or status.st_size != 0
237
+ or status.st_uid != os.geteuid()
238
+ or stat.S_IMODE(status.st_mode) & 0o077
239
+ ):
240
+ raise LibrusError(ErrorKind.STORAGE)
241
+ try:
242
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
243
+ except BlockingIOError:
244
+ raise LibrusError(ErrorKind.LIMIT) from None
245
+ held.append(descriptor)
246
+ descriptor = -1
247
+ finally:
248
+ if descriptor >= 0:
249
+ os.close(descriptor)
250
+
251
+ @asynccontextmanager
252
+ async def _context(self, context: AccountContext) -> AsyncIterator[None]:
253
+ identifier = _context_id(context)
254
+ held: list[int] = []
255
+ try:
256
+ await self._io(lambda: self._register(identifier))
257
+ await self._io(lambda: self._lock_context(identifier, held))
258
+ yield
259
+ finally:
260
+ # A resource-release syscall, not a new queued storage operation.
261
+ # The acquisition worker is joined before this point, even on cancel.
262
+ if held:
263
+ os.close(held.pop())
264
+
265
+ async def _transaction[T](
266
+ self, context: AccountContext, operation: Callable[[], Coroutine[Any, Any, T]]
267
+ ) -> T:
268
+ async def run() -> T:
269
+ async with self._context(context):
270
+ return await operation()
271
+
272
+ return await self._owned(run)
273
+
274
+ def _state(
275
+ self, connection: sqlite3.Connection, context: str
276
+ ) -> tuple[NotificationState, str | None]:
277
+ row = connection.execute(
278
+ "SELECT payload,last_receipt FROM notification_state WHERE context=?",
279
+ (self._context_key(context),),
280
+ ).fetchone()
281
+ if row is None:
282
+ return empty_state(), None
283
+ if type(row[0]) is not bytes or (
284
+ row[1] is not None
285
+ and (type(row[1]) is not str or HEX.fullmatch(row[1]) is None)
286
+ ):
287
+ raise LibrusError(ErrorKind.PARSE)
288
+ return restore_state(
289
+ row[0], self.limits.seen_ids_per_category, self.limits.state_bytes
290
+ ), row[1]
291
+
292
+ async def state(self, *, context: AccountContext) -> NotificationState:
293
+ async def read() -> NotificationState:
294
+ return await self._io(lambda: self._read_state(context.identifier))
295
+
296
+ return await self._transaction(context, read)
297
+
298
+ def _read_state(self, context: str) -> NotificationState:
299
+ with self._connection() as connection:
300
+ self._validate_contents(connection)
301
+ return self._state(connection, context)[0]
302
+
303
+ async def prune_seen(
304
+ self,
305
+ *,
306
+ context: AccountContext,
307
+ category: NotificationCategory,
308
+ identifiers: tuple[str, ...],
309
+ ) -> int:
310
+ """Explicit retention; forgotten IDs may be notified again.
311
+
312
+ Staged delivery and uncertain reservations must be resolved first. Raw
313
+ checkpoints/progress are retained unchanged, including at seen saturation.
314
+ No age-based expiry, background pruning or first-run reset is performed.
315
+ """
316
+ if (
317
+ not isinstance(category, NotificationCategory)
318
+ or type(identifiers) is not tuple
319
+ or not 1 <= len(identifiers) <= self.limits.seen_ids_per_category
320
+ or any(
321
+ type(value) is not str or HEX.fullmatch(value) is None
322
+ for value in identifiers
323
+ )
324
+ or len(set(identifiers)) != len(identifiers)
325
+ ):
326
+ raise LibrusError(ErrorKind.INVALID_INPUT)
327
+
328
+ async def prune() -> int:
329
+ return await self._io(
330
+ lambda: self._prune_seen(context, category, identifiers)
331
+ )
332
+
333
+ return await self._transaction(context, prune)
334
+
335
+ def _prune_seen(
336
+ self,
337
+ context: AccountContext,
338
+ category: NotificationCategory,
339
+ identifiers: tuple[str, ...],
340
+ ) -> int:
341
+ with self._connection() as connection:
342
+ self._validate_contents(connection)
343
+ key = self._context_key(context.identifier)
344
+ for table in (
345
+ "notification_deliveries",
346
+ "notification_reservations",
347
+ ):
348
+ if connection.execute(
349
+ f"SELECT 1 FROM {table} WHERE context=?", (key,)
350
+ ).fetchone():
351
+ raise LibrusError(ErrorKind.INVALID_INPUT)
352
+ state, last = self._state(connection, context.identifier)
353
+ removed = set(identifiers)
354
+ current = next(entry for entry in state.seen if entry.category is category)
355
+ if not removed.issubset(current.identifiers):
356
+ raise LibrusError(ErrorKind.INVALID_INPUT)
357
+ raw = self._raw(connection, context)
358
+ if category is NotificationCategory.AGENDA and raw is not None and raw[2]:
359
+ response = raw[1]
360
+ events = parse_schedule_events(
361
+ decode_payload(response.wire.body, response.wire, WIRE_BYTES)
362
+ )
363
+ protected = {
364
+ canonical_notification_id(NotificationCategory.AGENDA, value)
365
+ for value in events[: raw[2]]
366
+ }
367
+ if removed & protected:
368
+ # Prefix membership proves acknowledged cursor progress during
369
+ # archive import. Retain it until the raw receipt is drained.
370
+ raise LibrusError(ErrorKind.INVALID_INPUT)
371
+ after = replace(
372
+ state,
373
+ seen=tuple(
374
+ replace(
375
+ entry,
376
+ identifiers=tuple(
377
+ value for value in entry.identifiers if value not in removed
378
+ ),
379
+ )
380
+ if entry.category is category
381
+ else entry
382
+ for entry in state.seen
383
+ ),
384
+ )
385
+ connection.execute(
386
+ "UPDATE notification_state SET payload=? WHERE context=?",
387
+ (
388
+ state_bytes(
389
+ after,
390
+ self.limits.seen_ids_per_category,
391
+ self.limits.state_bytes,
392
+ ),
393
+ key,
394
+ ),
395
+ )
396
+ assert self._state(connection, context.identifier) == (after, last)
397
+ return len(identifiers)
398
+
399
+ def _raw(
400
+ self, connection: sqlite3.Connection, context: AccountContext
401
+ ) -> tuple[str, ScheduleEventResponse, int, int | None] | None:
402
+ row = connection.execute(
403
+ "SELECT identifier,metadata,body,cursor,total FROM notification_raw "
404
+ "WHERE context=?",
405
+ (self._context_key(context.identifier),),
406
+ ).fetchone()
407
+ if row is None:
408
+ return None
409
+ identifier, metadata, body, cursor, total = row
410
+ if (
411
+ type(metadata) is not bytes
412
+ or type(body) is not bytes
413
+ or type(cursor) is not int
414
+ or not 0 <= cursor <= 1024
415
+ or (
416
+ total is not None
417
+ and (type(total) is not int or not cursor <= total <= 1024)
418
+ )
419
+ or identifier
420
+ != _raw_id(self._context_key(context.identifier), metadata, body)
421
+ ):
422
+ raise LibrusError(ErrorKind.PARSE)
423
+ return (
424
+ identifier,
425
+ restore_envelope(metadata, body, context.alias),
426
+ cursor,
427
+ total,
428
+ )
429
+
430
+ def _pending(
431
+ self, context: AccountContext
432
+ ) -> tuple[
433
+ NotificationBatch | None,
434
+ tuple[str, ScheduleEventResponse, int, int | None] | None,
435
+ bool,
436
+ ]:
437
+ with self._connection() as connection:
438
+ self._validate_contents(connection)
439
+ delivery = self._delivery(connection, context)
440
+ raw = self._raw(connection, context)
441
+ reservation = (
442
+ connection.execute(
443
+ "SELECT 1 FROM notification_reservations WHERE context=?",
444
+ (self._context_key(context.identifier),),
445
+ ).fetchone()
446
+ is not None
447
+ )
448
+ if reservation and raw is not None:
449
+ raise LibrusError(ErrorKind.PARSE)
450
+ return delivery[0] if delivery else None, raw, reservation
451
+
452
+ def _reserve(self, context: str, body_bytes: int) -> None:
453
+ context = self._context_key(context)
454
+ if type(body_bytes) is not int or not 1 <= body_bytes <= WIRE_BYTES:
455
+ raise LibrusError(ErrorKind.LIMIT)
456
+ with self._connection() as connection:
457
+ self._validate_contents(connection)
458
+ if (
459
+ connection.execute(
460
+ "SELECT 1 FROM notification_raw WHERE context=?", (context,)
461
+ ).fetchone()
462
+ or connection.execute(
463
+ "SELECT 1 FROM notification_reservations WHERE context=?",
464
+ (context,),
465
+ ).fetchone()
466
+ ):
467
+ raise LibrusError(ErrorKind.CHECKPOINT)
468
+ connection.execute(
469
+ "INSERT INTO notification_reservations VALUES (?,?)",
470
+ (context, body_bytes + META_BYTES),
471
+ )
472
+ self._validate_contents(connection)
473
+
474
+ def _checkpoint(
475
+ self, context: AccountContext, response: ScheduleEventResponse
476
+ ) -> None:
477
+ metadata, body = encode_envelope(response, context.alias)
478
+ key = self._context_key(context.identifier)
479
+ with self._connection() as connection:
480
+ self._validate_contents(connection)
481
+ row = connection.execute(
482
+ "SELECT bytes FROM notification_reservations WHERE context=?",
483
+ (key,),
484
+ ).fetchone()
485
+ if row is None or len(metadata) + len(body) > row[0]:
486
+ raise LibrusError(ErrorKind.CHECKPOINT)
487
+ identifier = _raw_id(key, metadata, body)
488
+ connection.execute(
489
+ "INSERT INTO notification_raw VALUES (?,?,?,?,0,NULL)",
490
+ (key, identifier, metadata, body),
491
+ )
492
+ connection.execute(
493
+ "DELETE FROM notification_reservations WHERE context=?",
494
+ (key,),
495
+ )
496
+ self._validate_contents(connection)
497
+
498
+ def _stage(
499
+ self,
500
+ batch: NotificationBatch,
501
+ raw_identifier: str | None,
502
+ cursor_after: int | None,
503
+ total: int | None,
504
+ ) -> None:
505
+ stored_context = replace(
506
+ batch.context, identifier=self._context_key(batch.context.identifier)
507
+ )
508
+ payload = encode_batch(
509
+ replace(batch, context=stored_context), self.limits.batch_bytes
510
+ )
511
+ if len(batch.items) > self.limits.batch_items:
512
+ raise LibrusError(ErrorKind.LIMIT)
513
+ with self._connection() as connection:
514
+ self._validate_contents(connection)
515
+ state, _ = self._state(connection, batch.context.identifier)
516
+ after = state_bytes(
517
+ _seen_after(state, batch.items, self.limits.seen_ids_per_category),
518
+ self.limits.seen_ids_per_category,
519
+ self.limits.state_bytes,
520
+ )
521
+ if raw_identifier is not None:
522
+ raw = self._raw(connection, batch.context)
523
+ if (
524
+ raw is None
525
+ or raw[0] != raw_identifier
526
+ or cursor_after is None
527
+ or total is None
528
+ or not raw[2] <= cursor_after <= total <= 1024
529
+ ):
530
+ raise LibrusError(ErrorKind.PARSE)
531
+ connection.execute(
532
+ "UPDATE notification_raw SET total=? WHERE context=?",
533
+ (total, stored_context.identifier),
534
+ )
535
+ connection.execute(
536
+ "INSERT INTO notification_deliveries VALUES (?,?,?,?,?,?)",
537
+ (
538
+ stored_context.identifier,
539
+ batch.receipt,
540
+ payload,
541
+ after,
542
+ raw_identifier,
543
+ cursor_after,
544
+ ),
545
+ )
546
+ self._validate_contents(connection)
547
+ self._delivery(connection, batch.context)
548
+
549
+ def _delivery(
550
+ self, connection: sqlite3.Connection, context: AccountContext
551
+ ) -> tuple[NotificationBatch, NotificationState, str | None, int | None] | None:
552
+ row = connection.execute(
553
+ "SELECT receipt,payload,state_after,raw_identifier,cursor_after "
554
+ "FROM notification_deliveries WHERE context=?",
555
+ (self._context_key(context.identifier),),
556
+ ).fetchone()
557
+ if row is None:
558
+ return None
559
+ if type(row[1]) is not bytes or type(row[2]) is not bytes:
560
+ raise LibrusError(ErrorKind.PARSE)
561
+ batch = restore_batch(row[1], self.limits.batch_bytes)
562
+ if batch.context != replace(
563
+ context, identifier=self._context_key(context.identifier)
564
+ ):
565
+ raise LibrusError(ErrorKind.PARSE)
566
+ batch = replace(batch, context=context)
567
+ after = restore_state(
568
+ row[2], self.limits.seen_ids_per_category, self.limits.state_bytes
569
+ )
570
+ before, _ = self._state(connection, context.identifier)
571
+ seen = {entry.category: set(entry.identifiers) for entry in before.seen}
572
+ keys = [(item.category, item.identifier) for item in batch.items]
573
+ if (
574
+ batch.context != context
575
+ or batch.receipt != row[0]
576
+ or batch.first_run == before.initialized
577
+ or len(batch.items) > self.limits.batch_items
578
+ or len(set(keys)) != len(keys)
579
+ or any(identifier in seen[category] for category, identifier in keys)
580
+ or after
581
+ != _seen_after(before, batch.items, self.limits.seen_ids_per_category)
582
+ ):
583
+ raise LibrusError(ErrorKind.PARSE)
584
+ raw = self._raw(connection, context)
585
+ if row[3] is None:
586
+ if (
587
+ row[4] is not None
588
+ or batch.has_more_schedule
589
+ or NotificationCategory.AGENDA in batch.categories
590
+ ):
591
+ raise LibrusError(ErrorKind.PARSE)
592
+ elif (
593
+ raw is None
594
+ or raw[0] != row[3]
595
+ or type(row[4]) is not int
596
+ or raw[3] is None
597
+ or not raw[2] <= row[4] <= raw[3]
598
+ or batch.has_more_schedule != (row[4] < raw[3])
599
+ ):
600
+ raise LibrusError(ErrorKind.PARSE)
601
+ return batch, after, row[3], row[4]
602
+
603
+ async def acknowledge(self, receipt: str, *, context: AccountContext) -> None:
604
+ if type(receipt) is not str or HEX.fullmatch(receipt) is None:
605
+ raise LibrusError(ErrorKind.INVALID_INPUT)
606
+
607
+ async def commit() -> None:
608
+ await self._io(lambda: self._ack(context, receipt), finishing=True)
609
+
610
+ await self._transaction(context, commit)
611
+
612
+ def _ack(self, context: AccountContext, receipt: str) -> None:
613
+ with self._connection() as connection:
614
+ self._validate_contents(connection)
615
+ delivery = self._delivery(connection, context)
616
+ if self._state(connection, context.identifier)[1] == receipt:
617
+ # A late acknowledgement retry must leave a newer batch intact.
618
+ return
619
+ if delivery is None:
620
+ raise LibrusError(ErrorKind.INVALID_INPUT)
621
+ batch, after, raw_identifier, cursor_after = delivery
622
+ if batch.receipt != receipt:
623
+ raise LibrusError(ErrorKind.INVALID_INPUT)
624
+ payload = state_bytes(
625
+ after, self.limits.seen_ids_per_category, self.limits.state_bytes
626
+ )
627
+ connection.execute(
628
+ "INSERT OR REPLACE INTO notification_state VALUES (?,?,?)",
629
+ (self._context_key(context.identifier), payload, receipt),
630
+ )
631
+ if raw_identifier is not None:
632
+ raw = self._raw(connection, context)
633
+ assert raw is not None
634
+ if cursor_after == raw[3]:
635
+ connection.execute(
636
+ "DELETE FROM notification_raw WHERE context=?",
637
+ (self._context_key(context.identifier),),
638
+ )
639
+ else:
640
+ connection.execute(
641
+ "UPDATE notification_raw SET cursor=? WHERE context=?",
642
+ (cursor_after, self._context_key(context.identifier)),
643
+ )
644
+ connection.execute(
645
+ "DELETE FROM notification_deliveries WHERE context=?",
646
+ (self._context_key(context.identifier),),
647
+ )
648
+ self._validate_contents(connection)
649
+
650
+ async def resolve_uncertain_consume(
651
+ self, *, context: AccountContext, accept_possible_loss: bool = False
652
+ ) -> None:
653
+ if type(accept_possible_loss) is not bool or not accept_possible_loss:
654
+ raise LibrusError(ErrorKind.INVALID_INPUT)
655
+
656
+ async def resolve() -> None:
657
+ await self._io(lambda: self._resolve(context))
658
+
659
+ await self._transaction(context, resolve)
660
+
661
+ def _resolve(self, context: AccountContext) -> None:
662
+ with self._connection() as connection:
663
+ self._validate_contents(connection)
664
+ if (
665
+ self._raw(connection, context) is not None
666
+ or self._delivery(connection, context) is not None
667
+ ):
668
+ raise LibrusError(ErrorKind.INVALID_INPUT)
669
+ if (
670
+ connection.execute(
671
+ "DELETE FROM notification_reservations WHERE context=?",
672
+ (self._context_key(context.identifier),),
673
+ ).rowcount
674
+ != 1
675
+ ):
676
+ raise LibrusError(ErrorKind.INVALID_INPUT)
677
+
678
+ async def export_archive(self, *, context: AccountContext) -> NotificationArchive:
679
+ async def export() -> NotificationArchive:
680
+ payload = await self._io(lambda: self._export(context))
681
+ return NotificationArchive(3, context, payload)
682
+
683
+ return await self._transaction(context, export)
684
+
685
+ def _export(self, context: AccountContext) -> bytes:
686
+ with self._connection() as connection:
687
+ self._validate_contents(connection)
688
+ state, last = self._state(connection, context.identifier)
689
+ raw = self._raw(connection, context)
690
+ delivery = self._delivery(connection, context)
691
+ reservation = connection.execute(
692
+ "SELECT bytes FROM notification_reservations WHERE context=?",
693
+ (self._context_key(context.identifier),),
694
+ ).fetchone()
695
+ raw_record = None
696
+ if raw:
697
+ metadata, body = encode_envelope(raw[1], context.alias)
698
+ raw_record = {
699
+ "identifier": raw[0],
700
+ "metadata": load(metadata, META_BYTES),
701
+ "body": base64.b64encode(body).decode("ascii"),
702
+ "cursor": raw[2],
703
+ "total": raw[3],
704
+ }
705
+ delivery_record = None
706
+ if delivery:
707
+ delivery_record = {
708
+ "batch": load(
709
+ encode_batch(
710
+ replace(
711
+ delivery[0],
712
+ context=replace(
713
+ context,
714
+ identifier=self._context_key(context.identifier),
715
+ ),
716
+ ),
717
+ self.limits.batch_bytes,
718
+ ),
719
+ self.limits.batch_bytes,
720
+ ),
721
+ "state_after": asdict(delivery[1]),
722
+ "raw_identifier": delivery[2],
723
+ "cursor_after": delivery[3],
724
+ }
725
+ return dump(
726
+ {
727
+ "state": asdict(state),
728
+ "version": 3,
729
+ "context": asdict(
730
+ replace(
731
+ context, identifier=self._context_key(context.identifier)
732
+ )
733
+ ),
734
+ "context_salt": self._context_salt.hex()
735
+ if self._context_salt
736
+ else None,
737
+ "last_receipt": last,
738
+ "raw": raw_record,
739
+ "delivery": delivery_record,
740
+ "reservation": reservation[0] if reservation else None,
741
+ },
742
+ ARCHIVE_BYTES,
743
+ )
744
+
745
+ async def import_archive(self, archive: NotificationArchive) -> None:
746
+ if (
747
+ not isinstance(archive, NotificationArchive)
748
+ or type(archive.version) is not int
749
+ or archive.version != 3
750
+ or type(archive.payload) is not bytes
751
+ ):
752
+ raise LibrusError(ErrorKind.INVALID_INPUT)
753
+
754
+ async def restore() -> None:
755
+ await self._io(lambda: self._import(archive))
756
+
757
+ await self._transaction(archive.context, restore)
758
+
759
+ def _rebind_archive(self, record: dict[str, Any], context: AccountContext) -> None:
760
+ """Validate source namespace before rebinding to this empty target store."""
761
+ salt = record["context_salt"]
762
+ if type(salt) is not str or HEX.fullmatch(salt) is None:
763
+ raise LibrusError(ErrorKind.PARSE)
764
+ source_key = hmac.new(
765
+ bytes.fromhex(salt), bytes.fromhex(context.identifier), hashlib.sha256
766
+ ).hexdigest()
767
+ source_context = replace(context, identifier=source_key)
768
+ if record["context"] != asdict(source_context):
769
+ raise LibrusError(ErrorKind.PARSE)
770
+ target_context = replace(
771
+ context, identifier=self._context_key(context.identifier)
772
+ )
773
+ record["context"] = asdict(target_context)
774
+ raw, delivery = record["raw"], record["delivery"]
775
+ source_raw: str | None = None
776
+ target_raw: str | None = None
777
+ if raw is not None:
778
+ if (
779
+ type(raw) is not dict
780
+ or set(raw) != {"identifier", "metadata", "body", "cursor", "total"}
781
+ or type(raw["body"]) is not str
782
+ ):
783
+ raise LibrusError(ErrorKind.PARSE)
784
+ try:
785
+ body = base64.b64decode(raw["body"], validate=True)
786
+ except (ValueError, UnicodeError):
787
+ raise LibrusError(ErrorKind.PARSE) from None
788
+ metadata = dump(raw["metadata"], META_BYTES)
789
+ source_raw = _raw_id(source_key, metadata, body)
790
+ if raw["identifier"] != source_raw:
791
+ raise LibrusError(ErrorKind.PARSE)
792
+ target_raw = _raw_id(target_context.identifier, metadata, body)
793
+ raw["identifier"] = target_raw
794
+ if delivery is not None:
795
+ if type(delivery) is not dict or set(delivery) != {
796
+ "batch",
797
+ "state_after",
798
+ "raw_identifier",
799
+ "cursor_after",
800
+ }:
801
+ raise LibrusError(ErrorKind.PARSE)
802
+ batch = restore_batch(
803
+ dump(delivery["batch"], self.limits.batch_bytes),
804
+ self.limits.batch_bytes,
805
+ )
806
+ if (
807
+ batch.context != source_context
808
+ or delivery["raw_identifier"] != source_raw
809
+ ):
810
+ # Ordinary-only batches may coexist with pending raw recovery.
811
+ if (
812
+ batch.context != source_context
813
+ or delivery["raw_identifier"] is not None
814
+ ):
815
+ raise LibrusError(ErrorKind.PARSE)
816
+ if delivery["raw_identifier"] is not None:
817
+ delivery["raw_identifier"] = target_raw
818
+ delivery["batch"] = load(
819
+ encode_batch(
820
+ replace(batch, context=target_context), self.limits.batch_bytes
821
+ ),
822
+ self.limits.batch_bytes,
823
+ )
824
+
825
+ def _import(self, archive: NotificationArchive) -> None:
826
+ record = load(archive.payload, ARCHIVE_BYTES)
827
+ if type(record) is not dict or set(record) != {
828
+ "state",
829
+ "last_receipt",
830
+ "raw",
831
+ "delivery",
832
+ "reservation",
833
+ "version",
834
+ "context",
835
+ "context_salt",
836
+ }:
837
+ raise LibrusError(ErrorKind.PARSE)
838
+ context = archive.context
839
+ if type(record["version"]) is not int or record["version"] != 3:
840
+ raise LibrusError(ErrorKind.PARSE)
841
+ self._rebind_archive(record, context)
842
+ with self._connection() as connection:
843
+ self._validate_contents(connection)
844
+ # Import is empty-target only, never a silent history reset.
845
+ for table in (
846
+ "notification_state",
847
+ "notification_raw",
848
+ "notification_deliveries",
849
+ "notification_reservations",
850
+ ):
851
+ if connection.execute(
852
+ f"SELECT 1 FROM {table} WHERE context=?",
853
+ (self._context_key(context.identifier),),
854
+ ).fetchone():
855
+ raise LibrusError(ErrorKind.INVALID_INPUT)
856
+ state = restore_state(
857
+ dump(record["state"], self.limits.state_bytes),
858
+ self.limits.seen_ids_per_category,
859
+ self.limits.state_bytes,
860
+ )
861
+ last = record["last_receipt"]
862
+ if last is not None and (
863
+ type(last) is not str
864
+ or HEX.fullmatch(last) is None
865
+ or not state.initialized
866
+ ):
867
+ raise LibrusError(ErrorKind.PARSE)
868
+ if state.initialized or last is not None:
869
+ connection.execute(
870
+ "INSERT INTO notification_state VALUES (?,?,?)",
871
+ (
872
+ self._context_key(context.identifier),
873
+ state_bytes(
874
+ state,
875
+ self.limits.seen_ids_per_category,
876
+ self.limits.state_bytes,
877
+ ),
878
+ last,
879
+ ),
880
+ )
881
+ raw = record["raw"]
882
+ if raw is not None:
883
+ self._import_raw(connection, context, raw)
884
+ reservation = record["reservation"]
885
+ if reservation is not None:
886
+ if (
887
+ raw is not None
888
+ or type(reservation) is not int
889
+ or not 1 <= reservation <= WIRE_BYTES + META_BYTES
890
+ ):
891
+ raise LibrusError(ErrorKind.PARSE)
892
+ connection.execute(
893
+ "INSERT INTO notification_reservations VALUES (?,?)",
894
+ (self._context_key(context.identifier), reservation),
895
+ )
896
+ delivery = record["delivery"]
897
+ if delivery is not None:
898
+ if type(delivery) is not dict or set(delivery) != {
899
+ "batch",
900
+ "state_after",
901
+ "raw_identifier",
902
+ "cursor_after",
903
+ }:
904
+ raise LibrusError(ErrorKind.PARSE)
905
+ batch = restore_batch(
906
+ dump(delivery["batch"], self.limits.batch_bytes),
907
+ self.limits.batch_bytes,
908
+ )
909
+ if batch.context != replace(
910
+ context, identifier=self._context_key(context.identifier)
911
+ ):
912
+ raise LibrusError(ErrorKind.PARSE)
913
+ public_batch = replace(batch, context=context)
914
+ after = restore_state(
915
+ dump(delivery["state_after"], self.limits.state_bytes),
916
+ self.limits.seen_ids_per_category,
917
+ self.limits.state_bytes,
918
+ )
919
+ connection.execute(
920
+ "INSERT INTO notification_deliveries VALUES (?,?,?,?,?,?)",
921
+ (
922
+ self._context_key(context.identifier),
923
+ batch.receipt,
924
+ encode_batch(batch, self.limits.batch_bytes),
925
+ state_bytes(
926
+ after,
927
+ self.limits.seen_ids_per_category,
928
+ self.limits.state_bytes,
929
+ ),
930
+ delivery["raw_identifier"],
931
+ delivery["cursor_after"],
932
+ ),
933
+ )
934
+ self._delivery(connection, context)
935
+ self._validate_import_delivery(connection, context, public_batch)
936
+ self._validate_contents(connection)
937
+
938
+ def _validate_import_delivery(
939
+ self,
940
+ connection: sqlite3.Connection,
941
+ context: AccountContext,
942
+ batch: NotificationBatch,
943
+ ) -> None:
944
+ """Prove imported cursor progress does not discard an undelivered event."""
945
+ raw = self._raw(connection, context)
946
+ if NotificationCategory.AGENDA not in batch.categories:
947
+ return
948
+ if raw is None:
949
+ raise LibrusError(ErrorKind.PARSE)
950
+ delivery = self._delivery(connection, context)
951
+ assert delivery is not None and delivery[3] is not None
952
+ response = raw[1]
953
+ events = parse_schedule_events(
954
+ decode_payload(response.wire.body, response.wire, WIRE_BYTES)
955
+ )
956
+ before, _ = self._state(connection, context.identifier)
957
+ seen = {
958
+ identifier
959
+ for entry in before.seen
960
+ if entry.category is NotificationCategory.AGENDA
961
+ for identifier in entry.identifiers
962
+ }
963
+ expected = []
964
+ for value in events[raw[2] : delivery[3]]:
965
+ identifier = canonical_notification_id(NotificationCategory.AGENDA, value)
966
+ if identifier not in seen:
967
+ expected.append(
968
+ NotificationItem(
969
+ NotificationCategory.AGENDA,
970
+ identifier,
971
+ value,
972
+ response.identity,
973
+ response.observation,
974
+ )
975
+ )
976
+ seen.add(identifier)
977
+ actual = [
978
+ item for item in batch.items if item.category is NotificationCategory.AGENDA
979
+ ]
980
+ if actual != expected:
981
+ raise LibrusError(ErrorKind.PARSE)
982
+
983
+ def _import_raw(
984
+ self, connection: sqlite3.Connection, context: AccountContext, raw: Any
985
+ ) -> None:
986
+ if (
987
+ type(raw) is not dict
988
+ or set(raw) != {"identifier", "metadata", "body", "cursor", "total"}
989
+ or type(raw["body"]) is not str
990
+ ):
991
+ raise LibrusError(ErrorKind.PARSE)
992
+ failed = False
993
+ body = b""
994
+ try:
995
+ body = base64.b64decode(raw["body"], validate=True)
996
+ except (ValueError, UnicodeError):
997
+ failed = True
998
+ if (
999
+ failed
1000
+ or type(raw["cursor"]) is not int
1001
+ or (raw["total"] is not None and type(raw["total"]) is not int)
1002
+ ):
1003
+ raise LibrusError(ErrorKind.PARSE)
1004
+ metadata = dump(raw["metadata"], META_BYTES)
1005
+ response = restore_envelope(metadata, body, context.alias)
1006
+ if raw["total"] is None and raw["cursor"] != 0:
1007
+ raise LibrusError(ErrorKind.PARSE)
1008
+ if raw["total"] is not None:
1009
+ events = parse_schedule_events(
1010
+ decode_payload(body, response.wire, WIRE_BYTES)
1011
+ )
1012
+ if raw["total"] != len(events):
1013
+ raise LibrusError(ErrorKind.PARSE)
1014
+ # Acknowledged progress marks every event before the cursor seen; an
1015
+ # unseen one there would be skipped without ever being delivered.
1016
+ state, _ = self._state(connection, context.identifier)
1017
+ seen = {
1018
+ identifier
1019
+ for entry in state.seen
1020
+ if entry.category is NotificationCategory.AGENDA
1021
+ for identifier in entry.identifiers
1022
+ }
1023
+ if any(
1024
+ canonical_notification_id(NotificationCategory.AGENDA, value)
1025
+ not in seen
1026
+ for value in events[: raw["cursor"]]
1027
+ ):
1028
+ raise LibrusError(ErrorKind.PARSE)
1029
+ if raw["identifier"] != _raw_id(
1030
+ self._context_key(context.identifier), metadata, body
1031
+ ):
1032
+ raise LibrusError(ErrorKind.PARSE)
1033
+ connection.execute(
1034
+ "INSERT INTO notification_raw VALUES (?,?,?,?,?,?)",
1035
+ (
1036
+ self._context_key(context.identifier),
1037
+ raw["identifier"],
1038
+ metadata,
1039
+ body,
1040
+ raw["cursor"],
1041
+ raw["total"],
1042
+ ),
1043
+ )
1044
+ self._raw(connection, context)