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,400 @@
1
+ """Ordinary agenda/homework parsing; no script execution or read-once routes."""
2
+
3
+ import calendar
4
+ import re
5
+ from datetime import date, datetime, time
6
+ from typing import Literal
7
+
8
+ from lxml import html
9
+
10
+ from librus_python_api import markup
11
+ from librus_python_api.config import (
12
+ AGENDA_DESCRIPTION_LABEL,
13
+ AGENDA_DETAIL_PATH_PREFIX,
14
+ AGENDA_TOOLTIP_LABELS,
15
+ HOMEWORK_COLUMNS,
16
+ HOMEWORK_DETAIL_PATH_PREFIX,
17
+ HOMEWORK_DONE_PATTERN,
18
+ HOMEWORK_MARK_DONE_HANDLER,
19
+ HOMEWORK_MAX_COLUMNS,
20
+ SCHOOL_MAX_CONTENT_LENGTH,
21
+ SCHOOL_MAX_DETAIL_FIELDS,
22
+ SCHOOL_MAX_FIELD_LENGTH,
23
+ SCHOOL_MAX_ITEMS,
24
+ SCHOOL_MAX_TOOLTIP_LENGTH,
25
+ SCHOOL_MAX_TOOLTIP_LINES,
26
+ SCHOOL_MAX_TOTAL_TEXT_LENGTH,
27
+ WEEKDAY_LABELS,
28
+ agenda_form,
29
+ )
30
+ from librus_python_api.exceptions import ErrorKind, LibrusError
31
+ from librus_python_api.models import (
32
+ AgendaDay,
33
+ AgendaEvent,
34
+ HomeworkItem,
35
+ SchoolReference,
36
+ )
37
+ from librus_python_api.parsers import page_notices, parse_html_document, parse_page
38
+
39
+
40
+ def school_reference(
41
+ nodes: list[html.HtmlElement], kind: Literal["agenda", "homework"], account: str
42
+ ) -> SchoolReference | None:
43
+ """Find the single detail link.
44
+
45
+ Agenda cells must not carry any other handler. Homework rows also carry a
46
+ mark-done button; the library never follows handlers, so it is inert here.
47
+ """
48
+ prefix = (
49
+ AGENDA_DETAIL_PATH_PREFIX if kind == "agenda" else HOMEWORK_DETAIL_PATH_PREFIX
50
+ )
51
+ found: set[str] = set()
52
+ for node in nodes:
53
+ if not isinstance(node.tag, str):
54
+ continue
55
+ action = node.get("onclick", "")
56
+ if len(action) > 4096:
57
+ raise LibrusError(ErrorKind.LIMIT)
58
+ if not action:
59
+ continue
60
+ if kind == "homework" and prefix not in action:
61
+ # Only the observed mark-done button is inert; anything else is new.
62
+ if re.fullmatch(HOMEWORK_MARK_DONE_HANDLER, action):
63
+ continue
64
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
65
+ paths = re.findall(r"['\"](" + re.escape(prefix) + r"[0-9]{1,64})['\"]", action)
66
+ if len(paths) != 1:
67
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
68
+ found.add(paths[0].removeprefix(prefix))
69
+ if len(found) > 1:
70
+ raise LibrusError(ErrorKind.PARSE)
71
+ return SchoolReference(kind, next(iter(found)), account) if found else None
72
+
73
+
74
+ def _metadata(
75
+ cell: html.HtmlElement,
76
+ ) -> tuple[str, tuple[tuple[str, str], ...], tuple[str, ...]]:
77
+ raw = cell.get("title", "")
78
+ if len(raw) > SCHOOL_MAX_TOOLTIP_LENGTH:
79
+ raise LibrusError(ErrorKind.LIMIT)
80
+ parts = re.split(r"<br\s*/?>|\n", raw, flags=re.I)
81
+ if len(parts) > SCHOOL_MAX_TOOLTIP_LINES:
82
+ raise LibrusError(ErrorKind.LIMIT)
83
+ fields: dict[str, str] = {}
84
+ notes = []
85
+ describing = False
86
+ for part in parts:
87
+ if not part.strip():
88
+ continue
89
+ rendered = (
90
+ markup.text(parse_html_document(part.encode()), SCHOOL_MAX_FIELD_LENGTH)
91
+ if "<" in part
92
+ else " ".join(part.split())
93
+ )
94
+ if len(rendered) > SCHOOL_MAX_FIELD_LENGTH:
95
+ raise LibrusError(ErrorKind.LIMIT)
96
+ label, separator, value = rendered.partition(":")
97
+ label, value = label.strip(), value.strip()
98
+ if describing and not (separator and label in AGENDA_TOOLTIP_LABELS):
99
+ fields[AGENDA_DESCRIPTION_LABEL] += "\n" + rendered
100
+ continue
101
+ describing = separator != "" and label == AGENDA_DESCRIPTION_LABEL
102
+ if not separator:
103
+ notes.append(rendered)
104
+ elif not label or label in fields:
105
+ raise LibrusError(ErrorKind.PARSE)
106
+ else:
107
+ fields[label] = value
108
+ text = (
109
+ markup.text(
110
+ parse_html_document(raw.encode()), SCHOOL_MAX_TOOLTIP_LENGTH, multiline=True
111
+ )
112
+ if raw
113
+ else ""
114
+ )
115
+ return text, tuple(fields.items()), tuple(notes)
116
+
117
+
118
+ def _event(cell: html.HtmlElement, day: date, account: str) -> AgendaEvent:
119
+ spans = list(cell.iter("span"))
120
+ if (
121
+ len(spans) > 1
122
+ or next(cell.iter("table"), None) is not None
123
+ or cell.get("rowspan", "1") != "1"
124
+ or cell.get("colspan", "1") != "1"
125
+ ):
126
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
127
+ text = markup.text(cell, SCHOOL_MAX_CONTENT_LENGTH, multiline=True)
128
+ if not text:
129
+ raise LibrusError(ErrorKind.PARSE)
130
+ subject = markup.text(spans[0], SCHOOL_MAX_FIELD_LENGTH) if spans else None
131
+ lines = text.splitlines()
132
+ header = lines[0].removesuffix(subject).rstrip(" ,") if subject else lines[0]
133
+ number = re.fullmatch(
134
+ r"(?:Lekcja(?: nr)?|Nr(?: lekcji)?|Numer(?: lekcji)?):\s*([0-9]{1,2})[,]?",
135
+ header,
136
+ re.I,
137
+ )
138
+ clock = re.fullmatch(r"(?:Godzina:\s*)?([0-9]{2}:[0-9]{2})[,]?", header, re.I)
139
+ title = lines[0]
140
+ if subject and subject in lines:
141
+ index = lines.index(subject)
142
+ title = lines[index + 1] if index + 1 < len(lines) else subject
143
+ elif len(lines) > 1 and (number or clock):
144
+ title = lines[1]
145
+ if len(title) > SCHOOL_MAX_FIELD_LENGTH:
146
+ raise LibrusError(ErrorKind.LIMIT)
147
+ metadata_text, metadata, notes = _metadata(cell)
148
+ return AgendaEvent(
149
+ day,
150
+ title,
151
+ subject or None,
152
+ text,
153
+ int(number[1]) if number else None,
154
+ _clock(clock[1]) if clock else None,
155
+ metadata_text,
156
+ metadata,
157
+ notes,
158
+ school_reference(list(cell.iter()), "agenda", account),
159
+ )
160
+
161
+
162
+ def parse_agenda(
163
+ body: bytes, year: int, month: int, account: str
164
+ ) -> tuple[AgendaDay, ...]:
165
+ agenda_form(year, month)
166
+ document = parse_page(body)
167
+ nodes = document.xpath(
168
+ '//div[contains(concat(" ",normalize-space(@class)," ")," kalendarz-dzien ")]'
169
+ )
170
+ expected = calendar.monthrange(year, month)[1]
171
+ if len(nodes) != expected:
172
+ raise LibrusError(ErrorKind.PARSE)
173
+ days: dict[int, AgendaDay] = {}
174
+ count = total = 0
175
+ for node in nodes:
176
+ markers = node.xpath(
177
+ './/div[contains(concat(" ",normalize-space(@class)," "),'
178
+ '" kalendarz-numer-dnia ")]'
179
+ )
180
+ if len(markers) != 1 or not re.fullmatch(
181
+ r"[0-9]{1,2}", markup.text(markers[0], 2)
182
+ ):
183
+ raise LibrusError(ErrorKind.PARSE)
184
+ number = int(markup.text(markers[0], 2))
185
+ if not 1 <= number <= expected or number in days:
186
+ raise LibrusError(ErrorKind.PARSE)
187
+ events = []
188
+ for row in node.iter("tr"):
189
+ cells = markup.cells(row)
190
+ if len(cells) != 1 or cells[0].tag != "td":
191
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
192
+ item = _event(cells[0], date(year, month, number), account)
193
+ count += 1
194
+ total += (
195
+ len(item.text)
196
+ + len(item.metadata_text)
197
+ + sum(len(k) + len(v) for k, v in item.metadata)
198
+ + sum(map(len, item.metadata_notes))
199
+ )
200
+ if count > SCHOOL_MAX_ITEMS or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH:
201
+ raise LibrusError(ErrorKind.LIMIT)
202
+ events.append(item)
203
+ days[number] = AgendaDay(date(year, month, number), tuple(events))
204
+ return tuple(days[number] for number in range(1, expected + 1))
205
+
206
+
207
+ def _clock(value: str) -> time:
208
+ if not re.fullmatch(r"[0-9]{2}:[0-9]{2}", value):
209
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
210
+ try:
211
+ return time.fromisoformat(value)
212
+ except ValueError:
213
+ pass
214
+ raise LibrusError(ErrorKind.PARSE)
215
+
216
+
217
+ def _iso_date(value: str) -> date:
218
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
219
+ raise LibrusError(ErrorKind.PARSE)
220
+ try:
221
+ return date.fromisoformat(value)
222
+ except ValueError:
223
+ pass
224
+ raise LibrusError(ErrorKind.PARSE)
225
+
226
+
227
+ def _dated(value: str, weekday: str) -> date:
228
+ day = _iso_date(value)
229
+ # The adjacent weekday cell guards against silent column drift.
230
+ if weekday != WEEKDAY_LABELS[day.weekday()]:
231
+ raise LibrusError(ErrorKind.PARSE)
232
+ return day
233
+
234
+
235
+ def _homework_columns(table: html.HtmlElement) -> list[str]:
236
+ """Expand the header into one field name per body cell."""
237
+ headers = [
238
+ row
239
+ for row in markup.rows(table)
240
+ if next(row.iterancestors("thead"), None) is not None
241
+ ]
242
+ if len(headers) != 1:
243
+ raise LibrusError(ErrorKind.PARSE)
244
+ columns: list[str] = []
245
+ for cell in markup.cells(headers[0]):
246
+ field = HOMEWORK_COLUMNS.get(markup.text(cell, SCHOOL_MAX_FIELD_LENGTH))
247
+ if field is None:
248
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
249
+ span = 2 if field in ("assigned", "due") else 1
250
+ if cell.get("colspan", "1") != str(span) or field in columns:
251
+ raise LibrusError(ErrorKind.PARSE)
252
+ columns.extend([field] if span == 1 else [field, field + "_weekday"])
253
+ required = {"subject", "teacher", "topic", "category", "assigned", "due"}
254
+ if not required <= set(columns) or len(columns) > HOMEWORK_MAX_COLUMNS:
255
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
256
+ return columns
257
+
258
+
259
+ def _marked_done_at(options: html.HtmlElement | None) -> datetime | None:
260
+ if options is None:
261
+ return None
262
+ found = []
263
+ for image in options.iter("img"):
264
+ title = image.get("title", "")
265
+ if title.startswith("Zadanie oznaczono jako wykonane"):
266
+ match = re.fullmatch(HOMEWORK_DONE_PATTERN, title)
267
+ if match is None:
268
+ raise LibrusError(ErrorKind.PARSE)
269
+ found.append(datetime.combine(_iso_date(match[1]), _clock(match[2])))
270
+ if len(found) > 1:
271
+ raise LibrusError(ErrorKind.PARSE)
272
+ return found[0] if found else None
273
+
274
+
275
+ def _homework_row(
276
+ row: html.HtmlElement, columns: list[str], account: str
277
+ ) -> HomeworkItem:
278
+ cells = markup.cells(row)
279
+ if len(cells) != len(columns) or any(
280
+ c.tag != "td" or c.get("colspan", "1") != "1" or c.get("rowspan", "1") != "1"
281
+ for c in cells
282
+ ):
283
+ raise LibrusError(ErrorKind.PARSE)
284
+ by_field = dict(zip(columns, cells, strict=True))
285
+ values = {
286
+ field: markup.text(cell, SCHOOL_MAX_FIELD_LENGTH)
287
+ for field, cell in by_field.items()
288
+ if field != "options"
289
+ }
290
+ return HomeworkItem(
291
+ values["subject"],
292
+ values["teacher"],
293
+ values["topic"],
294
+ values["category"],
295
+ _dated(values["assigned"], values["assigned_weekday"]),
296
+ _dated(values["due"], values["due_weekday"]),
297
+ values.get("submission_status"),
298
+ _marked_done_at(by_field.get("options")),
299
+ school_reference(list(row.iter()), "homework", account),
300
+ )
301
+
302
+
303
+ def parse_homework(body: bytes, account: str) -> tuple[HomeworkItem, ...]:
304
+ document = parse_page(body)
305
+ tables = [
306
+ t
307
+ for t in document.iter("table")
308
+ if {"decorated", "myHomeworkTable"} <= set(t.get("class", "").split())
309
+ ]
310
+ markers = document.xpath(
311
+ '//p[contains(concat(" ",normalize-space(@class)," ")," msgEmptyTable ")]'
312
+ )
313
+ if len(tables) > 1 or len(markers) > 1 or (tables and markers):
314
+ raise LibrusError(ErrorKind.PARSE)
315
+ if not tables:
316
+ # An unrecognized notice next to an empty marker is not a valid empty list.
317
+ if len(markers) != 1 or page_notices(document):
318
+ raise LibrusError(ErrorKind.PARSE)
319
+ return ()
320
+ table = tables[0]
321
+ if len(list(table.iter("table"))) != 1:
322
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
323
+ columns = _homework_columns(table)
324
+ items: list[HomeworkItem] = []
325
+ total = 0
326
+ for row in markup.rows(table):
327
+ if {"line0", "line1"}.intersection(row.get("class", "").split()):
328
+ item = _homework_row(row, columns, account)
329
+ total += sum(
330
+ len(markup.text(c, SCHOOL_MAX_FIELD_LENGTH)) for c in markup.cells(row)
331
+ )
332
+ if len(items) >= SCHOOL_MAX_ITEMS or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH:
333
+ raise LibrusError(ErrorKind.LIMIT)
334
+ items.append(item)
335
+ elif next(row.iterancestors("thead"), None) is None and markup.text(
336
+ row, SCHOOL_MAX_FIELD_LENGTH
337
+ ):
338
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
339
+ if not items:
340
+ raise LibrusError(ErrorKind.PARSE)
341
+ return tuple(items)
342
+
343
+
344
+ def parse_school_detail(
345
+ body: bytes,
346
+ ) -> tuple[str | None, tuple[tuple[str, str], ...], tuple[str, ...]]:
347
+ document = parse_page(body)
348
+ containers = document.xpath(
349
+ '//div[contains(concat(" ",normalize-space(@class)," "),'
350
+ '" container-background ")]'
351
+ )
352
+ if len(containers) != 1:
353
+ raise LibrusError(ErrorKind.PARSE)
354
+ tables = list(containers[0].iter("table"))
355
+ if len(tables) != 1:
356
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
357
+ title = None
358
+ fields: dict[str, str] = {}
359
+ labels: set[str] = set()
360
+ notes = []
361
+ total = 0
362
+ for row in markup.rows(tables[0]):
363
+ cells = markup.cells(row)
364
+ if (
365
+ len(cells) == 1
366
+ and cells[0].tag == "td"
367
+ and cells[0].get("colspan", "1") == "2"
368
+ ):
369
+ value = markup.text(cells[0], SCHOOL_MAX_CONTENT_LENGTH, multiline=True)
370
+ if next(row.iterancestors("thead"), None) is not None:
371
+ if title is not None:
372
+ raise LibrusError(ErrorKind.PARSE)
373
+ title = value or None
374
+ elif value:
375
+ notes.append(value)
376
+ total += len(value)
377
+ elif len(cells) == 2 and cells[0].tag in {"th", "td"} and cells[1].tag == "td":
378
+ if any(
379
+ c.get("colspan", "1") != "1" or c.get("rowspan", "1") != "1"
380
+ for c in cells
381
+ ):
382
+ raise LibrusError(ErrorKind.PARSE)
383
+ label = markup.text(cells[0], SCHOOL_MAX_FIELD_LENGTH)
384
+ canonical = label.rstrip(":").strip().casefold()
385
+ if not canonical or canonical in labels:
386
+ raise LibrusError(ErrorKind.PARSE)
387
+ labels.add(canonical)
388
+ value = markup.text(cells[1], SCHOOL_MAX_CONTENT_LENGTH, multiline=True)
389
+ fields[label] = value
390
+ total += len(label) + len(value)
391
+ elif markup.text(row, SCHOOL_MAX_FIELD_LENGTH):
392
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
393
+ if (
394
+ len(fields) + len(notes) > SCHOOL_MAX_DETAIL_FIELDS
395
+ or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH
396
+ ):
397
+ raise LibrusError(ErrorKind.LIMIT)
398
+ if not fields:
399
+ raise LibrusError(ErrorKind.PARSE)
400
+ return title, tuple(fields.items()), tuple(notes)
@@ -0,0 +1,125 @@
1
+ """Single-use write attempts and independently authored acknowledgement parsing."""
2
+
3
+ import asyncio
4
+ from dataclasses import replace
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from librus_python_api.budget import RequestBudget
8
+ from librus_python_api.config import (
9
+ MESSAGE_INFORMATION_NOTICES,
10
+ SEND_ACCEPTED_TEXT,
11
+ SEND_REJECTED_TEXT,
12
+ )
13
+ from librus_python_api.exceptions import ErrorKind, LibrusError
14
+ from librus_python_api.markup import text
15
+ from librus_python_api.models import (
16
+ AccountContext,
17
+ Identity,
18
+ MessagingBackend,
19
+ ModernSendSubmission,
20
+ Observation,
21
+ SendResult,
22
+ SendStatus,
23
+ SendSubmission,
24
+ )
25
+ from librus_python_api.parsers import page_notices, parse_page
26
+
27
+ if TYPE_CHECKING:
28
+ from librus_python_api.service import AccountClient
29
+
30
+
31
+ def parse_send_acknowledgement(body: bytes) -> SendStatus:
32
+ """Only one designated, exact result paragraph can establish acceptance."""
33
+ document = parse_page(body)
34
+ # The legacy-module banner is shown on every legacy messages page, including
35
+ # the send result; any other notice may replace the acknowledgement.
36
+ if any(n not in MESSAGE_INFORMATION_NOTICES for n in page_notices(document)):
37
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
38
+ if document.xpath(
39
+ '//*[contains(concat(" ",normalize-space(@class)," "),'
40
+ '" container-message-content ")]'
41
+ ):
42
+ # A returned message body can quote the exact acknowledgement markup.
43
+ # It is not a send acknowledgement, even inside a matching status div.
44
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
45
+ results = document.xpath(
46
+ '//div[contains(concat(" ",normalize-space(@class)," "),'
47
+ '" container-background ")]/p'
48
+ )
49
+ if len(results) != 1:
50
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
51
+ result = text(results[0], 4096)
52
+ if result == SEND_ACCEPTED_TEXT:
53
+ return SendStatus.ACCEPTED
54
+ if result == SEND_REJECTED_TEXT:
55
+ return SendStatus.REJECTED
56
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
57
+
58
+
59
+ class SendAttempt:
60
+ """One process-local attempt, not consent, durable storage or idempotency."""
61
+
62
+ def __init__(
63
+ self, client: "AccountClient", submission: SendSubmission | ModernSendSubmission
64
+ ) -> None:
65
+ self._client, self._submission = client, submission
66
+ self._used = False
67
+ self._outcome = SendResult(
68
+ SendStatus.NOT_DISPATCHED,
69
+ backend=MessagingBackend.MODERN
70
+ if isinstance(submission, ModernSendSubmission)
71
+ else MessagingBackend.LEGACY,
72
+ )
73
+
74
+ @property
75
+ def submission(self) -> SendSubmission | ModernSendSubmission:
76
+ return self._submission
77
+
78
+ @property
79
+ def used(self) -> bool:
80
+ return self._used
81
+
82
+ @property
83
+ def account_context(self) -> AccountContext:
84
+ return self._client.context
85
+
86
+ @property
87
+ def outcome(self) -> SendResult:
88
+ return self._outcome
89
+
90
+ async def execute(self, *, budget: RequestBudget | None = None) -> SendResult:
91
+ if self._used:
92
+ raise LibrusError(ErrorKind.INVALID_INPUT)
93
+ # Consume synchronously before the first await, including pre-I/O failure.
94
+ self._used = True
95
+ try:
96
+ return await self._client._execute_send(self, budget)
97
+ except asyncio.CancelledError:
98
+ self._failure("cancelled")
99
+ raise
100
+ except LibrusError as error:
101
+ self._failure(error.kind)
102
+ if self.outcome.status is SendStatus.NOT_DISPATCHED:
103
+ raise
104
+ return self.outcome
105
+
106
+ def _dispatch(self, identity: Identity, observation: Observation) -> None:
107
+ if self._outcome.status is not SendStatus.NOT_DISPATCHED:
108
+ raise LibrusError(ErrorKind.INVALID_INPUT)
109
+ self._outcome = replace(
110
+ self._outcome,
111
+ status=SendStatus.UNKNOWN,
112
+ identity=identity,
113
+ observation=observation,
114
+ )
115
+
116
+ def _acknowledge(self, status: SendStatus) -> SendResult:
117
+ assert self._outcome.status is SendStatus.UNKNOWN
118
+ assert status in (SendStatus.ACCEPTED, SendStatus.REJECTED)
119
+ self._outcome = replace(self._outcome, status=status)
120
+ return self._outcome
121
+
122
+ def _failure(self, reason: ErrorKind | Literal["cancelled"]) -> None:
123
+ if self._outcome.status not in (SendStatus.ACCEPTED, SendStatus.REJECTED):
124
+ assert isinstance(reason, ErrorKind) or reason == "cancelled"
125
+ self._outcome = replace(self._outcome, reason=reason)