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,297 @@
1
+ """Single-owner attachment streams with joined, service-owned resource lifetimes."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import AsyncIterator
6
+ from types import TracebackType
7
+ from typing import TYPE_CHECKING, Literal, Self
8
+
9
+ from librus_python_api.attachment_routes import (
10
+ modern_attachment_key,
11
+ signed_attachment_key,
12
+ validate_attachment_reference,
13
+ validate_max_bytes,
14
+ validate_modern_attachment_reference,
15
+ )
16
+ from librus_python_api.budget import RequestBudget
17
+ from librus_python_api.config import ATTACHMENT_MAX_BYTES
18
+ from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
19
+ from librus_python_api.lifecycle import join_owned
20
+ from librus_python_api.models import (
21
+ AttachmentHeaders,
22
+ AttachmentMetadata,
23
+ DiagnosticEvent,
24
+ MessageAttachmentReference,
25
+ ModernMessageAttachmentReference,
26
+ OperationName,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from librus_python_api.service import AccountClient
31
+
32
+
33
+ class AttachmentStream(AsyncIterator[bytes]):
34
+ """Use with async with. Uncached, non-reentrant and consumed by one task.
35
+
36
+ Construction has no I/O. The owned worker holds shared admission until EOF,
37
+ close or failure, even when the consumer is paused. No filesystem writes.
38
+ """
39
+
40
+ _operation: OperationName = "attachment_download"
41
+
42
+ def __init__(
43
+ self,
44
+ client: "AccountClient",
45
+ reference: MessageAttachmentReference | ModernMessageAttachmentReference,
46
+ *,
47
+ max_bytes: int = ATTACHMENT_MAX_BYTES,
48
+ budget: RequestBudget | None = None,
49
+ ) -> None:
50
+ self._validate_reference(reference, client._alias)
51
+ validate_max_bytes(max_bytes)
52
+ if budget is not None and not isinstance(budget, RequestBudget):
53
+ raise LibrusError(ErrorKind.INVALID_INPUT)
54
+ self._client, self._reference = client, reference
55
+ self._max_bytes, self._budget = max_bytes, budget
56
+ self._task: asyncio.Task[None] | None = None
57
+ self._metadata: AttachmentMetadata | None = None
58
+ self._ready, self._demand, self._delivery = (asyncio.Event() for _ in range(3))
59
+ self._chunk: bytes | None = None
60
+ self._error: ErrorKind | None = None
61
+ self._consumer: asyncio.Task[object] | None = None
62
+ self._entered = self._closed = self._complete = self._reading = (
63
+ self._admitted
64
+ ) = False
65
+
66
+ @property
67
+ def metadata(self) -> AttachmentMetadata:
68
+ if self._metadata is None:
69
+ raise LibrusError(ErrorKind.INVALID_INPUT)
70
+ return self._metadata
71
+
72
+ @staticmethod
73
+ def _validate_reference(
74
+ reference: MessageAttachmentReference | ModernMessageAttachmentReference,
75
+ account: str,
76
+ ) -> None:
77
+ if not isinstance(reference, MessageAttachmentReference):
78
+ raise LibrusError(ErrorKind.INVALID_INPUT)
79
+ validate_attachment_reference(reference, account)
80
+
81
+ @property
82
+ def complete(self) -> bool:
83
+ return self._complete
84
+
85
+ async def __aenter__(self) -> Self:
86
+ if self._entered or self._closed:
87
+ raise LibrusError(ErrorKind.INVALID_INPUT)
88
+ client, service = self._client, self._client._service
89
+ service._bind()
90
+ budget = self._budget or service._budget()
91
+ budget._bind_loop()
92
+ budget.remaining_seconds()
93
+ client._admit_operation()
94
+ self._entered = self._admitted = True
95
+ self._task = asyncio.create_task(self._run(budget))
96
+ service._tasks.add(self._task)
97
+ self._task.add_done_callback(self._finished)
98
+ try:
99
+ await self._ready.wait()
100
+ self._raise_failure()
101
+ return self
102
+ except BaseException:
103
+ await self.aclose()
104
+ raise
105
+
106
+ def __aiter__(self) -> Self:
107
+ return self
108
+
109
+ async def __anext__(self) -> bytes:
110
+ if not self._entered or self._closed:
111
+ raise LibrusError(ErrorKind.CLOSED)
112
+ consumer = asyncio.current_task()
113
+ if self._reading or (
114
+ self._consumer is not None and self._consumer is not consumer
115
+ ):
116
+ raise LibrusError(ErrorKind.INVALID_INPUT)
117
+ self._consumer = consumer
118
+ self._reading = True
119
+ try:
120
+ self._raise_failure()
121
+ if self._complete:
122
+ raise StopAsyncIteration
123
+ self._demand.set()
124
+ await self._delivery.wait()
125
+ self._delivery.clear()
126
+ self._raise_failure()
127
+ if self._chunk is None:
128
+ assert self._task is not None
129
+ await join_owned(self._task)
130
+ self._raise_failure()
131
+ assert self._complete
132
+ raise StopAsyncIteration
133
+ chunk, self._chunk = self._chunk, None
134
+ return chunk
135
+ except asyncio.CancelledError:
136
+ await self.aclose()
137
+ raise
138
+ finally:
139
+ self._reading = False
140
+
141
+ def _raise_failure(self) -> None:
142
+ if self._error is not None:
143
+ raise LibrusError(self._error)
144
+
145
+ def _opened(self, headers: AttachmentHeaders) -> None:
146
+ self._metadata = AttachmentMetadata(
147
+ self._client._session_identity(),
148
+ self._reference,
149
+ headers,
150
+ self._client._observation(self._operation),
151
+ )
152
+ self._ready.set()
153
+
154
+ async def _pull(self) -> None:
155
+ async with asyncio.timeout(
156
+ self._client._service._transport_limits.attachment_idle_timeout_seconds
157
+ ):
158
+ await self._demand.wait()
159
+ self._demand.clear()
160
+
161
+ def _deliver(self, chunk: bytes) -> None:
162
+ assert self._chunk is None
163
+ self._chunk = chunk
164
+ self._delivery.set()
165
+
166
+ async def _fetch(self, budget: RequestBudget, _: bool) -> None:
167
+ client = self._client
168
+ assert isinstance(self._reference, MessageAttachmentReference)
169
+ response = await client._transport.resolve_attachment(self._reference, budget)
170
+ if response.status == 302 and client._is_login_redirect(response):
171
+ raise LibrusError(ErrorKind.SESSION_EXPIRED)
172
+ if response.status != 302:
173
+ raise LibrusError(ErrorKind.PARSE)
174
+ key = signed_attachment_key(
175
+ response.headers.get("location", ""), client._service._connection
176
+ )
177
+ await client._transport.stream_download(
178
+ key, budget, self._max_bytes, self._opened, self._pull, self._deliver
179
+ )
180
+
181
+ async def _run(self, budget: RequestBudget) -> None:
182
+ client = self._client
183
+ started = time.monotonic()
184
+ outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
185
+ try:
186
+ async with asyncio.timeout(budget.remaining_seconds()):
187
+ async with client._lock:
188
+ client._check_cooldown("authentication")
189
+ client._check_cooldown(self._operation)
190
+ await client._authenticated(self._fetch, budget, False)
191
+ self._complete = True
192
+ except LibrusError as error:
193
+ self._error = outcome = error.kind
194
+ if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
195
+ client._cooldowns[self._operation] = (
196
+ time.monotonic()
197
+ + client._service._transport_limits.cooldown_seconds,
198
+ error.kind,
199
+ )
200
+ except TimeoutError:
201
+ self._error = outcome = ErrorKind.TIMEOUT
202
+ except asyncio.CancelledError:
203
+ self._error = ErrorKind.CLOSED
204
+ outcome = "cancelled"
205
+ except Exception:
206
+ # Never attach URL-bearing exceptions from a custom transport.
207
+ self._error = outcome = ErrorKind.CONNECTION
208
+ finally:
209
+ if self._error is not None:
210
+ self._chunk = None
211
+ self._release()
212
+ self._ready.set()
213
+ self._delivery.set()
214
+ client._emit(
215
+ DiagnosticEvent(
216
+ self._operation,
217
+ outcome,
218
+ time.monotonic() - started,
219
+ budget.requests_dispatched,
220
+ budget.response_bytes,
221
+ )
222
+ )
223
+
224
+ def _release(self) -> None:
225
+ if self._admitted:
226
+ self._admitted = False
227
+ self._client._release_operation()
228
+
229
+ def _finished(self, task: asyncio.Task[None]) -> None:
230
+ self._client._service._tasks.discard(task)
231
+ # Cancellation can happen before the worker's first instruction.
232
+ self._release()
233
+ if task.cancelled():
234
+ self._error = ErrorKind.CLOSED
235
+ self._ready.set()
236
+ self._delivery.set()
237
+
238
+ async def aclose(self) -> None:
239
+ self._closed = True
240
+ self._chunk = None
241
+ if self._task is not None:
242
+ if not self._task.done() and not self._task.cancelling():
243
+ self._task.cancel()
244
+ if await join_owned(self._task):
245
+ raise asyncio.CancelledError
246
+
247
+ async def __aexit__(
248
+ self,
249
+ exc_type: type[BaseException] | None,
250
+ exc: BaseException | None,
251
+ traceback: TracebackType | None,
252
+ ) -> None:
253
+ await self.aclose()
254
+
255
+
256
+ class ModernAttachmentStream(AttachmentStream):
257
+ """Explicit modern resolution with the shared credential-free download worker."""
258
+
259
+ _operation: OperationName = "modern_attachment_download"
260
+
261
+ @staticmethod
262
+ def _validate_reference(
263
+ reference: MessageAttachmentReference | ModernMessageAttachmentReference,
264
+ account: str,
265
+ ) -> None:
266
+ if not isinstance(reference, ModernMessageAttachmentReference):
267
+ raise LibrusError(ErrorKind.INVALID_INPUT)
268
+ validate_modern_attachment_reference(reference, account)
269
+
270
+ async def _fetch(self, budget: RequestBudget, _: bool) -> None:
271
+ client = self._client
272
+ assert isinstance(self._reference, ModernMessageAttachmentReference)
273
+ resolver = getattr(client._transport, "resolve_modern_attachment", None)
274
+ if not callable(resolver):
275
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
276
+ await client._modern_ready(budget)
277
+ try:
278
+ response = await resolver(self._reference, budget)
279
+ client._validate_read_response(response, "application/json")
280
+ key = await client._service._parsers.run(
281
+ lambda body: modern_attachment_key(body, client._service._connection),
282
+ response.body,
283
+ budget,
284
+ )
285
+ except BaseException as error:
286
+ client._invalidate_modern()
287
+ if isinstance(error, SessionExpiredError):
288
+ error._messages_origin = True
289
+ raise
290
+ await client._transport.stream_download(
291
+ key,
292
+ budget,
293
+ self._max_bytes,
294
+ self._opened,
295
+ self._pull,
296
+ self._deliver,
297
+ )
@@ -0,0 +1,182 @@
1
+ """Original bounded attendance HTML parser from source-informed requirements."""
2
+
3
+ import re
4
+ from typing import Literal, cast
5
+
6
+ from lxml import html
7
+
8
+ from librus_python_api import markup
9
+ from librus_python_api.config import (
10
+ ATTENDANCE_DETAIL_MAX_FIELDS,
11
+ ATTENDANCE_EMPTY_MARKERS,
12
+ ATTENDANCE_MAX_RECORDS,
13
+ ATTENDANCE_SEMESTER_LABELS,
14
+ GRADE_MAX_METADATA_LENGTH,
15
+ GRADE_MAX_VALUE_LENGTH,
16
+ )
17
+ from librus_python_api.exceptions import ErrorKind, LibrusError
18
+ from librus_python_api.models import (
19
+ AttendanceDetailContent,
20
+ AttendanceRecord,
21
+ AttendanceRecords,
22
+ )
23
+ from librus_python_api.parsers import parse_page
24
+
25
+
26
+ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceRecord:
27
+ # Some source-informed tooltips separate complete bold label/value blocks
28
+ # without BR tags. Retain the closing tags while adding field boundaries.
29
+ title = element.get("title", "")
30
+ if len(title) > GRADE_MAX_METADATA_LENGTH:
31
+ raise LibrusError(ErrorKind.LIMIT)
32
+ holder = html.Element("span")
33
+ holder.set("title", re.sub(r"</b>\s*(?=<b[ >])", "</b>\n", title, flags=re.I))
34
+ fields = markup.tooltip_fields(holder)
35
+ if any(
36
+ max(len(key), len(value)) > GRADE_MAX_VALUE_LENGTH
37
+ for key, value in fields.items()
38
+ ):
39
+ raise LibrusError(ErrorKind.LIMIT)
40
+ period = fields.get("Godzina lekcyjna")
41
+ if period is not None and not re.fullmatch(r"[0-9]{1,2}", period):
42
+ raise LibrusError(ErrorKind.PARSE)
43
+ excursion = fields.get("Czy wycieczka")
44
+ if excursion is not None and excursion.casefold() not in ("tak", "nie"):
45
+ raise LibrusError(ErrorKind.PARSE)
46
+ symbol = markup.text(element)
47
+ if not symbol:
48
+ raise LibrusError(ErrorKind.PARSE)
49
+ return AttendanceRecord(
50
+ symbol,
51
+ markup.civil_date(fields.get("Data")),
52
+ semester,
53
+ fields.get("Rodzaj"),
54
+ fields.get("Nauczyciel"),
55
+ None if period is None else int(period),
56
+ None if excursion is None else excursion.casefold() == "tak",
57
+ fields.get("Temat zajęć"),
58
+ fields.get("Lekcja"),
59
+ markup.attendance_detail_id(element),
60
+ tuple(fields.items()),
61
+ )
62
+
63
+
64
+ def _plain_cell_is_layout(cell: html.HtmlElement) -> bool:
65
+ value = markup.text(cell)
66
+ if value in ATTENDANCE_EMPTY_MARKERS or re.fullmatch(r"[0-9]{1,2}", value):
67
+ return True
68
+ # Civil day labels in grid cells do not create attendance records.
69
+ if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}(?: \([^()]+\))?", value):
70
+ markup.civil_date(value)
71
+ return True
72
+ return False
73
+
74
+
75
+ def parse_attendance(body: bytes) -> AttendanceRecords:
76
+ document = parse_page(body)
77
+ tables = [
78
+ table
79
+ for table in document.iter("table")
80
+ if {"center", "big", "decorated"} <= set(table.get("class", "").split())
81
+ ]
82
+ if len(tables) != 1:
83
+ raise LibrusError(ErrorKind.PARSE)
84
+ table = tables[0]
85
+ if list(table.iterdescendants("table")):
86
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
87
+ for anchor in document.iter("a"):
88
+ if table not in anchor.iterancestors("table") and "Data:" in anchor.get(
89
+ "title", ""
90
+ ):
91
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
92
+ semesters: list[Literal[1, 2]] = []
93
+ semester: Literal[1, 2] | None = None
94
+ items: list[AttendanceRecord] = []
95
+ for row in markup.rows(table):
96
+ if next(row.iterancestors("thead"), None) is not None:
97
+ continue
98
+ cells = markup.cells(row)
99
+ if not cells or any(c.get("rowspan", "1") != "1" for c in cells):
100
+ raise LibrusError(ErrorKind.PARSE)
101
+ headings = [
102
+ c for c in cells if {"bolded", "center"} <= set(c.get("class", "").split())
103
+ ]
104
+ if headings:
105
+ if len(headings) != 1 or list(row.iter("a")):
106
+ raise LibrusError(ErrorKind.PARSE)
107
+ period = ATTENDANCE_SEMESTER_LABELS.get(
108
+ markup.text(headings[0]).casefold().rstrip(":")
109
+ )
110
+ if period is None or period in semesters:
111
+ raise LibrusError(ErrorKind.PARSE)
112
+ semester = cast(Literal[1, 2], period)
113
+ semesters.append(semester)
114
+ continue
115
+ for cell in cells:
116
+ anchors = list(cell.iter("a"))
117
+ if anchors:
118
+ if semester is None or "center" not in cell.get("class", "").split():
119
+ raise LibrusError(ErrorKind.PARSE)
120
+ for anchor in anchors:
121
+ items.append(_entry(anchor, semester))
122
+ if len(items) > ATTENDANCE_MAX_RECORDS:
123
+ raise LibrusError(ErrorKind.LIMIT)
124
+ elif "center" in cell.get(
125
+ "class", ""
126
+ ).split() and not _plain_cell_is_layout(cell):
127
+ raise LibrusError(ErrorKind.PARSE)
128
+ if not semesters:
129
+ raise LibrusError(ErrorKind.PARSE)
130
+ return AttendanceRecords(tuple(items), tuple(semesters))
131
+
132
+
133
+ def parse_attendance_detail(body: bytes) -> AttendanceDetailContent:
134
+ document = parse_page(body)
135
+ containers = [
136
+ node
137
+ for node in document.iter("div")
138
+ if "container-background" in node.get("class", "").split()
139
+ ]
140
+ if len(containers) != 1:
141
+ raise LibrusError(ErrorKind.PARSE)
142
+ fields: dict[str, str] = {}
143
+ notes: list[str] = []
144
+ tables: set[html.HtmlElement] = set()
145
+ for row in containers[0].iter("tr"):
146
+ if not {"line0", "line1"}.intersection(row.get("class", "").split()):
147
+ continue
148
+ cells = markup.cells(row)
149
+ table = next(row.iterancestors("table"), None)
150
+ if table is None:
151
+ raise LibrusError(ErrorKind.PARSE)
152
+ tables.add(table)
153
+ if (
154
+ fields
155
+ and len(cells) == 1
156
+ and cells[0].tag == "td"
157
+ and cells[0].get("colspan") == "2"
158
+ and cells[0].get("rowspan", "1") == "1"
159
+ ):
160
+ notes.append(markup.text(cells[0]))
161
+ if len(fields) + len(notes) > ATTENDANCE_DETAIL_MAX_FIELDS:
162
+ raise LibrusError(ErrorKind.LIMIT)
163
+ continue
164
+ if (
165
+ len(cells) != 2
166
+ or cells[0].tag != "th"
167
+ or cells[1].tag != "td"
168
+ or any(
169
+ c.get("rowspan", "1") != "1" or c.get("colspan", "1") != "1"
170
+ for c in cells
171
+ )
172
+ ):
173
+ raise LibrusError(ErrorKind.PARSE)
174
+ label = markup.text(cells[0]).rstrip(":")
175
+ if not label or label in fields:
176
+ raise LibrusError(ErrorKind.PARSE)
177
+ fields[label] = markup.text(cells[1])
178
+ if len(fields) + len(notes) > ATTENDANCE_DETAIL_MAX_FIELDS:
179
+ raise LibrusError(ErrorKind.LIMIT)
180
+ if not fields or len(tables) != 1:
181
+ raise LibrusError(ErrorKind.PARSE)
182
+ return AttendanceDetailContent(tuple(fields.items()), tuple(notes))
@@ -0,0 +1,112 @@
1
+ """Original validated gateway parsing and explicit attendance ratio policies."""
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from librus_python_api import markup
6
+ from librus_python_api.config import ATTENDANCE_MAX_RECORDS, ATTENDANCE_TYPE_KINDS
7
+ from librus_python_api.exceptions import ErrorKind, LibrusError
8
+ from librus_python_api.models import (
9
+ AttendanceKind,
10
+ FrequencyMeasure,
11
+ GatewayAttendanceRecord,
12
+ _AttendanceEnvelopeWire,
13
+ _LessonEnvelopeWire,
14
+ _SubjectEnvelopeWire,
15
+ )
16
+ from librus_python_api.parsers import decode_json
17
+
18
+
19
+ def parse_gateway_attendance(body: bytes) -> tuple[GatewayAttendanceRecord, ...]:
20
+ data = decode_json(body)
21
+ if (
22
+ isinstance(data, dict)
23
+ and isinstance(data.get("Attendances"), list)
24
+ and len(data["Attendances"]) > ATTENDANCE_MAX_RECORDS
25
+ ):
26
+ raise LibrusError(ErrorKind.LIMIT)
27
+ failed = False
28
+ envelope = None
29
+ try:
30
+ envelope = _AttendanceEnvelopeWire.model_validate(data)
31
+ except ValidationError:
32
+ failed = True
33
+ if failed or envelope is None:
34
+ raise LibrusError(ErrorKind.PARSE)
35
+ seen: set[str] = set()
36
+ records: list[GatewayAttendanceRecord] = []
37
+ for row in envelope.Attendances:
38
+ if row.Id is not None:
39
+ if row.Id in seen:
40
+ raise LibrusError(ErrorKind.PARSE)
41
+ seen.add(row.Id)
42
+ records.append(
43
+ GatewayAttendanceRecord(
44
+ row.Id,
45
+ markup.civil_date(row.Date),
46
+ row.Semester,
47
+ row.Type.Id,
48
+ AttendanceKind(ATTENDANCE_TYPE_KINDS.get(row.Type.Id, "unknown")),
49
+ row.Lesson.Id,
50
+ row.LessonNo,
51
+ )
52
+ )
53
+ return tuple(records)
54
+
55
+
56
+ def parse_lesson_subject(body: bytes, identifier: str) -> str:
57
+ data = decode_json(body)
58
+ failed = False
59
+ lesson = None
60
+ try:
61
+ lesson = _LessonEnvelopeWire.model_validate(data).Lesson
62
+ except ValidationError:
63
+ failed = True
64
+ if failed or lesson is None or lesson.Id != identifier:
65
+ raise LibrusError(ErrorKind.PARSE)
66
+ return lesson.Subject.Id
67
+
68
+
69
+ def parse_subject_name(body: bytes, identifier: str) -> str:
70
+ data = decode_json(body)
71
+ failed = False
72
+ subject = None
73
+ try:
74
+ subject = _SubjectEnvelopeWire.model_validate(data).Subject
75
+ except ValidationError:
76
+ failed = True
77
+ if (
78
+ failed
79
+ or subject is None
80
+ or subject.Id != identifier
81
+ or not subject.Name.strip()
82
+ ):
83
+ raise LibrusError(ErrorKind.PARSE)
84
+ return subject.Name.strip()
85
+
86
+
87
+ def summarize_frequency(
88
+ rows: tuple[GatewayAttendanceRecord, ...],
89
+ *,
90
+ subject_policy: bool,
91
+ ) -> FrequencyMeasure:
92
+ attended = total = unknown = excluded = 0
93
+ for row in rows:
94
+ if row.kind == "unknown":
95
+ unknown += 1
96
+ if not subject_policy:
97
+ total += 1
98
+ attended += row.kind in ("present", "late", "excursion")
99
+ elif row.kind in ("present", "late", "absence", "excused", "exemption"):
100
+ total += 1
101
+ attended += row.kind in ("present", "late")
102
+ elif row.kind != "unknown":
103
+ excluded += 1
104
+ ratio = attended / total if total and not unknown else None
105
+ return FrequencyMeasure(
106
+ attended,
107
+ total,
108
+ excluded,
109
+ unknown,
110
+ ratio,
111
+ "subject" if subject_policy else "overall",
112
+ )
@@ -0,0 +1,79 @@
1
+ """Caller-shareable operation budgets with no I/O or implicit configuration."""
2
+
3
+ import asyncio
4
+ import time
5
+
6
+ from librus_python_api.config import DEFAULT_OPERATION_LIMITS, OperationLimits
7
+ from librus_python_api.exceptions import ErrorKind, LibrusError
8
+
9
+
10
+ class RequestBudget:
11
+ """Single-event-loop request/deadline budget shared across account calls.
12
+
13
+ The deadline starts at construction, including admission wait. Every actual
14
+ dispatch consumes one request; rejected or canceled queued work consumes none.
15
+ A reserved rate token can still be spent when its waiter is canceled. Budgets
16
+ are never refunded after dispatch, including connection failure or timeout.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ max_requests: int = DEFAULT_OPERATION_LIMITS.max_requests,
23
+ timeout_seconds: float = DEFAULT_OPERATION_LIMITS.timeout_seconds,
24
+ max_response_bytes: int = DEFAULT_OPERATION_LIMITS.max_response_bytes,
25
+ ) -> None:
26
+ limits = OperationLimits(
27
+ max_requests=max_requests,
28
+ timeout_seconds=timeout_seconds,
29
+ max_response_bytes=max_response_bytes,
30
+ )
31
+ self._max_requests = limits.max_requests
32
+ self._deadline = time.monotonic() + limits.timeout_seconds
33
+ self._requests_dispatched = 0
34
+ self._max_response_bytes = limits.max_response_bytes
35
+ self._response_bytes = 0
36
+ self._loop: asyncio.AbstractEventLoop | None = None
37
+
38
+ @property
39
+ def requests_dispatched(self) -> int:
40
+ return self._requests_dispatched
41
+
42
+ @property
43
+ def response_bytes(self) -> int:
44
+ return self._response_bytes
45
+
46
+ @property
47
+ def remaining_response_bytes(self) -> int:
48
+ return max(0, self._max_response_bytes - self._response_bytes)
49
+
50
+ def _receive(self, count: int) -> None:
51
+ self._bind_loop()
52
+ self.remaining_seconds()
53
+ self._response_bytes += count
54
+ if self._response_bytes > self._max_response_bytes:
55
+ raise LibrusError(ErrorKind.LIMIT)
56
+
57
+ def remaining_seconds(self) -> float:
58
+ remaining = self._deadline - time.monotonic()
59
+ if remaining <= 0:
60
+ raise LibrusError(ErrorKind.TIMEOUT)
61
+ return remaining
62
+
63
+ def check(self) -> None:
64
+ self.remaining_seconds()
65
+ if self._requests_dispatched >= self._max_requests:
66
+ raise LibrusError(ErrorKind.LIMIT)
67
+ if self.remaining_response_bytes == 0:
68
+ raise LibrusError(ErrorKind.LIMIT)
69
+
70
+ def _consume(self) -> None:
71
+ # Synchronous check/update is atomic on the service's single event loop.
72
+ self.check()
73
+ self._requests_dispatched += 1
74
+
75
+ def _bind_loop(self) -> None:
76
+ loop = asyncio.get_running_loop()
77
+ if self._loop is not None and self._loop is not loop:
78
+ raise LibrusError(ErrorKind.INVALID_INPUT)
79
+ self._loop = loop