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,232 @@
1
+ """Pure, bounded, independently authored JSON and semantic HTML parsers."""
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from typing import Any
7
+
8
+ from lxml import etree, html
9
+ from pydantic import ValidationError
10
+
11
+ from librus_python_api.config import PAGE_NOTICES, PROFILE_LABELS
12
+ from librus_python_api.exceptions import ErrorKind, LibrusError
13
+ from librus_python_api.models import (
14
+ Availability,
15
+ LuckyNumber,
16
+ Person,
17
+ ProfileFields,
18
+ _EnvelopeWire,
19
+ )
20
+
21
+
22
+ def decode_json(body: bytes) -> Any:
23
+ # Bound nesting before the decoder allocates recursive containers. Strings
24
+ # and escaped quotes do not count as structure. Bytes were bounded by caller.
25
+ depth = 0
26
+ quoted = escaped = False
27
+ for value in body:
28
+ if quoted:
29
+ if escaped:
30
+ escaped = False
31
+ elif value == 92:
32
+ escaped = True
33
+ elif value == 34:
34
+ quoted = False
35
+ elif value == 34:
36
+ quoted = True
37
+ elif value in (91, 123):
38
+ depth += 1
39
+ if depth > 32:
40
+ raise LibrusError(ErrorKind.LIMIT)
41
+ elif value in (93, 125):
42
+ depth -= 1
43
+ failed = False
44
+ result: Any = None
45
+ try:
46
+ result = json.loads(
47
+ body.decode("utf-8"),
48
+ object_pairs_hook=_unique_object,
49
+ parse_constant=_invalid_constant,
50
+ parse_float=_finite_float,
51
+ )
52
+ except (ValueError, UnicodeError, RecursionError):
53
+ failed = True
54
+ if failed:
55
+ raise LibrusError(ErrorKind.PARSE)
56
+ return result
57
+
58
+
59
+ def _invalid_constant(value: str) -> Any:
60
+ raise ValueError("Non-standard JSON number")
61
+
62
+
63
+ def _finite_float(value: str) -> float:
64
+ number = float(value)
65
+ if not math.isfinite(number):
66
+ raise ValueError("Non-finite JSON number")
67
+ return number
68
+
69
+
70
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
71
+ result: dict[str, Any] = {}
72
+ for key, value in pairs:
73
+ if key in result:
74
+ raise ValueError("Duplicate JSON key")
75
+ result[key] = value
76
+ return result
77
+
78
+
79
+ def parse_identity(body: bytes) -> tuple[Person, Person]:
80
+ data = decode_json(body)
81
+ failed = False
82
+ envelope: _EnvelopeWire | None = None
83
+ try:
84
+ envelope = _EnvelopeWire.model_validate(data)
85
+ except ValidationError:
86
+ failed = True
87
+ if failed:
88
+ raise LibrusError(ErrorKind.PARSE)
89
+ assert envelope is not None
90
+ account, user = envelope.Me.Account, envelope.Me.User
91
+ # Gateway variants omit User.Id but supply its explicit Account.UserId
92
+ # reference. Account.Id remains the login owner, never the represented user.
93
+ student_id = user.Id if user.Id is not None else account.UserId
94
+ if student_id is None or (
95
+ user.Id is not None and account.UserId is not None and user.Id != account.UserId
96
+ ):
97
+ raise LibrusError(ErrorKind.PARSE)
98
+ return (
99
+ Person(account.Id, account.FirstName, account.LastName),
100
+ Person(student_id, user.FirstName, user.LastName),
101
+ )
102
+
103
+
104
+ def parse_login(body: bytes) -> str:
105
+ data = decode_json(body)
106
+ if not isinstance(data, dict):
107
+ raise LibrusError(ErrorKind.PARSE)
108
+ if any(data.get(key) for key in ("captcha", "twoFactorRequired", "requiresAction")):
109
+ raise LibrusError(ErrorKind.ACCOUNT_ACTION_REQUIRED)
110
+ if data.get("status") == "error":
111
+ raise LibrusError(ErrorKind.CREDENTIALS_REJECTED)
112
+ location = data.get("goTo")
113
+ if data.get("status") != "ok" or not isinstance(location, str) or not location:
114
+ raise LibrusError(ErrorKind.ACCOUNT_ACTION_REQUIRED)
115
+ if len(location) > 4096:
116
+ raise LibrusError(ErrorKind.LIMIT)
117
+ return location
118
+
119
+
120
+ def parse_html_document(
121
+ body: bytes, *, repeatable_id: str | None = None
122
+ ) -> html.HtmlElement:
123
+ failed = False
124
+ document: html.HtmlElement | None = None
125
+ parser = html.HTMLParser(no_network=True, recover=True, huge_tree=False)
126
+ try:
127
+ text = body.decode("utf-8")
128
+ document = html.document_fromstring(
129
+ text,
130
+ parser=parser,
131
+ )
132
+ except (UnicodeError, etree.LxmlError, ValueError):
133
+ failed = True
134
+ # Browser HTML commonly contains stray closing tags. Accept only this
135
+ # observed repair category; semantic tables, spans, IDs, and bounds still
136
+ # validate independently. Do not blanket-ignore arbitrary parser errors.
137
+ if failed or any(
138
+ error.type_name != "ERR_TAG_NAME_MISMATCH"
139
+ and not (
140
+ repeatable_id is not None
141
+ and error.type_name == "DTD_ID_REDEFINED"
142
+ and error.message == f"ID {repeatable_id} already defined"
143
+ )
144
+ for error in parser.error_log
145
+ ):
146
+ raise LibrusError(ErrorKind.PARSE)
147
+ assert document is not None
148
+ count = 0
149
+ pending = [(document, 0)]
150
+ while pending:
151
+ node, depth = pending.pop()
152
+ count += 1
153
+ if count > 8192 or depth > 32:
154
+ raise LibrusError(ErrorKind.LIMIT)
155
+ pending.extend((child, depth + 1) for child in node)
156
+ return document
157
+
158
+
159
+ def page_notices(document: html.HtmlElement) -> tuple[str, ...]:
160
+ """Texts of page-level information boxes, which replace requested content."""
161
+ nodes = document.xpath(
162
+ '//div[contains(concat(" ",normalize-space(@class)," ")," warning-content ")]'
163
+ )
164
+ return tuple(" ".join(node.text_content().split()) for node in nodes)
165
+
166
+
167
+ def parse_page(body: bytes, *, repeatable_id: str | None = None) -> html.HtmlElement:
168
+ """Parse a whole Synergia page and raise for a recognized notice.
169
+
170
+ Notices such as a view disabled by the school administrator are typed
171
+ outcomes, never parse failures or empty collections.
172
+ """
173
+ document = parse_html_document(body, repeatable_id=repeatable_id)
174
+ for notice in page_notices(document):
175
+ kind = PAGE_NOTICES.get(notice)
176
+ if kind is not None:
177
+ raise LibrusError(kind)
178
+ return document
179
+
180
+
181
+ def parse_profile(body: bytes) -> ProfileFields:
182
+ document = parse_page(body)
183
+ required = {"name", "class_name", "register_number", "tutor", "school"}
184
+ candidates: list[dict[str, str]] = []
185
+ for table in document.iter("table"):
186
+ fields: dict[str, str] = {}
187
+ for row in table.iter("tr"):
188
+ # Do not inherit rows of a nested table into its parent.
189
+ if next(row.iterancestors("table"), None) is not table:
190
+ continue
191
+ cells = [child for child in row if child.tag in ("td", "th")]
192
+ if len(cells) != 2:
193
+ continue
194
+ label = " ".join(cells[0].text_content().split()).rstrip(":")
195
+ key = PROFILE_LABELS.get(label)
196
+ if key is None:
197
+ continue
198
+ if key in fields or any(
199
+ c.get("rowspan", "1") != "1" or c.get("colspan", "1") != "1"
200
+ for c in cells
201
+ ):
202
+ raise LibrusError(ErrorKind.PARSE)
203
+ value = " ".join(cells[1].text_content().split())
204
+ if not value or len(value) > 1024:
205
+ raise LibrusError(ErrorKind.PARSE)
206
+ fields[key] = value
207
+ if fields.keys() == required:
208
+ candidates.append(fields)
209
+ if len(candidates) != 1:
210
+ raise LibrusError(ErrorKind.PARSE)
211
+ if not re.fullmatch(r"[0-9]{1,4}", candidates[0]["register_number"]):
212
+ raise LibrusError(ErrorKind.PARSE)
213
+ markers = document.xpath('//*[@id="luckyNumber"]')
214
+ if not markers:
215
+ lucky = LuckyNumber(Availability.UNAVAILABLE)
216
+ elif len(markers) == 1:
217
+ value = " ".join(markers[0].text_content().split())
218
+ if not re.fullmatch(r"[0-9]{1,4}", value):
219
+ raise LibrusError(ErrorKind.PARSE)
220
+ # No evidenced civil date in this marker. Never fabricate today's date.
221
+ lucky = LuckyNumber(Availability.AVAILABLE, int(value))
222
+ else:
223
+ raise LibrusError(ErrorKind.PARSE)
224
+ fields = candidates[0]
225
+ return ProfileFields(
226
+ fields["name"],
227
+ fields["class_name"],
228
+ int(fields["register_number"]),
229
+ fields["tutor"],
230
+ fields["school"],
231
+ lucky,
232
+ )
@@ -0,0 +1,49 @@
1
+ """A bounded parser executor whose canceled work is joined before release."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+ from concurrent.futures import ThreadPoolExecutor
6
+
7
+ from librus_python_api.budget import RequestBudget
8
+ from librus_python_api.exceptions import ErrorKind, LibrusError
9
+ from librus_python_api.lifecycle import join_owned
10
+
11
+
12
+ class ParserPool:
13
+ def __init__(self, max_bytes: int) -> None:
14
+ self._max_bytes = max_bytes
15
+ self._slots = asyncio.Semaphore(2)
16
+ self._executor: ThreadPoolExecutor | None = None
17
+
18
+ async def run[T](
19
+ self,
20
+ parser: Callable[[bytes], T],
21
+ body: bytes,
22
+ budget: RequestBudget,
23
+ ) -> T:
24
+ if len(body) > self._max_bytes:
25
+ raise LibrusError(ErrorKind.LIMIT)
26
+ # Service admission bounds waiting coroutines; acquire before submit so
27
+ # the executor never develops an unbounded hidden queue.
28
+ async with self._slots:
29
+ budget.remaining_seconds()
30
+ if self._executor is None:
31
+ self._executor = ThreadPoolExecutor(
32
+ max_workers=2, thread_name_prefix="librus-parse"
33
+ )
34
+ future = asyncio.get_running_loop().run_in_executor(
35
+ self._executor, parser, body
36
+ )
37
+ try:
38
+ result = await asyncio.shield(future)
39
+ except asyncio.CancelledError:
40
+ # Threads cannot be preempted. Pure parsing cannot mutate session
41
+ # state; release the slot only after actual bounded completion.
42
+ await join_owned(future)
43
+ raise
44
+ budget.remaining_seconds()
45
+ return result
46
+
47
+ def close(self) -> None:
48
+ if self._executor is not None:
49
+ self._executor.shutdown(wait=True, cancel_futures=True)
@@ -0,0 +1,419 @@
1
+ """Explicit optional durable sending, independent of MCP and core transport."""
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import secrets
7
+ import sqlite3
8
+ import time
9
+ from dataclasses import asdict, dataclass, field
10
+ from datetime import UTC, datetime
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Self, cast
14
+
15
+ from pydantic import Field, model_validator
16
+
17
+ from librus_python_api._notification_codec import canonical_notification_id
18
+ from librus_python_api._storage import _SQLiteStore, _StorageLimits
19
+ from librus_python_api.budget import RequestBudget
20
+ from librus_python_api.exceptions import ErrorKind, LibrusError
21
+ from librus_python_api.models import AccountContext, SendResult, SendStatus
22
+ from librus_python_api.notification_models import (
23
+ NotificationArchive,
24
+ NotificationBaselineMapping,
25
+ NotificationBatch,
26
+ NotificationBootstrap,
27
+ NotificationBootstrapResult,
28
+ NotificationItem,
29
+ NotificationPendingDelivery,
30
+ NotificationProvenance,
31
+ NotificationRawCheckpointStatus,
32
+ NotificationRecoveryStatus,
33
+ NotificationSeen,
34
+ NotificationState,
35
+ )
36
+ from librus_python_api.notification_persistence import (
37
+ NotificationLimits,
38
+ NotificationStore,
39
+ )
40
+ from librus_python_api.notification_workflow import NotificationWorkflow
41
+ from librus_python_api.sending import SendAttempt
42
+
43
+ __all__ = [
44
+ "NotificationArchive",
45
+ "NotificationBaselineMapping",
46
+ "NotificationBatch",
47
+ "NotificationBootstrap",
48
+ "NotificationBootstrapResult",
49
+ "NotificationItem",
50
+ "NotificationPendingDelivery",
51
+ "NotificationProvenance",
52
+ "NotificationRawCheckpointStatus",
53
+ "NotificationRecoveryStatus",
54
+ "NotificationSeen",
55
+ "NotificationState",
56
+ "NotificationLimits",
57
+ "NotificationStore",
58
+ "NotificationWorkflow",
59
+ "canonical_notification_id",
60
+ "DurableSendOutcome",
61
+ "DurableSendPhase",
62
+ "DurableSendRecord",
63
+ "PersistenceLimits",
64
+ "PersistenceStore",
65
+ "SendConfirmation",
66
+ ]
67
+
68
+ _STATUS_VALUES = frozenset(
69
+ {"pending", "claimed", "invalidated", *(s.value for s in SendStatus)}
70
+ )
71
+ _HEX = re.compile(r"[0-9a-f]{64}")
72
+ _TOKEN = re.compile(r"[A-Za-z0-9_-]{20,128}")
73
+ _SendRow = tuple[str, str, str, int, int, str]
74
+ _SEND_SCHEMA = """CREATE TABLE send_attempts (
75
+ token_hash TEXT PRIMARY KEY CHECK(length(token_hash)=64),
76
+ context TEXT NOT NULL CHECK(length(context)=64),
77
+ payload_digest TEXT NOT NULL CHECK(length(payload_digest)=64),
78
+ created_at INTEGER NOT NULL CHECK(created_at>=0),
79
+ expires_at INTEGER NOT NULL CHECK(
80
+ expires_at>created_at AND expires_at<=created_at+300),
81
+ status TEXT NOT NULL CHECK(status IN (
82
+ 'pending','claimed','invalidated','not_dispatched',
83
+ 'unknown','accepted','rejected'))
84
+ )"""
85
+
86
+
87
+ class PersistenceLimits(_StorageLimits):
88
+ send_records: int = Field(default=256, ge=1, le=4096)
89
+ pending_confirmations: int = Field(default=32, ge=1, le=256)
90
+ confirmation_ttl_seconds: int = Field(default=300, ge=1, le=300)
91
+
92
+ @model_validator(mode="after")
93
+ def pending_bound(self) -> Self:
94
+ if self.pending_confirmations > self.send_records:
95
+ raise ValueError("Pending confirmations exceed history capacity")
96
+ return self
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class SendConfirmation:
101
+ token: str = field(repr=False)
102
+ expires_at: datetime
103
+
104
+
105
+ class DurableSendPhase(StrEnum):
106
+ PENDING = "pending"
107
+ CLAIMED = "claimed"
108
+ INVALIDATED = "invalidated"
109
+ NOT_DISPATCHED = "not_dispatched"
110
+ UNKNOWN = "unknown"
111
+ ACCEPTED = "accepted"
112
+ REJECTED = "rejected"
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class DurableSendOutcome:
117
+ status: SendStatus | None
118
+ phase: DurableSendPhase
119
+ requires_reconciliation: bool
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class DurableSendRecord:
124
+ identifier: str = field(repr=False)
125
+ payload_digest: str = field(repr=False)
126
+ created_at: datetime
127
+ expires_at: datetime
128
+ outcome: DurableSendOutcome
129
+
130
+
131
+ def _valid_digest(value: str) -> str:
132
+ if type(value) is not str or _HEX.fullmatch(value) is None:
133
+ raise LibrusError(ErrorKind.INVALID_INPUT)
134
+ return value
135
+
136
+
137
+ def _token_hash(token: str) -> str:
138
+ if type(token) is not str or _TOKEN.fullmatch(token) is None:
139
+ raise LibrusError(ErrorKind.INVALID_INPUT)
140
+ return hashlib.sha256(token.encode("ascii")).hexdigest()
141
+
142
+
143
+ def _now() -> int:
144
+ return time.time_ns() // 1_000_000_000
145
+
146
+
147
+ def _binding(attempt: SendAttempt) -> tuple[str, str]:
148
+ if not isinstance(attempt, SendAttempt) or attempt.used:
149
+ raise LibrusError(ErrorKind.INVALID_INPUT)
150
+ context = _valid_digest(attempt.account_context.identifier)
151
+ encoded = json.dumps(
152
+ [1, context, attempt.outcome.backend.value, asdict(attempt.submission)],
153
+ ensure_ascii=False,
154
+ sort_keys=True,
155
+ separators=(",", ":"),
156
+ ).encode("utf-8")
157
+ return context, hashlib.sha256(encoded).hexdigest()
158
+
159
+
160
+ class PersistenceStore(_SQLiteStore):
161
+ """Explicit private durable send claims; core clients require no storage.
162
+
163
+ The selected directory's parent must exist. Store only digests and outcomes,
164
+ never send bodies, token plaintext or HTTP cookies. Existing files are
165
+ validated, not reset or automatically migrated. A durable claim is uncertain
166
+ after process loss, even if no HTTP request actually reached the upstream.
167
+ """
168
+
169
+ _schema = (_SEND_SCHEMA,)
170
+
171
+ def __init__(
172
+ self, directory: Path, *, limits: PersistenceLimits | None = None
173
+ ) -> None:
174
+ self._limits = limits if limits is not None else PersistenceLimits()
175
+ if not isinstance(self._limits, PersistenceLimits):
176
+ raise LibrusError(ErrorKind.INVALID_INPUT)
177
+ super().__init__(directory, limits=self._limits)
178
+
179
+ def _validate_contents(self, connection: sqlite3.Connection) -> None:
180
+ self._rows(connection)
181
+
182
+ def _rows(self, connection: sqlite3.Connection) -> list[_SendRow]:
183
+ rows = cast(
184
+ list[_SendRow],
185
+ connection.execute(
186
+ "SELECT token_hash, context, payload_digest, created_at, "
187
+ "expires_at, status FROM send_attempts LIMIT ?",
188
+ (self._limits.send_records + 1,),
189
+ ).fetchall(),
190
+ )
191
+ if len(rows) > self._limits.send_records:
192
+ raise LibrusError(ErrorKind.LIMIT)
193
+ for row in rows:
194
+ if (
195
+ any(
196
+ type(value) is not str or _HEX.fullmatch(value) is None
197
+ for value in row[:3]
198
+ )
199
+ or type(row[3]) is not int
200
+ or type(row[4]) is not int
201
+ or not 0 <= row[3] < row[4] <= row[3] + 300
202
+ or row[4] > 253402300799
203
+ or type(row[5]) is not str
204
+ or row[5] not in _STATUS_VALUES
205
+ ):
206
+ raise LibrusError(ErrorKind.PARSE)
207
+ return rows
208
+
209
+ async def preview_send(self, attempt: SendAttempt) -> SendConfirmation:
210
+ context, digest = _binding(attempt)
211
+ context = self.context_identifier(context)
212
+ return await self._io(lambda: self._issue(context, digest))
213
+
214
+ def _issue(self, context: str, digest: str) -> SendConfirmation:
215
+ now = _now()
216
+ with self._connection() as connection:
217
+ self._rows(connection)
218
+ connection.execute(
219
+ "DELETE FROM send_attempts WHERE status='pending' AND expires_at<=?",
220
+ (now,),
221
+ )
222
+ rows = self._rows(connection)
223
+ if self._duplicate(rows, context, digest):
224
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
225
+ if (
226
+ len(rows) >= self._limits.send_records
227
+ or sum(row[5] == "pending" for row in rows)
228
+ >= self._limits.pending_confirmations
229
+ ):
230
+ raise LibrusError(ErrorKind.LIMIT)
231
+ token = secrets.token_urlsafe(32)
232
+ deadline = now + self._limits.confirmation_ttl_seconds
233
+ connection.execute(
234
+ "INSERT INTO send_attempts VALUES (?,?,?,?,?,?)",
235
+ (_token_hash(token), context, digest, now, deadline, "pending"),
236
+ )
237
+ return SendConfirmation(token, datetime.fromtimestamp(deadline, UTC))
238
+
239
+ @staticmethod
240
+ def _duplicate(rows: list[_SendRow], context: str, digest: str) -> bool:
241
+ return any(
242
+ row[1] == context
243
+ and row[2] == digest
244
+ and row[5] in {"claimed", "unknown", "accepted"}
245
+ for row in rows
246
+ )
247
+
248
+ def _claim(self, token_hash: str, context: str, digest: str) -> None:
249
+ with self._connection() as connection:
250
+ rows = self._rows(connection)
251
+ row = next((row for row in rows if row[0] == token_hash), None)
252
+ if row is None or row[5] != "pending":
253
+ raise LibrusError(ErrorKind.INVALID_INPUT)
254
+ invalid = (
255
+ row[1] != context
256
+ or row[2] != digest
257
+ or not row[3] <= _now() < row[4]
258
+ or self._duplicate(rows, context, digest)
259
+ )
260
+ connection.execute(
261
+ "UPDATE send_attempts SET status=? WHERE token_hash=?",
262
+ ("invalidated" if invalid else "claimed", token_hash),
263
+ )
264
+ if invalid:
265
+ raise LibrusError(ErrorKind.INVALID_INPUT)
266
+
267
+ async def execute_send(
268
+ self, token: str, attempt: SendAttempt, *, budget: RequestBudget | None = None
269
+ ) -> SendResult:
270
+ token_hash = _token_hash(token)
271
+ context, digest = _binding(attempt)
272
+ context = self.context_identifier(context)
273
+ return await self._owned(
274
+ lambda: self._execute_send(token_hash, context, digest, attempt, budget)
275
+ )
276
+
277
+ async def _execute_send(
278
+ self,
279
+ token_hash: str,
280
+ context: str,
281
+ digest: str,
282
+ attempt: SendAttempt,
283
+ budget: RequestBudget | None,
284
+ ) -> SendResult:
285
+ await self._io(lambda: self._claim(token_hash, context, digest))
286
+ try:
287
+ return await attempt.execute(budget=budget)
288
+ finally:
289
+ try:
290
+ await self._io(
291
+ lambda: self._finish(
292
+ token_hash, context, digest, attempt.outcome.status
293
+ ),
294
+ finishing=True,
295
+ )
296
+ except LibrusError as error:
297
+ # LIMIT means "stopped before upstream work"; after a claim the
298
+ # send may have been dispatched and the claim stays uncertain.
299
+ if error.kind is not ErrorKind.LIMIT:
300
+ raise
301
+ raise LibrusError(ErrorKind.STORAGE) from None
302
+
303
+ def _finish(
304
+ self, token_hash: str, context: str, digest: str, status: SendStatus
305
+ ) -> None:
306
+ with self._connection() as connection:
307
+ self._rows(connection)
308
+ changed = connection.execute(
309
+ "UPDATE send_attempts SET status=? WHERE token_hash=? "
310
+ "AND context=? AND payload_digest=? AND status='claimed'",
311
+ (status.value, token_hash, context, digest),
312
+ ).rowcount
313
+ if changed != 1:
314
+ raise LibrusError(ErrorKind.INVALID_INPUT)
315
+
316
+ async def send_outcome(
317
+ self, token: str, *, context: AccountContext
318
+ ) -> DurableSendOutcome:
319
+ token_hash = _token_hash(token)
320
+ if not isinstance(context, AccountContext):
321
+ raise LibrusError(ErrorKind.INVALID_INPUT)
322
+ identifier = self.context_identifier(_valid_digest(context.identifier))
323
+ return await self._io(lambda: self._outcome(token_hash, identifier))
324
+
325
+ def _outcome(self, token_hash: str, context: str) -> DurableSendOutcome:
326
+ with self._connection() as connection:
327
+ row = next(
328
+ (row for row in self._rows(connection) if row[0] == token_hash), None
329
+ )
330
+ if row is None or row[1] != context:
331
+ raise LibrusError(ErrorKind.INVALID_INPUT)
332
+ return self._snapshot(row[5])
333
+
334
+ @staticmethod
335
+ def _snapshot(value: str) -> DurableSendOutcome:
336
+ phase = DurableSendPhase(value)
337
+ status = (
338
+ SendStatus.UNKNOWN
339
+ if phase is DurableSendPhase.CLAIMED
340
+ else SendStatus(phase)
341
+ if phase in {s.value for s in SendStatus}
342
+ else None
343
+ )
344
+ return DurableSendOutcome(status, phase, phase in {"claimed", "unknown"})
345
+
346
+ async def send_history(
347
+ self, *, context: AccountContext
348
+ ) -> tuple[DurableSendRecord, ...]:
349
+ """Bounded context-bound recovery, even if plaintext tokens were lost."""
350
+ if not isinstance(context, AccountContext):
351
+ raise LibrusError(ErrorKind.INVALID_INPUT)
352
+ identifier = self.context_identifier(_valid_digest(context.identifier))
353
+ return await self._io(lambda: self._history(identifier))
354
+
355
+ def _history(self, context: str) -> tuple[DurableSendRecord, ...]:
356
+ with self._connection() as connection:
357
+ rows = sorted(
358
+ (row for row in self._rows(connection) if row[1] == context),
359
+ key=lambda row: (row[3], row[0]),
360
+ )
361
+ return tuple(
362
+ DurableSendRecord(
363
+ row[0],
364
+ row[2],
365
+ datetime.fromtimestamp(row[3], UTC),
366
+ datetime.fromtimestamp(row[4], UTC),
367
+ self._snapshot(row[5]),
368
+ )
369
+ for row in rows
370
+ )
371
+
372
+ async def prune_send_history(
373
+ self,
374
+ *,
375
+ context: AccountContext,
376
+ identifiers: tuple[str, ...],
377
+ allow_accepted: bool = False,
378
+ ) -> int:
379
+ """Explicit atomic retention. Uncertain/claimed/live pending sends stay.
380
+
381
+ Removing ACCEPTED records requires explicit duplicate-risk acceptance:
382
+ identical payloads can subsequently receive a new confirmation.
383
+ """
384
+ if (
385
+ not isinstance(context, AccountContext)
386
+ or type(identifiers) is not tuple
387
+ or not 1 <= len(identifiers) <= self._limits.send_records
388
+ or type(allow_accepted) is not bool
389
+ ):
390
+ raise LibrusError(ErrorKind.INVALID_INPUT)
391
+ for identifier in identifiers:
392
+ _valid_digest(identifier)
393
+ if len(set(identifiers)) != len(identifiers):
394
+ raise LibrusError(ErrorKind.INVALID_INPUT)
395
+ key = self.context_identifier(context.identifier)
396
+ return await self._io(
397
+ lambda: self._prune_sends(key, identifiers, allow_accepted)
398
+ )
399
+
400
+ def _prune_sends(
401
+ self, context: str, identifiers: tuple[str, ...], allow_accepted: bool
402
+ ) -> int:
403
+ with self._connection() as connection:
404
+ rows = {row[0]: row for row in self._rows(connection) if row[1] == context}
405
+ allowed = {"invalidated", "not_dispatched", "rejected"}
406
+ if allow_accepted:
407
+ allowed.add("accepted")
408
+ now = _now()
409
+ for identifier in identifiers:
410
+ row = rows.get(identifier)
411
+ if row is None or not (
412
+ row[5] in allowed or (row[5] == "pending" and row[4] <= now)
413
+ ):
414
+ raise LibrusError(ErrorKind.INVALID_INPUT)
415
+ connection.executemany(
416
+ "DELETE FROM send_attempts WHERE token_hash=? AND context=?",
417
+ [(identifier, context) for identifier in identifiers],
418
+ )
419
+ return len(identifiers)
File without changes