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,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,145 @@
1
+ """Neutral optional notification delivery and recovery values, never MCP DTOs."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+
6
+ from librus_python_api.models import (
7
+ AccountContext,
8
+ Announcement,
9
+ AttendanceRecord,
10
+ DescriptiveGrade,
11
+ HomeworkItem,
12
+ Identity,
13
+ MessageSummary,
14
+ MessagingBackend,
15
+ ModernMessageSummary,
16
+ NotificationCategory,
17
+ NumericGrade,
18
+ Observation,
19
+ RecentScheduleEvent,
20
+ )
21
+
22
+ type NotificationValue = (
23
+ NumericGrade
24
+ | DescriptiveGrade
25
+ | AttendanceRecord
26
+ | MessageSummary
27
+ | ModernMessageSummary
28
+ | Announcement
29
+ | HomeworkItem
30
+ | RecentScheduleEvent
31
+ )
32
+
33
+
34
+ class NotificationProvenance(StrEnum):
35
+ OBSERVED = "observed"
36
+ IMPORTED_HISTORY = "imported_history"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class NotificationItem:
41
+ category: NotificationCategory
42
+ identifier: str = field(repr=False)
43
+ value: NotificationValue = field(repr=False)
44
+ identity: Identity | None = field(repr=False)
45
+ observation: Observation | None = field(repr=False)
46
+ provenance: NotificationProvenance = NotificationProvenance.OBSERVED
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class NotificationBatch:
51
+ receipt: str = field(repr=False)
52
+ context: AccountContext = field(repr=False)
53
+ first_run: bool
54
+ categories: tuple[NotificationCategory, ...]
55
+ items: tuple[NotificationItem, ...] = field(repr=False)
56
+ has_more_schedule: bool
57
+ messages_backend: MessagingBackend = MessagingBackend.LEGACY
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class NotificationSeen:
62
+ category: NotificationCategory
63
+ identifiers: tuple[str, ...] = field(repr=False)
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class NotificationState:
68
+ initialized: bool
69
+ seen: tuple[NotificationSeen, ...] = field(repr=False)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class NotificationArchive:
74
+ """Versioned neutral bytes; includes private raw envelopes and pending delivery."""
75
+
76
+ version: int
77
+ context: AccountContext = field(repr=False)
78
+ payload: bytes = field(repr=False)
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class NotificationBaselineMapping:
83
+ """Caller-established mapping; None means explicitly unmappable."""
84
+
85
+ category: NotificationCategory
86
+ source_identifier: str = field(repr=False)
87
+ native_identifier: str | None = field(repr=False)
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class NotificationBootstrap:
92
+ """Neutral offline inputs, never a consumer's files or fabricated raw pages."""
93
+
94
+ context: AccountContext = field(repr=False)
95
+ mappings: tuple[NotificationBaselineMapping, ...] = field(repr=False)
96
+ pending_events: tuple[RecentScheduleEvent, ...] = field(repr=False)
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class NotificationBootstrapResult:
101
+ """Unmapped inputs prevent every write; successful history has a durable receipt."""
102
+
103
+ imported: bool
104
+ unmapped: tuple[NotificationBaselineMapping, ...] = field(repr=False)
105
+ pending: NotificationBatch | None = field(repr=False)
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class NotificationPendingDelivery:
110
+ """Compact delivery metadata; message/event content requires pending_batch."""
111
+
112
+ receipt: str = field(repr=False)
113
+ categories: tuple[NotificationCategory, ...]
114
+ messages_backend: MessagingBackend
115
+ first_run: bool
116
+ item_count: int
117
+ has_more_schedule: bool
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class NotificationRawCheckpointStatus:
122
+ """Retained native wire receipt, not a parsed event count or retry permission."""
123
+
124
+ identifier: str = field(repr=False)
125
+ cursor: int
126
+ total: int | None
127
+ wire_bytes: int
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class NotificationRecoveryStatus:
132
+ """Independent facts: an ordinary delivery may coexist with raw/uncertain work."""
133
+
134
+ context: AccountContext = field(repr=False)
135
+ initialized: bool
136
+ last_acknowledged_receipt: str | None = field(repr=False)
137
+ pending: NotificationPendingDelivery | None
138
+ raw: NotificationRawCheckpointStatus | None
139
+ uncertain_consume: bool
140
+
141
+ @property
142
+ def has_pending_work(self) -> bool:
143
+ return (
144
+ self.pending is not None or self.raw is not None or self.uncertain_consume
145
+ )