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,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