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.
Files changed (46) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_codec.py +310 -0
  3. librus_python_api/_storage.py +367 -0
  4. librus_python_api/announcements.py +159 -0
  5. librus_python_api/attachment_routes.py +114 -0
  6. librus_python_api/attachments.py +297 -0
  7. librus_python_api/attendance.py +182 -0
  8. librus_python_api/attendance_frequency.py +112 -0
  9. librus_python_api/budget.py +79 -0
  10. librus_python_api/checkpoint.py +61 -0
  11. librus_python_api/completed_lessons.py +216 -0
  12. librus_python_api/config.py +1410 -0
  13. librus_python_api/detail_fields.py +50 -0
  14. librus_python_api/diagnostics.py +25 -0
  15. librus_python_api/exceptions.py +172 -0
  16. librus_python_api/files.py +156 -0
  17. librus_python_api/grade_parsers.py +169 -0
  18. librus_python_api/grade_records.py +454 -0
  19. librus_python_api/homework_range.py +41 -0
  20. librus_python_api/lifecycle.py +24 -0
  21. librus_python_api/markup.py +147 -0
  22. librus_python_api/message_content.py +230 -0
  23. librus_python_api/messages.py +288 -0
  24. librus_python_api/models.py +1160 -0
  25. librus_python_api/modern_body.py +75 -0
  26. librus_python_api/modern_mailbox.py +459 -0
  27. librus_python_api/modern_messages.py +276 -0
  28. librus_python_api/notification_models.py +67 -0
  29. librus_python_api/notification_persistence.py +1044 -0
  30. librus_python_api/notification_workflow.py +303 -0
  31. librus_python_api/notifications.py +216 -0
  32. librus_python_api/parsers.py +232 -0
  33. librus_python_api/parsing.py +49 -0
  34. librus_python_api/persistence.py +405 -0
  35. librus_python_api/py.typed +0 -0
  36. librus_python_api/recipients.py +271 -0
  37. librus_python_api/scheduler.py +287 -0
  38. librus_python_api/school_reads.py +400 -0
  39. librus_python_api/sending.py +125 -0
  40. librus_python_api/service.py +2285 -0
  41. librus_python_api/timetable.py +261 -0
  42. librus_python_api/transport.py +956 -0
  43. librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
  44. librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
  45. librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
  46. librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,276 @@
1
+ """Original bounded modern directory parsers; no scripts or backend fallback."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from librus_python_api.config import (
8
+ MODERN_DIRECTORY_TYPE_PATTERN,
9
+ MODERN_MAX_CLASSES,
10
+ MODERN_MAX_LABEL,
11
+ MODERN_MAX_RECIPIENTS,
12
+ MODERN_MAX_TOTAL_TEXT,
13
+ MODERN_MAX_TYPES,
14
+ MODERN_RECIPIENT_OPERATIONS,
15
+ MODERN_REJECTION_CODES,
16
+ MODERN_SUPPORTED_ACCOUNT_GROUPS,
17
+ modern_directory_query,
18
+ )
19
+ from librus_python_api.exceptions import ErrorKind, LibrusError
20
+ from librus_python_api.models import (
21
+ ModernAccountData,
22
+ ModernRecipient,
23
+ ModernRecipientReference,
24
+ ModernRecipientType,
25
+ ModernRecipientTypeReference,
26
+ SendStatus,
27
+ )
28
+ from librus_python_api.parsers import decode_json
29
+
30
+
31
+ def _object(value: Any) -> dict[str, Any]:
32
+ if not isinstance(value, dict):
33
+ raise LibrusError(ErrorKind.PARSE)
34
+ return value
35
+
36
+
37
+ def _label(value: Any) -> str:
38
+ if (
39
+ type(value) is not str
40
+ or not value.strip()
41
+ or len(value) > MODERN_MAX_LABEL
42
+ or any(ord(c) < 32 for c in value)
43
+ ):
44
+ raise LibrusError(ErrorKind.PARSE)
45
+ return value
46
+
47
+
48
+ def _identifier(value: Any) -> str:
49
+ if type(value) is not str or re.fullmatch(r"[0-9]{1,64}", value) is None:
50
+ raise LibrusError(ErrorKind.PARSE)
51
+ return value
52
+
53
+
54
+ def parse_modern_identity(body: bytes) -> ModernAccountData:
55
+ data = _object(decode_json(body))
56
+ raw_identifier = data.get("accountId")
57
+ # Identity alone independently returned a JSON integer. Keep recipient IDs
58
+ # strict and reject bool/float/negative/oversized values before normalization.
59
+ if type(raw_identifier) is int and 0 <= raw_identifier < 10**64:
60
+ raw_identifier = str(raw_identifier)
61
+ identifier = _identifier(raw_identifier)
62
+ if (
63
+ data.get("originSystem") != "synergia"
64
+ or type(data.get("groupId")) is not str
65
+ or data.get("groupId") not in MODERN_SUPPORTED_ACCOUNT_GROUPS
66
+ ):
67
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
68
+ return ModernAccountData(
69
+ identifier,
70
+ data["groupId"],
71
+ _label(data.get("firstName")),
72
+ _label(data.get("lastName")),
73
+ )
74
+
75
+
76
+ def parse_modern_types(body: bytes, account: str) -> tuple[ModernRecipientType, ...]:
77
+ data = _object(_object(decode_json(body)).get("data"))
78
+ entries = data.get("list")
79
+ if not isinstance(entries, list) or len(entries) > MODERN_MAX_TYPES:
80
+ raise LibrusError(ErrorKind.PARSE)
81
+ result = []
82
+ seen = set()
83
+ for item in entries:
84
+ value = _object(item)
85
+ identifier = value.get("id")
86
+ if (
87
+ type(identifier) is not str
88
+ or (
89
+ identifier not in MODERN_RECIPIENT_OPERATIONS
90
+ and MODERN_DIRECTORY_TYPE_PATTERN.fullmatch(identifier) is None
91
+ )
92
+ or identifier in seen
93
+ ):
94
+ raise LibrusError(ErrorKind.PARSE)
95
+ seen.add(identifier)
96
+ result.append(
97
+ ModernRecipientType(
98
+ ModernRecipientTypeReference(identifier, account),
99
+ _label(value.get("name")),
100
+ identifier in MODERN_RECIPIENT_OPERATIONS,
101
+ )
102
+ )
103
+ if "defaultGroup" in data and (
104
+ type(data["defaultGroup"]) is not str or data["defaultGroup"] not in seen
105
+ ):
106
+ raise LibrusError(ErrorKind.PARSE)
107
+ return tuple(result)
108
+
109
+
110
+ def validate_modern_type(reference: ModernRecipientTypeReference, account: str) -> None:
111
+ if (
112
+ not isinstance(reference, ModernRecipientTypeReference)
113
+ or reference.account != account
114
+ ):
115
+ raise LibrusError(ErrorKind.INVALID_INPUT)
116
+ modern_directory_query(
117
+ reference.identifier, include_virtual=reference.include_virtual
118
+ )
119
+
120
+
121
+ def parse_modern_recipients(
122
+ body: bytes, reference: ModernRecipientTypeReference
123
+ ) -> tuple[ModernRecipient, ...]:
124
+ validate_modern_type(reference, reference.account)
125
+ data = _object(decode_json(body))
126
+ if MODERN_RECIPIENT_OPERATIONS[reference.identifier] != "modern_recipients":
127
+ return _employee_recipients(data, reference)
128
+ field = "classes" if "classes" in data else "data"
129
+ classes = data.get(field)
130
+ if (
131
+ set(data) != {field}
132
+ or not isinstance(classes, list)
133
+ or len(classes) > MODERN_MAX_CLASSES
134
+ ):
135
+ raise LibrusError(ErrorKind.PARSE)
136
+ result: list[ModernRecipient] = []
137
+ labels: set[str] = set()
138
+ seen: set[str] = set()
139
+ total = 0
140
+ for raw_class in classes:
141
+ klass = _object(raw_class)
142
+ label = _label(klass.get("label"))
143
+ groups = klass.get("receivers")
144
+ if (
145
+ set(klass) != {"label", "receivers"}
146
+ or label in labels
147
+ or not isinstance(groups, list)
148
+ or len(groups) != 1
149
+ or not isinstance(groups[0], list)
150
+ ):
151
+ raise LibrusError(ErrorKind.PARSE)
152
+ labels.add(label)
153
+ total += len(label)
154
+ if total > MODERN_MAX_TOTAL_TEXT:
155
+ raise LibrusError(ErrorKind.LIMIT)
156
+ if len(result) + len(groups[0]) > MODERN_MAX_RECIPIENTS:
157
+ raise LibrusError(ErrorKind.LIMIT)
158
+ for raw in groups[0]:
159
+ leaf = _object(raw)
160
+ if set(leaf) != {"accountId", "userId", "name"}:
161
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
162
+ identifier, user = (
163
+ _identifier(leaf.get("accountId")),
164
+ _identifier(leaf.get("userId")),
165
+ )
166
+ name = _label(leaf.get("name"))
167
+ if identifier in seen:
168
+ raise LibrusError(ErrorKind.PARSE)
169
+ seen.add(identifier)
170
+ total += len(name)
171
+ if total > MODERN_MAX_TOTAL_TEXT:
172
+ raise LibrusError(ErrorKind.LIMIT)
173
+ result.append(
174
+ ModernRecipient(
175
+ ModernRecipientReference(
176
+ identifier,
177
+ user,
178
+ reference.account,
179
+ reference.identifier,
180
+ label,
181
+ reference.include_virtual,
182
+ ),
183
+ name,
184
+ )
185
+ )
186
+ return tuple(result)
187
+
188
+
189
+ def _employee_recipients(
190
+ data: dict[str, Any], reference: ModernRecipientTypeReference
191
+ ) -> tuple[ModernRecipient, ...]:
192
+ entries = data.get("receivers")
193
+ if set(data) != {"receivers"} or not isinstance(entries, list):
194
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
195
+ if len(entries) > MODERN_MAX_RECIPIENTS:
196
+ raise LibrusError(ErrorKind.LIMIT)
197
+ result = []
198
+ seen = set()
199
+ total = 0
200
+ for raw in entries:
201
+ leaf = _object(raw)
202
+ if not {"accountId", "userId", "label"} <= set(leaf) or set(leaf) - {
203
+ "accountId",
204
+ "userId",
205
+ "label",
206
+ "availabilityStatus",
207
+ }:
208
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
209
+ identifier, user = _identifier(leaf["accountId"]), _identifier(leaf["userId"])
210
+ if identifier in seen:
211
+ raise LibrusError(ErrorKind.PARSE)
212
+ seen.add(identifier)
213
+ label = _label(leaf["label"])
214
+ # Availability is exposed as inert bounded JSON, not invented permission,
215
+ # work-hours or delivery semantics. It never changes the send payload.
216
+ availability = leaf.get("availabilityStatus")
217
+ try:
218
+ encoded = (
219
+ None
220
+ if availability is None
221
+ else json.dumps(
222
+ availability,
223
+ separators=(",", ":"),
224
+ ensure_ascii=True,
225
+ allow_nan=False,
226
+ )
227
+ )
228
+ except ValueError:
229
+ raise LibrusError(ErrorKind.PARSE) from None
230
+ if encoded is not None and len(encoded) > MODERN_MAX_LABEL:
231
+ raise LibrusError(ErrorKind.LIMIT)
232
+ total += len(label) + len(encoded or "")
233
+ if total > MODERN_MAX_TOTAL_TEXT:
234
+ raise LibrusError(ErrorKind.LIMIT)
235
+ result.append(
236
+ ModernRecipient(
237
+ ModernRecipientReference(
238
+ identifier, user, reference.account, reference.identifier, ""
239
+ ),
240
+ label,
241
+ encoded,
242
+ )
243
+ )
244
+ return tuple(result)
245
+
246
+
247
+ def parse_modern_send_response(body: bytes, status: int) -> SendStatus:
248
+ """Accept only the observed created/sent envelope, never HTTP success alone."""
249
+ data = _object(decode_json(body))
250
+ if status == 201 and set(data) == {"data"}:
251
+ receipt = data["data"]
252
+ if (
253
+ type(receipt) is dict
254
+ and set(receipt) == {"messageId", "status"}
255
+ and receipt["status"] == "sent"
256
+ and type(receipt["messageId"]) is int
257
+ and 0 < receipt["messageId"] < 10**64
258
+ ):
259
+ return SendStatus.ACCEPTED
260
+ if status in (400, 422) and set(data) <= {"code", "errors", "message"}:
261
+ codes = []
262
+ if "code" in data:
263
+ codes.append(data["code"])
264
+ errors = data.get("errors", [])
265
+ if not isinstance(errors, list) or len(errors) > 50:
266
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
267
+ for error in errors:
268
+ entry = _object(error)
269
+ if set(entry) - {"code", "message", "field"}:
270
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
271
+ codes.append(entry.get("code"))
272
+ if codes and all(
273
+ type(code) is str and code in MODERN_REJECTION_CODES for code in codes
274
+ ):
275
+ return SendStatus.REJECTED
276
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
@@ -0,0 +1,67 @@
1
+ """Neutral optional notification delivery and recovery values, never MCP DTOs."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from librus_python_api.models import (
6
+ AccountContext,
7
+ Announcement,
8
+ AttendanceRecord,
9
+ DescriptiveGrade,
10
+ HomeworkItem,
11
+ Identity,
12
+ MessageSummary,
13
+ NotificationCategory,
14
+ NumericGrade,
15
+ Observation,
16
+ RecentScheduleEvent,
17
+ )
18
+
19
+ type NotificationValue = (
20
+ NumericGrade
21
+ | DescriptiveGrade
22
+ | AttendanceRecord
23
+ | MessageSummary
24
+ | Announcement
25
+ | HomeworkItem
26
+ | RecentScheduleEvent
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class NotificationItem:
32
+ category: NotificationCategory
33
+ identifier: str = field(repr=False)
34
+ value: NotificationValue = field(repr=False)
35
+ identity: Identity = field(repr=False)
36
+ observation: Observation = field(repr=False)
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class NotificationBatch:
41
+ receipt: str = field(repr=False)
42
+ context: AccountContext = field(repr=False)
43
+ first_run: bool
44
+ categories: tuple[NotificationCategory, ...]
45
+ items: tuple[NotificationItem, ...] = field(repr=False)
46
+ has_more_schedule: bool
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class NotificationSeen:
51
+ category: NotificationCategory
52
+ identifiers: tuple[str, ...] = field(repr=False)
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class NotificationState:
57
+ initialized: bool
58
+ seen: tuple[NotificationSeen, ...] = field(repr=False)
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class NotificationArchive:
63
+ """Versioned neutral bytes; includes private raw envelopes and pending delivery."""
64
+
65
+ version: int
66
+ context: AccountContext = field(repr=False)
67
+ payload: bytes = field(repr=False)