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