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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_codec.py +310 -0
- librus_python_api/_storage.py +367 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +156 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +67 -0
- librus_python_api/notification_persistence.py +1044 -0
- librus_python_api/notification_workflow.py +303 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +405 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
- librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
- librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0rc1.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)
|