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,303 @@
|
|
|
1
|
+
"""Optional native category orchestration with durable at-least-once replay."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
from librus_python_api._notification_codec import (
|
|
9
|
+
META_BYTES,
|
|
10
|
+
canonical_notification_id,
|
|
11
|
+
dump,
|
|
12
|
+
encode_batch,
|
|
13
|
+
)
|
|
14
|
+
from librus_python_api.budget import RequestBudget
|
|
15
|
+
from librus_python_api.checkpoint import validate_checkpoint
|
|
16
|
+
from librus_python_api.config import homework_form
|
|
17
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
18
|
+
from librus_python_api.models import (
|
|
19
|
+
AttendanceView,
|
|
20
|
+
GradeView,
|
|
21
|
+
MessageFolder,
|
|
22
|
+
NotificationCategory,
|
|
23
|
+
ScheduleEventResponse,
|
|
24
|
+
ScheduleEvents,
|
|
25
|
+
)
|
|
26
|
+
from librus_python_api.notification_models import (
|
|
27
|
+
NotificationBatch,
|
|
28
|
+
NotificationItem,
|
|
29
|
+
NotificationState,
|
|
30
|
+
NotificationValue,
|
|
31
|
+
)
|
|
32
|
+
from librus_python_api.notification_persistence import NotificationStore
|
|
33
|
+
from librus_python_api.service import AccountClient
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotificationWorkflow:
|
|
37
|
+
"""Explicit selected-category poll and acknowledgement, without MCP schemas.
|
|
38
|
+
|
|
39
|
+
A pending batch is returned without network until explicitly acknowledged.
|
|
40
|
+
Replay of raw schedule bytes requires no fresh consume consent. New read-once
|
|
41
|
+
retrieval requires allow_consume_events=True every time; no hidden retry.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, client: AccountClient, store: NotificationStore) -> None:
|
|
45
|
+
if not isinstance(client, AccountClient) or not isinstance(
|
|
46
|
+
store, NotificationStore
|
|
47
|
+
):
|
|
48
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
49
|
+
self.client, self.store = client, store
|
|
50
|
+
|
|
51
|
+
async def poll(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
categories: tuple[NotificationCategory, ...],
|
|
55
|
+
allow_consume_events: bool = False,
|
|
56
|
+
homework_window: tuple[date, date] | None = None,
|
|
57
|
+
checkpoint_timeout_seconds: float = 5.0,
|
|
58
|
+
budget: RequestBudget | None = None,
|
|
59
|
+
) -> NotificationBatch:
|
|
60
|
+
if (
|
|
61
|
+
type(categories) is not tuple
|
|
62
|
+
or not 1 <= len(categories) <= 6
|
|
63
|
+
or any(
|
|
64
|
+
not isinstance(category, NotificationCategory)
|
|
65
|
+
for category in categories
|
|
66
|
+
)
|
|
67
|
+
or len(set(categories)) != len(categories)
|
|
68
|
+
or type(allow_consume_events) is not bool
|
|
69
|
+
or (budget is not None and not isinstance(budget, RequestBudget))
|
|
70
|
+
):
|
|
71
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
72
|
+
validate_checkpoint(self.store._checkpoint, checkpoint_timeout_seconds)
|
|
73
|
+
if homework_window is None:
|
|
74
|
+
today = datetime.now(ZoneInfo("Europe/Warsaw")).date()
|
|
75
|
+
homework_window = (today - timedelta(days=7), today)
|
|
76
|
+
if type(homework_window) is not tuple or len(homework_window) != 2:
|
|
77
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
78
|
+
homework_form(*homework_window)
|
|
79
|
+
actual = budget if budget is not None else self.client._service._budget()
|
|
80
|
+
actual._bind_loop()
|
|
81
|
+
actual.remaining_seconds()
|
|
82
|
+
window = homework_window
|
|
83
|
+
return await self.store._transaction(
|
|
84
|
+
self.client.context,
|
|
85
|
+
lambda: self._poll(
|
|
86
|
+
categories,
|
|
87
|
+
allow_consume_events,
|
|
88
|
+
window,
|
|
89
|
+
checkpoint_timeout_seconds,
|
|
90
|
+
actual,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def acknowledge(self, receipt: str) -> None:
|
|
95
|
+
await self.store.acknowledge(receipt, context=self.client.context)
|
|
96
|
+
|
|
97
|
+
async def _poll(
|
|
98
|
+
self,
|
|
99
|
+
categories: tuple[NotificationCategory, ...],
|
|
100
|
+
consume: bool,
|
|
101
|
+
window: tuple[date, date],
|
|
102
|
+
seconds: float,
|
|
103
|
+
budget: RequestBudget,
|
|
104
|
+
) -> NotificationBatch:
|
|
105
|
+
context, store = self.client.context, self.store
|
|
106
|
+
pending, raw, uncertain = await store._io(lambda: store._pending(context))
|
|
107
|
+
if pending is not None:
|
|
108
|
+
if pending.categories != categories:
|
|
109
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
110
|
+
return pending
|
|
111
|
+
agenda = NotificationCategory.AGENDA in categories
|
|
112
|
+
if agenda and raw is None:
|
|
113
|
+
if uncertain:
|
|
114
|
+
raise LibrusError(ErrorKind.CHECKPOINT)
|
|
115
|
+
if not consume:
|
|
116
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
117
|
+
state = await store._io(lambda: store._read_state(context.identifier))
|
|
118
|
+
ordinary = await self._ordinary(categories, window, budget)
|
|
119
|
+
# Validate whole ordinary results and state capacity before consuming.
|
|
120
|
+
items = self._new_items(ordinary, state)
|
|
121
|
+
if len(items) > store.limits.batch_items:
|
|
122
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
123
|
+
for entry in state.seen:
|
|
124
|
+
if (
|
|
125
|
+
len(entry.identifiers)
|
|
126
|
+
+ sum(item.category == entry.category for item in items)
|
|
127
|
+
> store.limits.seen_ids_per_category
|
|
128
|
+
):
|
|
129
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
130
|
+
provisional = NotificationBatch(
|
|
131
|
+
"0" * 64, context, not state.initialized, categories, items, False
|
|
132
|
+
)
|
|
133
|
+
encode_batch(provisional, store.limits.batch_bytes)
|
|
134
|
+
events: ScheduleEvents | None = None
|
|
135
|
+
if agenda and raw is None:
|
|
136
|
+
reserve = min(
|
|
137
|
+
self.client._service._transport_limits.response_max_bytes,
|
|
138
|
+
budget.remaining_response_bytes,
|
|
139
|
+
)
|
|
140
|
+
await store._io(lambda: store._reserve(context.identifier, reserve))
|
|
141
|
+
|
|
142
|
+
async def persist(response: ScheduleEventResponse) -> None:
|
|
143
|
+
await store._io(
|
|
144
|
+
lambda: store._checkpoint(context, response), finishing=True
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
events = await self.client.consume_schedule_events(
|
|
148
|
+
checkpoint=persist,
|
|
149
|
+
allow_consume_events=True,
|
|
150
|
+
checkpoint_timeout_seconds=seconds,
|
|
151
|
+
budget=budget,
|
|
152
|
+
)
|
|
153
|
+
_, raw, uncertain = await store._io(lambda: store._pending(context))
|
|
154
|
+
assert raw is not None and not uncertain
|
|
155
|
+
raw_identifier: str | None = None
|
|
156
|
+
cursor: int | None = None
|
|
157
|
+
total: int | None = None
|
|
158
|
+
more = False
|
|
159
|
+
if agenda:
|
|
160
|
+
assert raw is not None
|
|
161
|
+
if events is None:
|
|
162
|
+
events = await self.client.decode_schedule_events(raw[1], budget=budget)
|
|
163
|
+
if raw[3] is not None and raw[3] != len(events.items):
|
|
164
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
165
|
+
schedule, cursor = self._slice_schedule(
|
|
166
|
+
events, raw[2], state, store.limits.batch_items - len(items)
|
|
167
|
+
)
|
|
168
|
+
items += schedule
|
|
169
|
+
raw_identifier, total = raw[0], len(events.items)
|
|
170
|
+
more = cursor < total
|
|
171
|
+
batch = NotificationBatch(
|
|
172
|
+
secrets.token_hex(32),
|
|
173
|
+
context,
|
|
174
|
+
not state.initialized,
|
|
175
|
+
categories,
|
|
176
|
+
items,
|
|
177
|
+
more,
|
|
178
|
+
)
|
|
179
|
+
await store._io(
|
|
180
|
+
lambda: store._stage(batch, raw_identifier, cursor, total), finishing=True
|
|
181
|
+
)
|
|
182
|
+
return batch
|
|
183
|
+
|
|
184
|
+
async def _ordinary(
|
|
185
|
+
self,
|
|
186
|
+
categories: tuple[NotificationCategory, ...],
|
|
187
|
+
window: tuple[date, date],
|
|
188
|
+
budget: RequestBudget,
|
|
189
|
+
) -> tuple[NotificationItem, ...]:
|
|
190
|
+
items = []
|
|
191
|
+
for category in categories:
|
|
192
|
+
values: tuple[NotificationValue, ...]
|
|
193
|
+
if category is NotificationCategory.AGENDA:
|
|
194
|
+
continue
|
|
195
|
+
if category is NotificationCategory.GRADES:
|
|
196
|
+
grades = await self.client.grades(
|
|
197
|
+
view=GradeView.LAST_LOGIN, budget=budget
|
|
198
|
+
)
|
|
199
|
+
values = (*grades.records.numeric, *grades.records.descriptive)
|
|
200
|
+
identity, observation = grades.identity, grades.observation
|
|
201
|
+
elif category is NotificationCategory.ATTENDANCE:
|
|
202
|
+
attendance = await self.client.attendance(
|
|
203
|
+
view=AttendanceView.LAST_LOGIN, budget=budget
|
|
204
|
+
)
|
|
205
|
+
values, identity, observation = (
|
|
206
|
+
attendance.items,
|
|
207
|
+
attendance.identity,
|
|
208
|
+
attendance.observation,
|
|
209
|
+
)
|
|
210
|
+
elif category is NotificationCategory.MESSAGES:
|
|
211
|
+
messages = await self.client.messages_page(
|
|
212
|
+
MessageFolder.RECEIVED, budget=budget
|
|
213
|
+
)
|
|
214
|
+
values, identity, observation = (
|
|
215
|
+
messages.items,
|
|
216
|
+
messages.identity,
|
|
217
|
+
messages.observation,
|
|
218
|
+
)
|
|
219
|
+
elif category is NotificationCategory.ANNOUNCEMENTS:
|
|
220
|
+
announcements = await self.client.announcements(budget=budget)
|
|
221
|
+
values, identity, observation = (
|
|
222
|
+
announcements.items,
|
|
223
|
+
announcements.identity,
|
|
224
|
+
announcements.observation,
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
assert category is NotificationCategory.HOMEWORK
|
|
228
|
+
homework = await self.client.homework(*window, budget=budget)
|
|
229
|
+
values, identity, observation = (
|
|
230
|
+
homework.items,
|
|
231
|
+
homework.identity,
|
|
232
|
+
homework.observation,
|
|
233
|
+
)
|
|
234
|
+
for value in values:
|
|
235
|
+
items.append(
|
|
236
|
+
NotificationItem(
|
|
237
|
+
category,
|
|
238
|
+
canonical_notification_id(category, value),
|
|
239
|
+
value,
|
|
240
|
+
identity,
|
|
241
|
+
observation,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
return tuple(items)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _new_items(
|
|
248
|
+
items: tuple[NotificationItem, ...], state: NotificationState
|
|
249
|
+
) -> tuple[NotificationItem, ...]:
|
|
250
|
+
seen = {entry.category: set(entry.identifiers) for entry in state.seen}
|
|
251
|
+
result = []
|
|
252
|
+
for item in items:
|
|
253
|
+
if item.identifier not in seen[item.category]:
|
|
254
|
+
result.append(item)
|
|
255
|
+
seen[item.category].add(item.identifier)
|
|
256
|
+
return tuple(result)
|
|
257
|
+
|
|
258
|
+
def _slice_schedule(
|
|
259
|
+
self,
|
|
260
|
+
events: ScheduleEvents,
|
|
261
|
+
cursor: int,
|
|
262
|
+
state: NotificationState,
|
|
263
|
+
available: int,
|
|
264
|
+
) -> tuple[tuple[NotificationItem, ...], int]:
|
|
265
|
+
if not 0 <= cursor <= len(events.items):
|
|
266
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
267
|
+
seen = {
|
|
268
|
+
identifier
|
|
269
|
+
for entry in state.seen
|
|
270
|
+
if entry.category is NotificationCategory.AGENDA
|
|
271
|
+
for identifier in entry.identifiers
|
|
272
|
+
}
|
|
273
|
+
items: list[NotificationItem] = []
|
|
274
|
+
used = 2
|
|
275
|
+
for index in range(cursor, len(events.items)):
|
|
276
|
+
value = events.items[index]
|
|
277
|
+
identifier = canonical_notification_id(NotificationCategory.AGENDA, value)
|
|
278
|
+
if identifier in seen:
|
|
279
|
+
cursor = index + 1
|
|
280
|
+
continue
|
|
281
|
+
if len(items) >= min(self.store.limits.replay_events, available):
|
|
282
|
+
break
|
|
283
|
+
encoded = dump(asdict(value), META_BYTES)
|
|
284
|
+
size = used + len(encoded) + bool(items)
|
|
285
|
+
if size > self.store.limits.replay_bytes:
|
|
286
|
+
if not items:
|
|
287
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
288
|
+
break
|
|
289
|
+
items.append(
|
|
290
|
+
NotificationItem(
|
|
291
|
+
NotificationCategory.AGENDA,
|
|
292
|
+
identifier,
|
|
293
|
+
value,
|
|
294
|
+
events.identity,
|
|
295
|
+
events.observation,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
seen.add(identifier)
|
|
299
|
+
used = size
|
|
300
|
+
cursor = index + 1
|
|
301
|
+
if not items and cursor < len(events.items):
|
|
302
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
303
|
+
return tuple(items), cursor
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Original bounded count/event parsers; no seen state, hashing or persistence."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import zlib
|
|
5
|
+
|
|
6
|
+
from librus_python_api import markup
|
|
7
|
+
from librus_python_api.config import (
|
|
8
|
+
NOTIFICATION_DESTINATIONS,
|
|
9
|
+
NOTIFICATION_MAX_COUNT,
|
|
10
|
+
NOTIFICATION_MAX_MENU_ITEMS,
|
|
11
|
+
SCHEDULE_EMPTY_LABEL,
|
|
12
|
+
SCHEDULE_EVENT_HEADERS,
|
|
13
|
+
SCHEDULE_MAX_EVENTS,
|
|
14
|
+
SCHEDULE_RESPONSE_VERSION,
|
|
15
|
+
SCHOOL_MAX_CONTENT_LENGTH,
|
|
16
|
+
SCHOOL_MAX_FIELD_LENGTH,
|
|
17
|
+
SCHOOL_MAX_TOTAL_TEXT_LENGTH,
|
|
18
|
+
)
|
|
19
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
20
|
+
from librus_python_api.models import (
|
|
21
|
+
Identity,
|
|
22
|
+
NotificationCategory,
|
|
23
|
+
NotificationCount,
|
|
24
|
+
Observation,
|
|
25
|
+
Person,
|
|
26
|
+
RecentScheduleEvent,
|
|
27
|
+
ScheduleEventResponse,
|
|
28
|
+
ScheduleEventWire,
|
|
29
|
+
)
|
|
30
|
+
from librus_python_api.parsers import parse_page
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_notification_counts(body: bytes) -> tuple[NotificationCount, ...]:
|
|
34
|
+
root = parse_page(body)
|
|
35
|
+
menus = root.xpath("//div[@id='graphic-menu']/ul")
|
|
36
|
+
if len(menus) != 1:
|
|
37
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
38
|
+
rows = menus[0].xpath("./li")
|
|
39
|
+
if len(rows) > NOTIFICATION_MAX_MENU_ITEMS:
|
|
40
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
41
|
+
items = []
|
|
42
|
+
seen = set()
|
|
43
|
+
for row in rows:
|
|
44
|
+
links = row.xpath("./a")
|
|
45
|
+
if len(links) > 4:
|
|
46
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
47
|
+
known = [
|
|
48
|
+
a
|
|
49
|
+
for a in links
|
|
50
|
+
if a.get("href") in NOTIFICATION_DESTINATIONS
|
|
51
|
+
and "counter" not in a.get("class", "").split()
|
|
52
|
+
]
|
|
53
|
+
if not known:
|
|
54
|
+
continue
|
|
55
|
+
if len(known) != 1:
|
|
56
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
57
|
+
category = NotificationCategory(NOTIFICATION_DESTINATIONS[known[0].get("href")])
|
|
58
|
+
if category in seen:
|
|
59
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
60
|
+
seen.add(category)
|
|
61
|
+
counters = [a for a in links if "counter" in a.get("class", "").split()]
|
|
62
|
+
marked = [
|
|
63
|
+
e
|
|
64
|
+
for e in row.iterdescendants()
|
|
65
|
+
if isinstance(e.tag, str) and "counter" in e.get("class", "").split()
|
|
66
|
+
]
|
|
67
|
+
if len(marked) != len(counters):
|
|
68
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
69
|
+
if len(counters) > 1:
|
|
70
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
71
|
+
count = 0
|
|
72
|
+
if counters:
|
|
73
|
+
value = markup.text(counters[0], 16)
|
|
74
|
+
if re.fullmatch(r"[0-9]{1,7}", value) is None:
|
|
75
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
76
|
+
count = int(value)
|
|
77
|
+
if count > NOTIFICATION_MAX_COUNT:
|
|
78
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
79
|
+
label = markup.text(known[0], SCHOOL_MAX_FIELD_LENGTH)
|
|
80
|
+
if not label:
|
|
81
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
82
|
+
items.append(NotificationCount(category, label, count))
|
|
83
|
+
if not items:
|
|
84
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
85
|
+
return tuple(items)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_schedule_events(body: bytes) -> tuple[RecentScheduleEvent, ...]:
|
|
89
|
+
root = parse_page(body)
|
|
90
|
+
containers = root.xpath(
|
|
91
|
+
"//div[contains(concat(' ',normalize-space(@class),' '),"
|
|
92
|
+
"' container-background ')]"
|
|
93
|
+
)
|
|
94
|
+
if len(containers) != 1:
|
|
95
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
96
|
+
tables = containers[0].xpath(".//table")
|
|
97
|
+
empty = containers[0].xpath(".//p[@class='msgEmptyTable']")
|
|
98
|
+
if tables and empty:
|
|
99
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
100
|
+
if len(tables) != 1:
|
|
101
|
+
empty = containers[0].xpath("./p[@class='msgEmptyTable']")
|
|
102
|
+
if (
|
|
103
|
+
not tables
|
|
104
|
+
and len(empty) == 1
|
|
105
|
+
and markup.text(empty[0]) == SCHEDULE_EMPTY_LABEL
|
|
106
|
+
):
|
|
107
|
+
return ()
|
|
108
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
109
|
+
rows = list(markup.rows(tables[0]))
|
|
110
|
+
if len(rows) > SCHEDULE_MAX_EVENTS + 1:
|
|
111
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
112
|
+
header = markup.cells(rows[0]) if rows else []
|
|
113
|
+
if (
|
|
114
|
+
not rows
|
|
115
|
+
or any(markup.colspan(c) != 1 for c in header)
|
|
116
|
+
or tuple(markup.text(c).casefold() for c in header) != SCHEDULE_EVENT_HEADERS
|
|
117
|
+
):
|
|
118
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
119
|
+
items = []
|
|
120
|
+
total = 0
|
|
121
|
+
for row in rows[1:]:
|
|
122
|
+
cells = markup.cells(row)
|
|
123
|
+
if (
|
|
124
|
+
len(cells) != 4
|
|
125
|
+
or any(c.tag != "td" or markup.colspan(c) != 1 for c in cells)
|
|
126
|
+
or markup.in_header(row)
|
|
127
|
+
):
|
|
128
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
129
|
+
# Display row numbers are not durable identities.
|
|
130
|
+
values = [markup.text(c, SCHOOL_MAX_FIELD_LENGTH) for c in cells[:3]]
|
|
131
|
+
if tuple(value.casefold() for value in values) == SCHEDULE_EVENT_HEADERS[:3]:
|
|
132
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
133
|
+
data = markup.text(cells[3], SCHOOL_MAX_CONTENT_LENGTH, multiline=True)
|
|
134
|
+
if not values[1] or not values[2]:
|
|
135
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
136
|
+
total += sum(map(len, values)) + len(data)
|
|
137
|
+
if total > SCHOOL_MAX_TOTAL_TEXT_LENGTH:
|
|
138
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
139
|
+
items.append(RecentScheduleEvent(values[1], values[2], data))
|
|
140
|
+
return tuple(items)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate_response(
|
|
144
|
+
response: ScheduleEventResponse, account: str, wire_limit: int
|
|
145
|
+
) -> None:
|
|
146
|
+
if (
|
|
147
|
+
not isinstance(response, ScheduleEventResponse)
|
|
148
|
+
or type(response.version) is not int
|
|
149
|
+
or response.version != SCHEDULE_RESPONSE_VERSION
|
|
150
|
+
):
|
|
151
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
152
|
+
wire = response.wire
|
|
153
|
+
if (
|
|
154
|
+
not isinstance(wire, ScheduleEventWire)
|
|
155
|
+
or type(wire.body) is not bytes
|
|
156
|
+
or len(wire.body) > wire_limit
|
|
157
|
+
):
|
|
158
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
159
|
+
if (
|
|
160
|
+
not isinstance(response.identity, Identity)
|
|
161
|
+
or not isinstance(response.identity.observation, Observation)
|
|
162
|
+
or not isinstance(response.identity.owner, Person)
|
|
163
|
+
or not isinstance(response.identity.student, Person)
|
|
164
|
+
or not isinstance(response.observation, Observation)
|
|
165
|
+
or response.observation.account != account
|
|
166
|
+
or response.identity.observation.account != account
|
|
167
|
+
or response.observation.source != "consume_schedule_events"
|
|
168
|
+
):
|
|
169
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
170
|
+
for values in (wire.content_codings, wire.transfer_codings):
|
|
171
|
+
if (
|
|
172
|
+
type(values) is not tuple
|
|
173
|
+
or len(values) > 128
|
|
174
|
+
or any(type(v) is not str or len(v) > 8192 for v in values)
|
|
175
|
+
):
|
|
176
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
177
|
+
if wire.content_type is not None and (
|
|
178
|
+
type(wire.content_type) is not str or len(wire.content_type) > 8192
|
|
179
|
+
):
|
|
180
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def decode_payload(body: bytes, wire: ScheduleEventWire, limit: int) -> bytes:
|
|
184
|
+
if (wire.content_type or "").split(";", 1)[0].strip().casefold() != "text/html":
|
|
185
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
186
|
+
if (
|
|
187
|
+
len(wire.content_codings) > 1
|
|
188
|
+
or any(
|
|
189
|
+
v.strip().casefold() not in ("identity", "gzip")
|
|
190
|
+
for v in wire.content_codings
|
|
191
|
+
)
|
|
192
|
+
or len(wire.transfer_codings) > 1
|
|
193
|
+
or any(v.strip().casefold() != "chunked" for v in wire.transfer_codings)
|
|
194
|
+
):
|
|
195
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
196
|
+
if (
|
|
197
|
+
not wire.content_codings
|
|
198
|
+
or wire.content_codings[0].strip().casefold() == "identity"
|
|
199
|
+
):
|
|
200
|
+
if len(body) > limit:
|
|
201
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
202
|
+
return body
|
|
203
|
+
failed = False
|
|
204
|
+
decoded = b""
|
|
205
|
+
try:
|
|
206
|
+
inflater = zlib.decompressobj(16 + zlib.MAX_WBITS)
|
|
207
|
+
decoded = inflater.decompress(body, limit + 1)
|
|
208
|
+
if len(decoded) > limit:
|
|
209
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
210
|
+
if not inflater.eof or inflater.unused_data:
|
|
211
|
+
failed = True
|
|
212
|
+
except zlib.error:
|
|
213
|
+
failed = True
|
|
214
|
+
if failed:
|
|
215
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
216
|
+
return decoded
|