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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_bootstrap.py +124 -0
- librus_python_api/_notification_codec.py +396 -0
- librus_python_api/_storage.py +403 -0
- librus_python_api/_windows_filesystem.py +390 -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 +242 -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 +145 -0
- librus_python_api/notification_persistence.py +1243 -0
- librus_python_api/notification_workflow.py +337 -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 +419 -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.0.dist-info/METADATA +262 -0
- librus_python_api-1.0.0.dist-info/RECORD +48 -0
- librus_python_api-1.0.0.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Bounded original serialization of typed native records and raw envelopes."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, fields
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from librus_python_api.config import SCHOOL_MAX_CONTENT_LENGTH, SCHOOL_MAX_FIELD_LENGTH
|
|
14
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
15
|
+
from librus_python_api.models import (
|
|
16
|
+
Announcement,
|
|
17
|
+
AttendanceRecord,
|
|
18
|
+
DescriptiveGrade,
|
|
19
|
+
HomeworkItem,
|
|
20
|
+
Identity,
|
|
21
|
+
MessageSummary,
|
|
22
|
+
MessagingBackend,
|
|
23
|
+
ModernMessageSummary,
|
|
24
|
+
NotificationCategory,
|
|
25
|
+
NumericGrade,
|
|
26
|
+
Observation,
|
|
27
|
+
RecentScheduleEvent,
|
|
28
|
+
ScheduleEventResponse,
|
|
29
|
+
ScheduleEventWire,
|
|
30
|
+
)
|
|
31
|
+
from librus_python_api.notification_models import (
|
|
32
|
+
NotificationBatch,
|
|
33
|
+
NotificationItem,
|
|
34
|
+
NotificationProvenance,
|
|
35
|
+
NotificationSeen,
|
|
36
|
+
NotificationState,
|
|
37
|
+
NotificationValue,
|
|
38
|
+
)
|
|
39
|
+
from librus_python_api.notifications import validate_response
|
|
40
|
+
|
|
41
|
+
HEX = re.compile(r"[0-9a-f]{64}")
|
|
42
|
+
WIRE_BYTES = 4 * 1024 * 1024
|
|
43
|
+
META_BYTES = 4 * 1024 * 1024
|
|
44
|
+
ARCHIVE_BYTES = 48 * 1024 * 1024
|
|
45
|
+
_VALUE_TYPES: dict[str, type[Any]] = {
|
|
46
|
+
cls.__name__: cls
|
|
47
|
+
for cls in (
|
|
48
|
+
NumericGrade,
|
|
49
|
+
DescriptiveGrade,
|
|
50
|
+
AttendanceRecord,
|
|
51
|
+
MessageSummary,
|
|
52
|
+
ModernMessageSummary,
|
|
53
|
+
Announcement,
|
|
54
|
+
HomeworkItem,
|
|
55
|
+
RecentScheduleEvent,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
_CATEGORY_TYPES: dict[NotificationCategory, tuple[type[Any], ...]] = {
|
|
59
|
+
NotificationCategory.GRADES: (NumericGrade, DescriptiveGrade),
|
|
60
|
+
NotificationCategory.ATTENDANCE: (AttendanceRecord,),
|
|
61
|
+
NotificationCategory.MESSAGES: (MessageSummary, ModernMessageSummary),
|
|
62
|
+
NotificationCategory.ANNOUNCEMENTS: (Announcement,),
|
|
63
|
+
NotificationCategory.HOMEWORK: (HomeworkItem,),
|
|
64
|
+
NotificationCategory.AGENDA: (RecentScheduleEvent,),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _dates(value: Any) -> str:
|
|
69
|
+
if isinstance(value, (date, datetime)):
|
|
70
|
+
return value.isoformat()
|
|
71
|
+
raise ValueError("Unsupported value")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dump(value: Any, maximum: int) -> bytes:
|
|
75
|
+
failed = False
|
|
76
|
+
payload = b""
|
|
77
|
+
try:
|
|
78
|
+
payload = json.dumps(
|
|
79
|
+
value,
|
|
80
|
+
default=_dates,
|
|
81
|
+
ensure_ascii=False,
|
|
82
|
+
sort_keys=True,
|
|
83
|
+
separators=(",", ":"),
|
|
84
|
+
allow_nan=False,
|
|
85
|
+
).encode("utf-8")
|
|
86
|
+
except (TypeError, ValueError, UnicodeError, RecursionError):
|
|
87
|
+
failed = True
|
|
88
|
+
if failed:
|
|
89
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
90
|
+
if len(payload) > maximum:
|
|
91
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _pairs(values: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
96
|
+
result: dict[str, Any] = {}
|
|
97
|
+
for key, value in values:
|
|
98
|
+
if key in result:
|
|
99
|
+
raise ValueError("Duplicate key")
|
|
100
|
+
result[key] = value
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load(payload: bytes, maximum: int) -> Any:
|
|
105
|
+
if type(payload) is not bytes or len(payload) > maximum:
|
|
106
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
107
|
+
failed = False
|
|
108
|
+
result: Any = None
|
|
109
|
+
try:
|
|
110
|
+
result = json.loads(payload, object_pairs_hook=_pairs)
|
|
111
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
112
|
+
failed = True
|
|
113
|
+
if failed:
|
|
114
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@lru_cache(maxsize=16)
|
|
119
|
+
def _adapter(cls: type[Any]) -> TypeAdapter[Any]:
|
|
120
|
+
return TypeAdapter(cls)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def typed[T](cls: type[T], value: Any, maximum: int = META_BYTES) -> T:
|
|
124
|
+
if type(value) is not dict or set(value) != {
|
|
125
|
+
f.name for f in fields(cast(Any, cls))
|
|
126
|
+
}:
|
|
127
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
128
|
+
adapter = _adapter(cast(Any, cls))
|
|
129
|
+
failed = False
|
|
130
|
+
result: T | None = None
|
|
131
|
+
try:
|
|
132
|
+
result = cast(T, adapter.validate_json(dump(value, maximum), strict=True))
|
|
133
|
+
except (ValidationError, ValueError, RecursionError):
|
|
134
|
+
failed = True
|
|
135
|
+
if failed:
|
|
136
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
137
|
+
assert result is not None
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def canonical_notification_id(
|
|
142
|
+
category: NotificationCategory, value: NotificationValue
|
|
143
|
+
) -> str:
|
|
144
|
+
if (
|
|
145
|
+
not isinstance(category, NotificationCategory)
|
|
146
|
+
or type(value) not in _CATEGORY_TYPES[category]
|
|
147
|
+
):
|
|
148
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
149
|
+
if isinstance(value, RecentScheduleEvent):
|
|
150
|
+
# Independently specified three-field canonical identity, including data.
|
|
151
|
+
canonical: Any = asdict(value)
|
|
152
|
+
elif isinstance(value, ModernMessageSummary):
|
|
153
|
+
canonical = [
|
|
154
|
+
2,
|
|
155
|
+
category.value,
|
|
156
|
+
MessagingBackend.MODERN.value,
|
|
157
|
+
value.reference.folder.value,
|
|
158
|
+
value.reference.identifier,
|
|
159
|
+
]
|
|
160
|
+
elif isinstance(value, MessageSummary):
|
|
161
|
+
canonical = [
|
|
162
|
+
1,
|
|
163
|
+
category.value,
|
|
164
|
+
value.reference.folder.value,
|
|
165
|
+
value.reference.identifier,
|
|
166
|
+
]
|
|
167
|
+
elif isinstance(value, Announcement):
|
|
168
|
+
canonical = [1, category.value, value.reference]
|
|
169
|
+
elif isinstance(value, HomeworkItem) and value.reference is not None:
|
|
170
|
+
canonical = [1, category.value, value.reference.identifier]
|
|
171
|
+
elif isinstance(value, AttendanceRecord) and value.detail_id is not None:
|
|
172
|
+
canonical = [1, category.value, value.detail_id]
|
|
173
|
+
else:
|
|
174
|
+
canonical = [1, category.value, type(value).__name__, asdict(value)]
|
|
175
|
+
return hashlib.sha256(dump(canonical, META_BYTES)).hexdigest()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def empty_state() -> NotificationState:
|
|
179
|
+
return NotificationState(
|
|
180
|
+
False,
|
|
181
|
+
tuple(NotificationSeen(category, ()) for category in NotificationCategory),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def state_bytes(state: NotificationState, per_category: int, maximum: int) -> bytes:
|
|
186
|
+
restored = typed(NotificationState, asdict(state))
|
|
187
|
+
if tuple(s.category for s in restored.seen) != tuple(NotificationCategory):
|
|
188
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
189
|
+
for seen in restored.seen:
|
|
190
|
+
if len(seen.identifiers) > per_category:
|
|
191
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
192
|
+
if len(set(seen.identifiers)) != len(seen.identifiers) or any(
|
|
193
|
+
HEX.fullmatch(v) is None for v in seen.identifiers
|
|
194
|
+
):
|
|
195
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
196
|
+
if not restored.initialized and any(seen.identifiers for seen in restored.seen):
|
|
197
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
198
|
+
return dump(asdict(restored), maximum)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def restore_state(payload: bytes, per_category: int, maximum: int) -> NotificationState:
|
|
202
|
+
state = typed(NotificationState, load(payload, maximum))
|
|
203
|
+
state_bytes(state, per_category, maximum)
|
|
204
|
+
return state
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class _EnvelopeMetadata:
|
|
209
|
+
version: int
|
|
210
|
+
identity: Identity
|
|
211
|
+
observation: Observation
|
|
212
|
+
content_type: str | None
|
|
213
|
+
content_codings: tuple[str, ...]
|
|
214
|
+
transfer_codings: tuple[str, ...]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def encode_envelope(response: ScheduleEventResponse, alias: str) -> tuple[bytes, bytes]:
|
|
218
|
+
validate_response(response, alias, WIRE_BYTES)
|
|
219
|
+
wire = response.wire
|
|
220
|
+
metadata = _EnvelopeMetadata(
|
|
221
|
+
response.version,
|
|
222
|
+
response.identity,
|
|
223
|
+
response.observation,
|
|
224
|
+
wire.content_type,
|
|
225
|
+
wire.content_codings,
|
|
226
|
+
wire.transfer_codings,
|
|
227
|
+
)
|
|
228
|
+
payload = dump(asdict(metadata), META_BYTES)
|
|
229
|
+
restore_envelope(payload, wire.body, alias)
|
|
230
|
+
return payload, wire.body
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def restore_envelope(metadata: bytes, body: bytes, alias: str) -> ScheduleEventResponse:
|
|
234
|
+
record = typed(_EnvelopeMetadata, load(metadata, META_BYTES))
|
|
235
|
+
response = ScheduleEventResponse(
|
|
236
|
+
record.version,
|
|
237
|
+
record.identity,
|
|
238
|
+
ScheduleEventWire(
|
|
239
|
+
body, record.content_type, record.content_codings, record.transfer_codings
|
|
240
|
+
),
|
|
241
|
+
record.observation,
|
|
242
|
+
)
|
|
243
|
+
validate_response(response, alias, WIRE_BYTES)
|
|
244
|
+
for observation in (record.observation, record.identity.observation):
|
|
245
|
+
if (
|
|
246
|
+
observation.observed_at.tzinfo is None
|
|
247
|
+
or not 0 <= observation.session_generation <= 10**12
|
|
248
|
+
):
|
|
249
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
250
|
+
return response
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def encode_batch(batch: NotificationBatch, maximum: int) -> bytes:
|
|
254
|
+
payload = asdict(batch)
|
|
255
|
+
payload["items"] = [
|
|
256
|
+
{"kind": type(item.value).__name__, "item": asdict(item)}
|
|
257
|
+
for item in batch.items
|
|
258
|
+
]
|
|
259
|
+
return dump(payload, maximum)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def restore_batch(payload: bytes, maximum: int) -> NotificationBatch:
|
|
263
|
+
record = load(payload, maximum)
|
|
264
|
+
if type(record) is dict and "messages_backend" not in record:
|
|
265
|
+
# Existing format-3 deliveries could only contain legacy summaries.
|
|
266
|
+
record["messages_backend"] = MessagingBackend.LEGACY.value
|
|
267
|
+
if (
|
|
268
|
+
type(record) is not dict
|
|
269
|
+
or set(record) != {f.name for f in fields(NotificationBatch)}
|
|
270
|
+
or type(record["items"]) is not list
|
|
271
|
+
):
|
|
272
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
273
|
+
if len(record["items"]) > 4096:
|
|
274
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
275
|
+
items = []
|
|
276
|
+
for entry in record["items"]:
|
|
277
|
+
if (
|
|
278
|
+
type(entry) is not dict
|
|
279
|
+
or set(entry) != {"kind", "item"}
|
|
280
|
+
or type(entry["kind"]) is not str
|
|
281
|
+
or entry["kind"] not in _VALUE_TYPES
|
|
282
|
+
):
|
|
283
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
284
|
+
item = entry["item"]
|
|
285
|
+
if type(item) is dict and "provenance" not in item:
|
|
286
|
+
item["provenance"] = NotificationProvenance.OBSERVED.value
|
|
287
|
+
if type(item) is not dict or set(item) != {
|
|
288
|
+
f.name for f in fields(NotificationItem)
|
|
289
|
+
}:
|
|
290
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
291
|
+
value = typed(_VALUE_TYPES[entry["kind"]], item["value"])
|
|
292
|
+
# Validate union contents separately, then reconstruct the original type.
|
|
293
|
+
parsed = typed(NotificationItem, item)
|
|
294
|
+
if type(value) not in _CATEGORY_TYPES[
|
|
295
|
+
parsed.category
|
|
296
|
+
] or parsed.identifier != canonical_notification_id(parsed.category, value):
|
|
297
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
298
|
+
items.append(
|
|
299
|
+
NotificationItem(
|
|
300
|
+
parsed.category,
|
|
301
|
+
parsed.identifier,
|
|
302
|
+
value,
|
|
303
|
+
parsed.identity,
|
|
304
|
+
parsed.observation,
|
|
305
|
+
parsed.provenance,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
record["items"] = []
|
|
309
|
+
batch = typed(NotificationBatch, record, maximum)
|
|
310
|
+
_validate_batch_items(batch, items)
|
|
311
|
+
return NotificationBatch(
|
|
312
|
+
batch.receipt,
|
|
313
|
+
batch.context,
|
|
314
|
+
batch.first_run,
|
|
315
|
+
batch.categories,
|
|
316
|
+
tuple(items),
|
|
317
|
+
batch.has_more_schedule,
|
|
318
|
+
batch.messages_backend,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _validate_batch_items(
|
|
323
|
+
batch: NotificationBatch, items: list[NotificationItem]
|
|
324
|
+
) -> None:
|
|
325
|
+
if (
|
|
326
|
+
HEX.fullmatch(batch.receipt) is None
|
|
327
|
+
or HEX.fullmatch(batch.context.identifier) is None
|
|
328
|
+
or not batch.categories
|
|
329
|
+
or len(set(batch.categories)) != len(batch.categories)
|
|
330
|
+
):
|
|
331
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
332
|
+
for item in items:
|
|
333
|
+
if item.category not in batch.categories:
|
|
334
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
335
|
+
if item.provenance is NotificationProvenance.IMPORTED_HISTORY:
|
|
336
|
+
validate_historical_item(item)
|
|
337
|
+
if (
|
|
338
|
+
batch.categories != (NotificationCategory.AGENDA,)
|
|
339
|
+
or batch.first_run
|
|
340
|
+
or batch.has_more_schedule
|
|
341
|
+
):
|
|
342
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
343
|
+
elif (
|
|
344
|
+
item.identity is None
|
|
345
|
+
or item.observation is None
|
|
346
|
+
or item.observation.account != batch.context.alias
|
|
347
|
+
or item.identity.observation.account != batch.context.alias
|
|
348
|
+
):
|
|
349
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
350
|
+
if isinstance(item.value, (MessageSummary, ModernMessageSummary)):
|
|
351
|
+
expected = (
|
|
352
|
+
MessagingBackend.MODERN
|
|
353
|
+
if isinstance(item.value, ModernMessageSummary)
|
|
354
|
+
else MessagingBackend.LEGACY
|
|
355
|
+
)
|
|
356
|
+
if (
|
|
357
|
+
batch.messages_backend is not expected
|
|
358
|
+
or item.value.reference.account != batch.context.alias
|
|
359
|
+
):
|
|
360
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
361
|
+
if any(
|
|
362
|
+
item.provenance is NotificationProvenance.IMPORTED_HISTORY for item in items
|
|
363
|
+
) and not is_historical_batch(batch, items):
|
|
364
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def is_historical_batch(
|
|
368
|
+
batch: NotificationBatch, items: list[NotificationItem] | None = None
|
|
369
|
+
) -> bool:
|
|
370
|
+
values = batch.items if items is None else items
|
|
371
|
+
return bool(values) and all(
|
|
372
|
+
item.provenance is NotificationProvenance.IMPORTED_HISTORY for item in values
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def validate_historical_item(item: NotificationItem) -> None:
|
|
377
|
+
if (
|
|
378
|
+
item.category is not NotificationCategory.AGENDA
|
|
379
|
+
or type(item.value) is not RecentScheduleEvent
|
|
380
|
+
or item.identity is not None
|
|
381
|
+
or item.observation is not None
|
|
382
|
+
or item.provenance is not NotificationProvenance.IMPORTED_HISTORY
|
|
383
|
+
):
|
|
384
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
385
|
+
value = item.value
|
|
386
|
+
for text, minimum, maximum in (
|
|
387
|
+
(value.date_added, 1, SCHOOL_MAX_FIELD_LENGTH),
|
|
388
|
+
(value.type, 1, SCHOOL_MAX_FIELD_LENGTH),
|
|
389
|
+
(value.data, 0, SCHOOL_MAX_CONTENT_LENGTH),
|
|
390
|
+
):
|
|
391
|
+
if type(text) is not str or not minimum <= len(text) <= maximum:
|
|
392
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
393
|
+
try:
|
|
394
|
+
text.encode("utf-8")
|
|
395
|
+
except UnicodeError:
|
|
396
|
+
raise LibrusError(ErrorKind.PARSE) from None
|