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