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,2285 @@
1
+ """Public login-scoped account service under one shared traffic boundary."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import math
8
+ import re
9
+ import time
10
+ from collections.abc import Awaitable, Callable, Hashable, Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, date, datetime
13
+ from types import TracebackType
14
+ from typing import Any, Literal, Self, cast
15
+ from urllib.parse import urljoin, urlsplit
16
+
17
+ from librus_python_api.announcements import parse_announcements
18
+ from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
19
+ from librus_python_api.attendance import parse_attendance, parse_attendance_detail
20
+ from librus_python_api.attendance_frequency import (
21
+ parse_gateway_attendance,
22
+ parse_lesson_subject,
23
+ parse_subject_name,
24
+ summarize_frequency,
25
+ )
26
+ from librus_python_api.budget import RequestBudget
27
+ from librus_python_api.checkpoint import CHECKPOINT_SERVICE, validate_checkpoint
28
+ from librus_python_api.completed_lessons import (
29
+ parse_completed_lessons,
30
+ validate_selection,
31
+ )
32
+ from librus_python_api.config import (
33
+ ATTACHMENT_MAX_BYTES,
34
+ ATTENDANCE_MAX_WINDOW_DAYS,
35
+ ATTENDANCE_METADATA_CACHE_SIZE,
36
+ ATTENDANCE_METADATA_TTL_SECONDS,
37
+ ATTENDANCE_RESULT_CACHE_SIZE,
38
+ CHECKPOINT_TIMEOUT_SECONDS,
39
+ ENDPOINTS,
40
+ GRADE_MAX_WINDOW_DAYS,
41
+ MESSAGE_MAX_CURSOR_IDS,
42
+ MODERN_RECIPIENT_OPERATIONS,
43
+ SCHEDULE_RESPONSE_VERSION,
44
+ SESSION_COOKIE,
45
+ AccountCredentials,
46
+ ConnectionSettings,
47
+ OperationLimits,
48
+ SchedulerLimits,
49
+ TransportLimits,
50
+ agenda_form,
51
+ attendance_view_form,
52
+ completed_lessons_form,
53
+ encode_modern_send,
54
+ encode_send_form,
55
+ grade_view_form,
56
+ homework_form,
57
+ homework_range_forms,
58
+ message_page_form,
59
+ modern_directory_query,
60
+ modern_mailbox_query,
61
+ recipient_form,
62
+ timetable_form,
63
+ )
64
+ from librus_python_api.detail_fields import normalize_detail_fields
65
+ from librus_python_api.diagnostics import DiagnosticSink
66
+ from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
67
+ from librus_python_api.grade_parsers import parse_final_grades
68
+ from librus_python_api.grade_records import parse_grade_records
69
+ from librus_python_api.homework_range import HomeworkAccumulator
70
+ from librus_python_api.lifecycle import join_owned
71
+ from librus_python_api.message_content import parse_message_content, validate_reference
72
+ from librus_python_api.messages import parse_messages
73
+ from librus_python_api.messages import validate_selection as validate_message_selection
74
+ from librus_python_api.models import (
75
+ AccountContext,
76
+ Agenda,
77
+ Announcements,
78
+ Attendance,
79
+ AttendanceDetail,
80
+ AttendanceFrequency,
81
+ AttendanceView,
82
+ AttendanceWindow,
83
+ CompletedLesson,
84
+ CompletedLessons,
85
+ CompletedLessonsCursor,
86
+ CompletedLessonsPage,
87
+ DiagnosticEvent,
88
+ FinalGrades,
89
+ GatewayAttendance,
90
+ GatewayAttendanceRecord,
91
+ Grades,
92
+ GradeView,
93
+ GradeWindow,
94
+ Homework,
95
+ HomeworkRangeRequest,
96
+ Identity,
97
+ LoginSubmission,
98
+ MessageAttachmentReference,
99
+ MessageContent,
100
+ MessageFolder,
101
+ MessageReference,
102
+ Messages,
103
+ MessagesCursor,
104
+ MessagesPage,
105
+ MessageSummary,
106
+ ModernAccountData,
107
+ ModernIdentity,
108
+ ModernMessageAttachmentReference,
109
+ ModernMessageContent,
110
+ ModernMessageReference,
111
+ ModernMessages,
112
+ ModernMessagesCursor,
113
+ ModernMessagesPage,
114
+ ModernRecipientReference,
115
+ ModernRecipients,
116
+ ModernRecipientTypeReference,
117
+ ModernRecipientTypes,
118
+ ModernSendSubmission,
119
+ NotificationCounts,
120
+ Observation,
121
+ OperationName,
122
+ RecipientGroupChoices,
123
+ RecipientGroupReference,
124
+ RecipientGroups,
125
+ RecipientReference,
126
+ Recipients,
127
+ RequestForm,
128
+ ScheduleEventResponse,
129
+ ScheduleEvents,
130
+ ScheduleEventWire,
131
+ SchedulerSnapshot,
132
+ SchoolDetail,
133
+ SchoolReference,
134
+ SendResult,
135
+ SendSubmission,
136
+ StudentInformation,
137
+ SubjectFrequencies,
138
+ SubjectFrequency,
139
+ Timetable,
140
+ TransportResponse,
141
+ )
142
+ from librus_python_api.modern_mailbox import collect as collect_modern_messages
143
+ from librus_python_api.modern_mailbox import (
144
+ parse_content as parse_modern_message_content,
145
+ )
146
+ from librus_python_api.modern_mailbox import parse_page as parse_modern_message_page
147
+ from librus_python_api.modern_mailbox import (
148
+ validate_reference as validate_modern_message_reference,
149
+ )
150
+ from librus_python_api.modern_mailbox import (
151
+ validate_selection as validate_modern_message_selection,
152
+ )
153
+ from librus_python_api.modern_messages import (
154
+ parse_modern_identity,
155
+ parse_modern_recipients,
156
+ parse_modern_send_response,
157
+ parse_modern_types,
158
+ validate_modern_type,
159
+ )
160
+ from librus_python_api.notifications import (
161
+ decode_payload,
162
+ parse_notification_counts,
163
+ parse_schedule_events,
164
+ validate_response,
165
+ )
166
+ from librus_python_api.parsers import parse_identity, parse_login, parse_profile
167
+ from librus_python_api.parsing import ParserPool
168
+ from librus_python_api.recipients import (
169
+ parse_recipient_group_choices,
170
+ parse_recipient_groups,
171
+ parse_recipients,
172
+ validate_group,
173
+ )
174
+ from librus_python_api.scheduler import RequestScheduler
175
+ from librus_python_api.school_reads import (
176
+ parse_agenda,
177
+ parse_homework,
178
+ parse_school_detail,
179
+ )
180
+ from librus_python_api.sending import SendAttempt, parse_send_acknowledgement
181
+ from librus_python_api.timetable import parse_timetable
182
+ from librus_python_api.transport import (
183
+ AccountTransport,
184
+ AiohttpTransport,
185
+ TransportFactory,
186
+ )
187
+
188
+ HTML = "text/html"
189
+ JSON = "application/json"
190
+ MAX_AGE_LIMIT_SECONDS = 3600
191
+ NUMERIC_ID = re.compile(r"[0-9]{1,64}")
192
+
193
+ # A read receives its budget and whether this attempt has just logged in.
194
+ type Fetch[T] = Callable[[RequestBudget, bool], Awaitable[T]]
195
+
196
+
197
+ @dataclass(slots=True)
198
+ class _Flight:
199
+ task: asyncio.Task[Any]
200
+ waiters: int = 0
201
+
202
+
203
+ class LibrusService:
204
+ """Own all independent login clients, transports, parsers, and worker tasks.
205
+
206
+ Construction performs no I/O and does not discover environment settings.
207
+ One service is event-loop local. Always close it or use an async context.
208
+ context_key is a caller-owned 32-byte random secret, retained across restarts
209
+ whenever account identifiers are used with durable state.
210
+ """
211
+
212
+ def __init__(
213
+ self,
214
+ accounts: Mapping[str, AccountCredentials],
215
+ *,
216
+ context_key: bytes,
217
+ scheduler_limits: SchedulerLimits | None = None,
218
+ transport_limits: TransportLimits | None = None,
219
+ operation_limits: OperationLimits | None = None,
220
+ connection: ConnectionSettings | None = None,
221
+ transport_factory: TransportFactory = AiohttpTransport,
222
+ diagnostic_sink: DiagnosticSink | None = None,
223
+ ) -> None:
224
+ if type(context_key) is not bytes or len(context_key) != 32:
225
+ raise LibrusError(ErrorKind.INVALID_INPUT)
226
+ self._context_key = context_key
227
+ self._limits = scheduler_limits or SchedulerLimits()
228
+ self._transport_limits = transport_limits or TransportLimits()
229
+ self._operation_limits = operation_limits or OperationLimits()
230
+ self._connection = connection or ConnectionSettings()
231
+ self._scheduler = RequestScheduler(tuple(accounts), limits=self._limits)
232
+ if any(
233
+ not isinstance(value, AccountCredentials) for value in accounts.values()
234
+ ):
235
+ raise LibrusError(ErrorKind.INVALID_INPUT)
236
+ self._factory = transport_factory
237
+ self._diagnostic_sink = diagnostic_sink
238
+ self._clients = {
239
+ alias: AccountClient(self, alias, credentials)
240
+ for alias, credentials in accounts.items()
241
+ }
242
+ self._parsers = ParserPool(self._transport_limits.parse_max_bytes)
243
+ self._transports: list[AccountTransport] = []
244
+ self._tasks: set[asyncio.Task[Any]] = set()
245
+ self._operations = 0
246
+ self._loop: asyncio.AbstractEventLoop | None = None
247
+ self._closed = False
248
+ self._close_task: asyncio.Task[None] | None = None
249
+
250
+ def account(self, alias: str) -> "AccountClient":
251
+ if self._closed:
252
+ raise LibrusError(ErrorKind.CLOSED)
253
+ client = self._clients.get(alias)
254
+ if client is None:
255
+ raise LibrusError(ErrorKind.INVALID_INPUT)
256
+ return client
257
+
258
+ def snapshot(self) -> SchedulerSnapshot:
259
+ return self._scheduler.snapshot()
260
+
261
+ def _bind(self) -> None:
262
+ if CHECKPOINT_SERVICE.get() is self:
263
+ raise LibrusError(ErrorKind.INVALID_INPUT)
264
+ loop = asyncio.get_running_loop()
265
+ if self._loop is not None and self._loop is not loop:
266
+ raise LibrusError(ErrorKind.INVALID_INPUT)
267
+ self._loop = loop
268
+ if self._closed:
269
+ raise LibrusError(ErrorKind.CLOSED)
270
+
271
+ def _transport(self, alias: str) -> AccountTransport:
272
+ transport = self._factory(
273
+ alias,
274
+ self._scheduler,
275
+ self._connection,
276
+ self._transport_limits,
277
+ )
278
+ if any(transport is item for item in self._transports):
279
+ raise LibrusError(ErrorKind.INVALID_INPUT)
280
+ self._transports.append(transport)
281
+ return transport
282
+
283
+ def _budget(self) -> RequestBudget:
284
+ limits = self._operation_limits
285
+ return RequestBudget(
286
+ max_requests=limits.max_requests,
287
+ timeout_seconds=limits.timeout_seconds,
288
+ max_response_bytes=limits.max_response_bytes,
289
+ )
290
+
291
+ async def aclose(self) -> None:
292
+ if CHECKPOINT_SERVICE.get() is self:
293
+ raise LibrusError(ErrorKind.INVALID_INPUT)
294
+ loop = asyncio.get_running_loop()
295
+ if self._loop is not None and self._loop is not loop:
296
+ raise LibrusError(ErrorKind.INVALID_INPUT)
297
+ self._loop = loop
298
+ if asyncio.current_task() in self._tasks:
299
+ raise LibrusError(ErrorKind.INVALID_INPUT)
300
+ if self._close_task is None:
301
+ self._closed = True
302
+ self._close_task = asyncio.create_task(self._finish_close())
303
+ try:
304
+ await asyncio.shield(self._close_task)
305
+ except asyncio.CancelledError:
306
+ await join_owned(self._close_task)
307
+ raise
308
+
309
+ async def _finish_close(self) -> None:
310
+ tasks = tuple(self._tasks)
311
+ for task in tasks:
312
+ if not task.cancelling():
313
+ task.cancel()
314
+ await asyncio.gather(*tasks, return_exceptions=True)
315
+ await self._scheduler.aclose()
316
+ try:
317
+ outcomes = await asyncio.gather(
318
+ *(transport.aclose() for transport in self._transports),
319
+ return_exceptions=True,
320
+ )
321
+ if any(isinstance(outcome, BaseException) for outcome in outcomes):
322
+ raise LibrusError(ErrorKind.CONNECTION)
323
+ finally:
324
+ self._parsers.close()
325
+
326
+ async def __aenter__(self) -> Self:
327
+ self._bind()
328
+ return self
329
+
330
+ async def __aexit__(
331
+ self,
332
+ exc_type: type[BaseException] | None,
333
+ exc: BaseException | None,
334
+ traceback: TracebackType | None,
335
+ ) -> None:
336
+ await self.aclose()
337
+
338
+
339
+ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
340
+ if any(day is not None and type(day) is not date for day in days):
341
+ raise LibrusError(ErrorKind.INVALID_INPUT)
342
+ start, end = days[0], days[-1]
343
+ if max_days is not None and start is not None and end is not None:
344
+ if not 0 <= (end - start).days < max_days:
345
+ raise LibrusError(ErrorKind.INVALID_INPUT)
346
+
347
+
348
+ class AccountClient:
349
+ """Service-owned account context. Fresh reads are the default.
350
+
351
+ max_age_seconds permits bounded account/session-scoped reuse. No persistent
352
+ cookies, child switching, or automatic cross-login identity merging exists.
353
+ Default-budget identical reads coalesce. Explicit budgets coalesce only by
354
+ budget object identity. Canceling the last waiter cancels and joins its work.
355
+ """
356
+
357
+ def __init__(
358
+ self,
359
+ service: LibrusService,
360
+ alias: str,
361
+ credentials: AccountCredentials,
362
+ ) -> None:
363
+ self._service, self._alias, self._credentials = service, alias, credentials
364
+ self._context = AccountContext(
365
+ alias,
366
+ hmac.new(
367
+ service._context_key,
368
+ json.dumps(
369
+ [
370
+ "librus-python-api/account-context/v2",
371
+ alias,
372
+ credentials.login.get_secret_value(),
373
+ service._connection.synergia_origin,
374
+ service._connection.api_origin,
375
+ service._connection.messages_origin,
376
+ ],
377
+ ensure_ascii=True,
378
+ separators=(",", ":"),
379
+ ).encode("utf-8"),
380
+ hashlib.sha256,
381
+ ).hexdigest(),
382
+ )
383
+ self._transport_instance: AccountTransport | None = None
384
+ self._lock = asyncio.Lock()
385
+ self._flights: dict[Hashable, _Flight] = {}
386
+ self._operations = 0
387
+ self._generation = 0
388
+ self._identity: Identity | None = None
389
+ self._cache: dict[Hashable, tuple[float, Any]] = {}
390
+ self._metadata: dict[tuple[str, str], tuple[float, str]] = {}
391
+ self._cooldowns: dict[str, tuple[float, ErrorKind]] = {}
392
+ self._consuming_schedule = False
393
+ self._modern_account: ModernAccountData | None = None
394
+
395
+ @property
396
+ def _transport(self) -> AccountTransport:
397
+ if self._transport_instance is None:
398
+ self._transport_instance = self._service._transport(self._alias)
399
+ return self._transport_instance
400
+
401
+ def prepare_send(
402
+ self, *, recipients: tuple[RecipientReference, ...], subject: str, body: str
403
+ ) -> SendAttempt:
404
+ if self._service._closed:
405
+ raise LibrusError(ErrorKind.CLOSED)
406
+ submission = SendSubmission(recipients, subject, body)
407
+ encode_send_form(submission, self._alias)
408
+ return SendAttempt(self, submission)
409
+
410
+ async def _execute_send(
411
+ self, attempt: SendAttempt, budget: RequestBudget | None
412
+ ) -> SendResult:
413
+ if isinstance(attempt.submission, ModernSendSubmission):
414
+ return await self._execute_modern_send(attempt, attempt.submission, budget)
415
+ submission = attempt.submission
416
+ encode_send_form(submission, self._alias)
417
+ deadline = asyncio.timeout(None)
418
+
419
+ async def fetch(budget: RequestBudget, _: bool) -> SendResult:
420
+ send = getattr(self._transport, "send_message", None)
421
+ if not callable(send):
422
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
423
+
424
+ def dispatched() -> None:
425
+ attempt._dispatch(
426
+ self._session_identity(), self._observation("send_message")
427
+ )
428
+ for key in tuple(self._cache):
429
+ if isinstance(key, tuple) and key[0] == "messages_sent":
430
+ del self._cache[key]
431
+
432
+ response = await send(submission, budget, dispatched)
433
+ receipt_budget = self._receipt_budget(deadline)
434
+ self._validate_read_response(response, HTML)
435
+ status = await self._service._parsers.run(
436
+ parse_send_acknowledgement, response.body, receipt_budget
437
+ )
438
+ return attempt._acknowledge(status)
439
+
440
+ return await self._uncached(
441
+ "send_message",
442
+ fetch,
443
+ budget,
444
+ authenticate=True,
445
+ preserve_cancellation=True,
446
+ deadline=deadline,
447
+ )
448
+
449
+ def _receipt_budget(self, deadline: asyncio.Timeout) -> RequestBudget:
450
+ """No further I/O: retain a complete receipt with bounded local parsing."""
451
+ seconds = self._service._transport_limits.request_timeout_seconds
452
+ deadline.reschedule(asyncio.get_running_loop().time() + seconds)
453
+ return RequestBudget(max_requests=1, timeout_seconds=seconds)
454
+
455
+ def prepare_modern_send(
456
+ self,
457
+ *,
458
+ recipients: tuple[ModernRecipientReference, ...],
459
+ subject: str,
460
+ body: str,
461
+ ) -> SendAttempt:
462
+ if self._service._closed:
463
+ raise LibrusError(ErrorKind.CLOSED)
464
+ submission = ModernSendSubmission(recipients, subject, body)
465
+ encode_modern_send(submission, self._alias)
466
+ return SendAttempt(self, submission)
467
+
468
+ @property
469
+ def context(self) -> AccountContext:
470
+ """Configured login/origin provenance without authentication or storage I/O."""
471
+ return self._context
472
+
473
+ async def _modern_ready(
474
+ self, budget: RequestBudget, *, refresh: bool = False
475
+ ) -> ModernAccountData:
476
+ if self._modern_account is not None and not refresh:
477
+ return self._modern_account
478
+ authenticate = getattr(self._transport, "authenticate_modern", None)
479
+ if not callable(authenticate):
480
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
481
+ try:
482
+ if self._modern_account is None:
483
+ await authenticate(self._credentials.login.get_secret_value(), budget)
484
+ account = await self._page(
485
+ "modern_identity", budget, parse_modern_identity, content_type=JSON
486
+ )
487
+ identity = self._session_identity()
488
+ if (
489
+ account.account_id != identity.owner.id
490
+ or (
491
+ identity.owner.first_name is not None
492
+ and account.first_name != identity.owner.first_name
493
+ )
494
+ or (
495
+ identity.owner.last_name is not None
496
+ and account.last_name != identity.owner.last_name
497
+ )
498
+ ):
499
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
500
+ self._modern_account = account
501
+ return account
502
+ except BaseException:
503
+ self._invalidate_modern()
504
+ raise
505
+
506
+ async def modern_identity(
507
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
508
+ ) -> ModernIdentity:
509
+ async def fetch(budget: RequestBudget, _: bool) -> ModernIdentity:
510
+ # Identity reads are fresh even if a modern session is already bound.
511
+ account = await self._modern_ready(budget, refresh=True)
512
+ return ModernIdentity(
513
+ self._session_identity(), account, self._observation("modern_identity")
514
+ )
515
+
516
+ return await self._read(("modern_identity",), fetch, budget, max_age_seconds)
517
+
518
+ async def modern_recipient_types(
519
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
520
+ ) -> ModernRecipientTypes:
521
+ async def fetch(budget: RequestBudget, _: bool) -> ModernRecipientTypes:
522
+ await self._modern_ready(budget)
523
+ items = await self._page(
524
+ "modern_recipient_types",
525
+ budget,
526
+ lambda body: parse_modern_types(body, self._alias),
527
+ content_type=JSON,
528
+ )
529
+ return ModernRecipientTypes(
530
+ self._session_identity(),
531
+ items,
532
+ self._observation("modern_recipient_types"),
533
+ )
534
+
535
+ return await self._read(
536
+ ("modern_recipient_types",),
537
+ fetch,
538
+ budget,
539
+ max_age_seconds,
540
+ )
541
+
542
+ async def modern_recipients(
543
+ self,
544
+ recipient_type: ModernRecipientTypeReference,
545
+ *,
546
+ budget: RequestBudget | None = None,
547
+ max_age_seconds: float = 0.0,
548
+ ) -> ModernRecipients:
549
+ validate_modern_type(recipient_type, self._alias)
550
+ operation = MODERN_RECIPIENT_OPERATIONS[recipient_type.identifier]
551
+ query = modern_directory_query(
552
+ recipient_type.identifier, include_virtual=recipient_type.include_virtual
553
+ )
554
+
555
+ async def fetch(budget: RequestBudget, _: bool) -> ModernRecipients:
556
+ await self._modern_ready(budget)
557
+ items = await self._page(
558
+ operation,
559
+ budget,
560
+ lambda body: parse_modern_recipients(body, recipient_type),
561
+ content_type=JSON,
562
+ query=query,
563
+ )
564
+ return ModernRecipients(
565
+ self._session_identity(),
566
+ recipient_type,
567
+ items,
568
+ self._observation("modern_recipients"),
569
+ )
570
+
571
+ return await self._read(
572
+ (
573
+ "modern_recipients",
574
+ recipient_type.identifier,
575
+ recipient_type.include_virtual,
576
+ ),
577
+ fetch,
578
+ budget,
579
+ max_age_seconds,
580
+ endpoint=operation,
581
+ )
582
+
583
+ async def _modern_message_page(
584
+ self, folder: MessageFolder, page: int, page_size: int, budget: RequestBudget
585
+ ) -> ModernMessagesPage:
586
+ operation: Literal["modern_messages_received", "modern_messages_sent"] = (
587
+ "modern_messages_received"
588
+ if folder is MessageFolder.RECEIVED
589
+ else "modern_messages_sent"
590
+ )
591
+ items, total, fingerprint = await self._page(
592
+ operation,
593
+ budget,
594
+ lambda body: parse_modern_message_page(
595
+ body, folder, page, page_size, self._alias
596
+ ),
597
+ content_type=JSON,
598
+ query=modern_mailbox_query(folder, page, page_size),
599
+ )
600
+ return ModernMessagesPage(
601
+ self._session_identity(),
602
+ folder,
603
+ page,
604
+ page_size,
605
+ total,
606
+ items,
607
+ fingerprint,
608
+ self._observation(operation),
609
+ )
610
+
611
+ async def modern_messages_page(
612
+ self,
613
+ folder: MessageFolder = MessageFolder.RECEIVED,
614
+ *,
615
+ page: int = 1,
616
+ page_size: int = 10,
617
+ budget: RequestBudget | None = None,
618
+ max_age_seconds: float = 0.0,
619
+ ) -> ModernMessagesPage:
620
+ """One one-based ordinary modern page, without any content open."""
621
+ modern_mailbox_query(folder, page, page_size)
622
+ operation: Literal["modern_messages_received", "modern_messages_sent"] = (
623
+ "modern_messages_received"
624
+ if folder is MessageFolder.RECEIVED
625
+ else "modern_messages_sent"
626
+ )
627
+
628
+ async def fetch(budget: RequestBudget, _: bool) -> ModernMessagesPage:
629
+ await self._modern_ready(budget)
630
+ return await self._modern_message_page(folder, page, page_size, budget)
631
+
632
+ return await self._read(
633
+ (operation, "page", page, page_size), fetch, budget, max_age_seconds
634
+ )
635
+
636
+ async def modern_messages(
637
+ self,
638
+ folder: MessageFolder = MessageFolder.RECEIVED,
639
+ *,
640
+ cursor: ModernMessagesCursor | None = None,
641
+ page_size: int = 50,
642
+ max_pages: int = 4,
643
+ limit: int = 128,
644
+ budget: RequestBudget | None = None,
645
+ max_age_seconds: float = 0.0,
646
+ ) -> ModernMessages:
647
+ """Bounded modern collection with selection-bound continuation."""
648
+ validate_modern_message_selection(
649
+ folder, cursor, page_size, max_pages, limit, self._alias
650
+ )
651
+ operation: Literal["modern_messages_received", "modern_messages_sent"] = (
652
+ "modern_messages_received"
653
+ if folder is MessageFolder.RECEIVED
654
+ else "modern_messages_sent"
655
+ )
656
+
657
+ async def fetch(budget: RequestBudget, _: bool) -> ModernMessages:
658
+ await self._modern_ready(budget)
659
+ result = await collect_modern_messages(
660
+ lambda page: self._modern_message_page(folder, page, page_size, budget),
661
+ folder,
662
+ self._alias,
663
+ cursor,
664
+ page_size,
665
+ max_pages,
666
+ limit,
667
+ )
668
+ items, pages, duplicates, continuation, reason = result
669
+ return ModernMessages(
670
+ self._session_identity(),
671
+ folder,
672
+ items,
673
+ pages,
674
+ duplicates,
675
+ continuation,
676
+ reason,
677
+ self._observation(operation),
678
+ )
679
+
680
+ return await self._read(
681
+ (operation, "batch", cursor, page_size, max_pages, limit),
682
+ fetch,
683
+ budget,
684
+ max_age_seconds,
685
+ )
686
+
687
+ async def modern_message_content(
688
+ self,
689
+ reference: ModernMessageReference,
690
+ *,
691
+ allow_mark_read: bool = False,
692
+ budget: RequestBudget | None = None,
693
+ max_age_seconds: float = 0.0,
694
+ ) -> ModernMessageContent:
695
+ validate_modern_message_reference(reference, self._alias)
696
+ if type(allow_mark_read) is not bool or (
697
+ reference.folder is MessageFolder.RECEIVED and not allow_mark_read
698
+ ):
699
+ raise LibrusError(ErrorKind.INVALID_INPUT)
700
+ operation: Literal["modern_content_received", "modern_content_sent"] = (
701
+ "modern_content_received"
702
+ if reference.folder is MessageFolder.RECEIVED
703
+ else "modern_content_sent"
704
+ )
705
+
706
+ async def fetch(budget: RequestBudget, _: bool) -> ModernMessageContent:
707
+ await self._modern_ready(budget)
708
+ if reference.folder is MessageFolder.RECEIVED:
709
+ for key in tuple(self._cache):
710
+ if isinstance(key, tuple) and key[0] == "modern_messages_received":
711
+ del self._cache[key]
712
+ parsed = await self._page(
713
+ operation,
714
+ budget,
715
+ lambda body: parse_modern_message_content(body, reference),
716
+ reference=reference.identifier,
717
+ content_type=JSON,
718
+ )
719
+ return ModernMessageContent(
720
+ self._session_identity(),
721
+ parsed.summary,
722
+ parsed.text,
723
+ parsed.attachments,
724
+ reference.folder is MessageFolder.RECEIVED,
725
+ self._observation(operation),
726
+ parsed.receipts,
727
+ parsed.recipient_count,
728
+ parsed.read_count,
729
+ parsed.receipt_source,
730
+ parsed.archived,
731
+ parsed.withdrawn,
732
+ parsed.original_subject,
733
+ parsed.original_text,
734
+ )
735
+
736
+ return await self._read((operation, reference), fetch, budget, max_age_seconds)
737
+
738
+ async def _execute_modern_send(
739
+ self,
740
+ attempt: SendAttempt,
741
+ submission: ModernSendSubmission,
742
+ budget: RequestBudget | None,
743
+ ) -> SendResult:
744
+ encode_modern_send(submission, self._alias)
745
+ deadline = asyncio.timeout(None)
746
+
747
+ async def fetch(budget: RequestBudget, _: bool) -> SendResult:
748
+ send = getattr(self._transport, "send_modern_message", None)
749
+ if not callable(send):
750
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
751
+ # A bound cookie/account is not proof it is still valid. One fresh
752
+ # side-effect-free identity GET prevents avoidable UNKNOWN sends.
753
+ await self._modern_ready(budget, refresh=True)
754
+
755
+ def dispatched() -> None:
756
+ attempt._dispatch(
757
+ self._session_identity(), self._observation("modern_send_message")
758
+ )
759
+ for key in tuple(self._cache):
760
+ if isinstance(key, tuple) and key[0] in {
761
+ "messages_sent",
762
+ "modern_messages_sent",
763
+ }:
764
+ del self._cache[key]
765
+
766
+ try:
767
+ response = await send(submission, budget, dispatched)
768
+ receipt_budget = self._receipt_budget(deadline)
769
+ if response.status in (201, 400, 422):
770
+ if _media_type(response) != JSON:
771
+ self._invalidate_modern()
772
+ raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
773
+ else:
774
+ self._validate_read_response(response, JSON)
775
+ status = await self._service._parsers.run(
776
+ lambda body: parse_modern_send_response(body, response.status),
777
+ response.body,
778
+ receipt_budget,
779
+ )
780
+ except BaseException as error:
781
+ # UNKNOWN_DELIVERY is an unqualified receipt, not session expiry.
782
+ # A malformed body/transport/permission failure still clears it.
783
+ if (
784
+ not isinstance(error, LibrusError)
785
+ or error.kind is not ErrorKind.UNKNOWN_DELIVERY
786
+ ):
787
+ self._invalidate_modern()
788
+ if isinstance(error, SessionExpiredError):
789
+ error._messages_origin = True
790
+ raise
791
+ return attempt._acknowledge(status)
792
+
793
+ return await self._uncached(
794
+ "modern_send_message",
795
+ fetch,
796
+ budget,
797
+ authenticate=True,
798
+ preserve_cancellation=True,
799
+ deadline=deadline,
800
+ )
801
+
802
+ # Public reads: validate input, then describe one fetch for _read.
803
+
804
+ async def notification_counts(
805
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
806
+ ) -> NotificationCounts:
807
+ """Read token-scoped menu counters, not a fresh notification poll."""
808
+
809
+ async def fetch(budget: RequestBudget, _: bool) -> NotificationCounts:
810
+ items = await self._page(
811
+ "notification_counts", budget, parse_notification_counts
812
+ )
813
+ return NotificationCounts(
814
+ self._session_identity(),
815
+ items,
816
+ self._observation("notification_counts"),
817
+ )
818
+
819
+ return await self._read(
820
+ ("notification_counts",), fetch, budget, max_age_seconds
821
+ )
822
+
823
+ async def consume_schedule_events(
824
+ self,
825
+ *,
826
+ checkpoint: Callable[[ScheduleEventResponse], Awaitable[None]],
827
+ allow_consume_events: bool = False,
828
+ checkpoint_timeout_seconds: float = CHECKPOINT_TIMEOUT_SECONDS,
829
+ budget: RequestBudget | None = None,
830
+ ) -> ScheduleEvents:
831
+ """Consume once, checkpoint complete encoded payload before parsing.
832
+
833
+ Callback success acknowledges consumer-owned durable storage. Callback
834
+ failure/timeout means acknowledgement unknown, never automatic replay.
835
+ """
836
+ if (
837
+ type(allow_consume_events) is not bool
838
+ or not allow_consume_events
839
+ or self._consuming_schedule
840
+ ):
841
+ raise LibrusError(ErrorKind.INVALID_INPUT)
842
+ validate_checkpoint(checkpoint, checkpoint_timeout_seconds)
843
+ self._consuming_schedule = True
844
+
845
+ async def fetch(budget: RequestBudget, _: bool) -> ScheduleEvents:
846
+ accepted: ScheduleEventResponse | None = None
847
+
848
+ async def persist(wire: ScheduleEventWire) -> None:
849
+ nonlocal accepted
850
+ accepted = ScheduleEventResponse(
851
+ SCHEDULE_RESPONSE_VERSION,
852
+ self._session_identity(),
853
+ wire,
854
+ self._observation("consume_schedule_events"),
855
+ )
856
+ token = CHECKPOINT_SERVICE.set(self._service)
857
+ try:
858
+ await checkpoint(accepted)
859
+ finally:
860
+ CHECKPOINT_SERVICE.reset(token)
861
+
862
+ response = await self._transport.consume_schedule_events(
863
+ budget, persist, checkpoint_timeout_seconds
864
+ )
865
+ self._validate_read_response(response, HTML)
866
+ assert accepted is not None
867
+ return await self._decode_schedule(accepted, budget)
868
+
869
+ try:
870
+ return await self._uncached(
871
+ "consume_schedule_events", fetch, budget, authenticate=True
872
+ )
873
+ finally:
874
+ self._consuming_schedule = False
875
+
876
+ async def decode_schedule_events(
877
+ self, response: ScheduleEventResponse, *, budget: RequestBudget | None = None
878
+ ) -> ScheduleEvents:
879
+ """Replay a persisted envelope locally, without authentication or HTTP."""
880
+ validate_response(
881
+ response, self._alias, self._service._transport_limits.response_max_bytes
882
+ )
883
+
884
+ async def fetch(budget: RequestBudget, _: bool) -> ScheduleEvents:
885
+ budget._receive(len(response.wire.body))
886
+ return await self._decode_schedule(response, budget)
887
+
888
+ return await self._uncached(
889
+ "decode_schedule_events", fetch, budget, authenticate=False
890
+ )
891
+
892
+ async def _decode_schedule(
893
+ self, response: ScheduleEventResponse, budget: RequestBudget
894
+ ) -> ScheduleEvents:
895
+ limits, wire = self._service._transport_limits, response.wire
896
+ compressed = (
897
+ bool(wire.content_codings)
898
+ and wire.content_codings[0].strip().casefold() == "gzip"
899
+ )
900
+ bound = (
901
+ min(limits.parse_max_bytes, budget.remaining_response_bytes)
902
+ if compressed
903
+ else limits.parse_max_bytes
904
+ )
905
+ body = await self._service._parsers.run(
906
+ lambda raw: decode_payload(raw, wire, bound), wire.body, budget
907
+ )
908
+ if compressed:
909
+ budget._receive(len(body))
910
+ items = await self._service._parsers.run(parse_schedule_events, body, budget)
911
+ return ScheduleEvents(response.identity, items, response.observation)
912
+
913
+ async def _uncached[T](
914
+ self,
915
+ operation: OperationName,
916
+ fetch: Fetch[T],
917
+ budget: RequestBudget | None,
918
+ *,
919
+ authenticate: bool,
920
+ preserve_cancellation: bool = False,
921
+ deadline: asyncio.Timeout | None = None,
922
+ ) -> T:
923
+ """Own one independent operation, without caching or coalescing."""
924
+ service = self._service
925
+ service._bind()
926
+ if budget is not None and not isinstance(budget, RequestBudget):
927
+ raise LibrusError(ErrorKind.INVALID_INPUT)
928
+ actual = budget or service._budget()
929
+ actual._bind_loop()
930
+ actual.remaining_seconds()
931
+ self._admit_operation()
932
+ task = asyncio.create_task(
933
+ self._execute_uncached(operation, fetch, actual, authenticate, deadline)
934
+ )
935
+ service._tasks.add(task)
936
+ task.add_done_callback(service._tasks.discard)
937
+ cancelled = False
938
+ try:
939
+ await asyncio.wait((task,))
940
+ return task.result()
941
+ except asyncio.CancelledError:
942
+ cancelled = True
943
+ raise
944
+ finally:
945
+ if cancelled and not task.done() and not task.cancelling():
946
+ task.cancel()
947
+ interrupted = await join_owned(task)
948
+ self._release_operation()
949
+ if (
950
+ not preserve_cancellation
951
+ and not task.cancelled()
952
+ and (error := task.exception()) is not None
953
+ and isinstance(error, LibrusError)
954
+ and error.kind is ErrorKind.CHECKPOINT
955
+ ):
956
+ raise LibrusError(ErrorKind.CHECKPOINT) from None
957
+ if cancelled or interrupted:
958
+ if service._closed and not preserve_cancellation:
959
+ raise LibrusError(ErrorKind.CLOSED) from None
960
+ raise asyncio.CancelledError
961
+
962
+ async def _execute_uncached[T](
963
+ self,
964
+ operation: OperationName,
965
+ fetch: Fetch[T],
966
+ budget: RequestBudget,
967
+ authenticate: bool,
968
+ deadline: asyncio.Timeout | None = None,
969
+ ) -> T:
970
+ started = time.monotonic()
971
+ outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
972
+ try:
973
+ if deadline is None:
974
+ deadline = asyncio.timeout(None)
975
+ async with deadline:
976
+ deadline.reschedule(
977
+ asyncio.get_running_loop().time() + budget.remaining_seconds()
978
+ )
979
+ async with self._lock:
980
+ if authenticate:
981
+ self._check_cooldown("authentication")
982
+ self._check_cooldown(operation)
983
+ return await self._authenticated(fetch, budget, False)
984
+ return await fetch(budget, False)
985
+ except LibrusError as error:
986
+ outcome = error.kind
987
+ if authenticate and error.kind in (
988
+ ErrorKind.ACCESS_DENIED,
989
+ ErrorKind.SESSION_EXPIRED,
990
+ ):
991
+ self._cooldowns[operation] = (
992
+ time.monotonic() + self._service._transport_limits.cooldown_seconds,
993
+ error.kind,
994
+ )
995
+ raise
996
+ except asyncio.CancelledError:
997
+ outcome = "cancelled"
998
+ raise
999
+ except TimeoutError:
1000
+ outcome = ErrorKind.TIMEOUT
1001
+ raise LibrusError(ErrorKind.TIMEOUT) from None
1002
+ except Exception:
1003
+ # A custom transport must not leak private exception text or
1004
+ # produce a misleading success diagnostic at this owner boundary.
1005
+ outcome = ErrorKind.CONNECTION
1006
+ finally:
1007
+ self._emit(
1008
+ DiagnosticEvent(
1009
+ operation,
1010
+ outcome,
1011
+ time.monotonic() - started,
1012
+ budget.requests_dispatched,
1013
+ budget.response_bytes,
1014
+ )
1015
+ )
1016
+
1017
+ raise LibrusError(ErrorKind.CONNECTION)
1018
+
1019
+ async def identity(
1020
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1021
+ ) -> Identity:
1022
+ async def fetch(budget: RequestBudget, fresh_login: bool) -> Identity:
1023
+ # A login already read the identity; reading it again is wasted.
1024
+ if not fresh_login:
1025
+ self._identity = await self._fetch_identity(budget)
1026
+ return self._session_identity()
1027
+
1028
+ return await self._read(("identity",), fetch, budget, max_age_seconds)
1029
+
1030
+ async def student_information(
1031
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1032
+ ) -> StudentInformation:
1033
+ async def fetch(budget: RequestBudget, _: bool) -> StudentInformation:
1034
+ fields = await self._page("student_information", budget, parse_profile)
1035
+ return StudentInformation(
1036
+ self._session_identity(),
1037
+ fields.name,
1038
+ fields.class_name,
1039
+ fields.register_number,
1040
+ fields.tutor,
1041
+ fields.school,
1042
+ fields.lucky_number,
1043
+ self._observation("student_information"),
1044
+ )
1045
+
1046
+ return await self._read(
1047
+ ("student_information",), fetch, budget, max_age_seconds
1048
+ )
1049
+
1050
+ async def final_grades(
1051
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1052
+ ) -> FinalGrades:
1053
+ async def fetch(budget: RequestBudget, _: bool) -> FinalGrades:
1054
+ items = await self._page("final_grades", budget, parse_final_grades)
1055
+ return FinalGrades(
1056
+ self._session_identity(), items, self._observation("final_grades")
1057
+ )
1058
+
1059
+ return await self._read(("final_grades",), fetch, budget, max_age_seconds)
1060
+
1061
+ async def grades(
1062
+ self,
1063
+ *,
1064
+ view: GradeView = GradeView.ALL,
1065
+ budget: RequestBudget | None = None,
1066
+ max_age_seconds: float = 0.0,
1067
+ ) -> Grades:
1068
+ """Select a grade view with one non-replayed view-changing POST.
1069
+
1070
+ Changes the selected grade filter, not school records. A cache hit makes
1071
+ no POST. Missing inline weight/count data remains unknown.
1072
+ """
1073
+ form = grade_view_form(view)
1074
+
1075
+ async def fetch(budget: RequestBudget, _: bool) -> Grades:
1076
+ records = await self._page("grades", budget, parse_grade_records, form=form)
1077
+ return Grades(
1078
+ self._session_identity(), records, self._observation("grades"), view
1079
+ )
1080
+
1081
+ return await self._read(("grades", view), fetch, budget, max_age_seconds)
1082
+
1083
+ async def grades_window(
1084
+ self,
1085
+ start: date | None = None,
1086
+ end: date | None = None,
1087
+ *,
1088
+ view: GradeView = GradeView.ALL,
1089
+ budget: RequestBudget | None = None,
1090
+ max_age_seconds: float = 0.0,
1091
+ ) -> GradeWindow:
1092
+ """Optional inclusive dates, at most 370 days apart when both are supplied.
1093
+
1094
+ Filters the selected upstream view, sharing its cache and coalescing.
1095
+ Averages and undated summaries are not dated rows.
1096
+ """
1097
+ _require_dates(start, end, max_days=GRADE_MAX_WINDOW_DAYS)
1098
+ result = await self.grades(
1099
+ view=view, budget=budget, max_age_seconds=max_age_seconds
1100
+ )
1101
+ return GradeWindow(
1102
+ result.identity,
1103
+ start,
1104
+ end,
1105
+ tuple(
1106
+ g
1107
+ for g in result.records.numeric
1108
+ if (start is None or start <= g.day) and (end is None or g.day <= end)
1109
+ ),
1110
+ tuple(
1111
+ g
1112
+ for g in result.records.descriptive
1113
+ if (start is None or start <= g.day) and (end is None or g.day <= end)
1114
+ ),
1115
+ result.observation,
1116
+ result.view,
1117
+ )
1118
+
1119
+ async def attendance(
1120
+ self,
1121
+ *,
1122
+ view: AttendanceView = AttendanceView.ALL,
1123
+ budget: RequestBudget | None = None,
1124
+ max_age_seconds: float = 0.0,
1125
+ ) -> Attendance:
1126
+ """Read a fixed upstream attendance view without replaying its POST."""
1127
+ form = attendance_view_form(view)
1128
+
1129
+ async def fetch(budget: RequestBudget, _: bool) -> Attendance:
1130
+ records = await self._page(
1131
+ "attendance", budget, parse_attendance, form=form
1132
+ )
1133
+ return Attendance(
1134
+ self._session_identity(),
1135
+ records.items,
1136
+ records.semesters,
1137
+ self._observation("attendance"),
1138
+ view,
1139
+ )
1140
+
1141
+ return await self._read(("attendance", view), fetch, budget, max_age_seconds)
1142
+
1143
+ async def attendance_window(
1144
+ self,
1145
+ start: date | None = None,
1146
+ end: date | None = None,
1147
+ *,
1148
+ view: AttendanceView = AttendanceView.ALL,
1149
+ budget: RequestBudget | None = None,
1150
+ max_age_seconds: float = 0.0,
1151
+ ) -> AttendanceWindow:
1152
+ """Optional inclusive civil dates over one explicit cached upstream view."""
1153
+ _require_dates(start, end, max_days=ATTENDANCE_MAX_WINDOW_DAYS)
1154
+ result = await self.attendance(
1155
+ view=view, budget=budget, max_age_seconds=max_age_seconds
1156
+ )
1157
+ return AttendanceWindow(
1158
+ result.identity,
1159
+ start,
1160
+ end,
1161
+ tuple(
1162
+ row
1163
+ for row in result.items
1164
+ if (start is None or start <= row.day)
1165
+ and (end is None or row.day <= end)
1166
+ ),
1167
+ result.observation,
1168
+ result.view,
1169
+ )
1170
+
1171
+ async def attendance_detail(
1172
+ self,
1173
+ detail_id: str,
1174
+ *,
1175
+ budget: RequestBudget | None = None,
1176
+ max_age_seconds: float = 0.0,
1177
+ ) -> AttendanceDetail:
1178
+ if type(detail_id) is not str or not NUMERIC_ID.fullmatch(detail_id):
1179
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1180
+
1181
+ async def fetch(budget: RequestBudget, _: bool) -> AttendanceDetail:
1182
+ content = await self._page(
1183
+ "attendance_detail",
1184
+ budget,
1185
+ parse_attendance_detail,
1186
+ reference=detail_id,
1187
+ )
1188
+ return AttendanceDetail(
1189
+ self._session_identity(),
1190
+ detail_id,
1191
+ content.fields,
1192
+ content.notes,
1193
+ self._observation("attendance_detail"),
1194
+ normalize_detail_fields(content.fields, "attendance"),
1195
+ )
1196
+
1197
+ return await self._read(
1198
+ ("attendance_detail", detail_id), fetch, budget, max_age_seconds
1199
+ )
1200
+
1201
+ async def gateway_attendance(
1202
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1203
+ ) -> GatewayAttendance:
1204
+ async def fetch(budget: RequestBudget, _: bool) -> GatewayAttendance:
1205
+ items = await self._page(
1206
+ "gateway_attendance",
1207
+ budget,
1208
+ parse_gateway_attendance,
1209
+ content_type=JSON,
1210
+ )
1211
+ return GatewayAttendance(
1212
+ self._session_identity(),
1213
+ items,
1214
+ self._observation("gateway_attendance"),
1215
+ )
1216
+
1217
+ return await self._read(("gateway_attendance",), fetch, budget, max_age_seconds)
1218
+
1219
+ async def attendance_frequency(
1220
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1221
+ ) -> AttendanceFrequency:
1222
+ rows = await self.gateway_attendance(
1223
+ budget=budget, max_age_seconds=max_age_seconds
1224
+ )
1225
+
1226
+ def semester(number: int) -> tuple[GatewayAttendanceRecord, ...]:
1227
+ return tuple(r for r in rows.items if r.semester == number)
1228
+
1229
+ return AttendanceFrequency(
1230
+ rows.identity,
1231
+ summarize_frequency(semester(1), subject_policy=False),
1232
+ summarize_frequency(semester(2), subject_policy=False),
1233
+ summarize_frequency(rows.items, subject_policy=False),
1234
+ rows.observation,
1235
+ )
1236
+
1237
+ async def subject_frequency(
1238
+ self,
1239
+ start: date | None = None,
1240
+ end: date | None = None,
1241
+ *,
1242
+ budget: RequestBudget | None = None,
1243
+ max_age_seconds: float = 0.0,
1244
+ ) -> SubjectFrequencies:
1245
+ _require_dates(start, end, max_days=ATTENDANCE_MAX_WINDOW_DAYS)
1246
+
1247
+ async def fetch(budget: RequestBudget, _: bool) -> SubjectFrequencies:
1248
+ return await self._subject_frequencies(budget, start, end)
1249
+
1250
+ return await self._read(
1251
+ ("subject_frequency", start, end),
1252
+ fetch,
1253
+ budget,
1254
+ max_age_seconds,
1255
+ endpoint="gateway_attendance",
1256
+ )
1257
+
1258
+ async def timetable(
1259
+ self,
1260
+ monday: date,
1261
+ *,
1262
+ budget: RequestBudget | None = None,
1263
+ max_age_seconds: float = 0.0,
1264
+ ) -> Timetable:
1265
+ """Select an explicit school civil week; never replay its selection POST."""
1266
+ form = timetable_form(monday)
1267
+
1268
+ async def fetch(budget: RequestBudget, _: bool) -> Timetable:
1269
+ days = await self._page(
1270
+ "timetable",
1271
+ budget,
1272
+ lambda body: parse_timetable(body, monday),
1273
+ form=form,
1274
+ )
1275
+ return Timetable(
1276
+ self._session_identity(), monday, days, self._observation("timetable")
1277
+ )
1278
+
1279
+ return await self._read(("timetable", monday), fetch, budget, max_age_seconds)
1280
+
1281
+ async def announcements(
1282
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1283
+ ) -> Announcements:
1284
+ """Read ordinary announcements, with full bounded text and inert references."""
1285
+
1286
+ async def fetch(budget: RequestBudget, _: bool) -> Announcements:
1287
+ items = await self._page(
1288
+ "announcements",
1289
+ budget,
1290
+ lambda body: parse_announcements(body, self._alias),
1291
+ )
1292
+ return Announcements(
1293
+ self._session_identity(), items, self._observation("announcements")
1294
+ )
1295
+
1296
+ return await self._read(("announcements",), fetch, budget, max_age_seconds)
1297
+
1298
+ async def agenda(
1299
+ self,
1300
+ year: int,
1301
+ month: int,
1302
+ *,
1303
+ budget: RequestBudget | None = None,
1304
+ max_age_seconds: float = 0.0,
1305
+ ) -> Agenda:
1306
+ form = agenda_form(year, month)
1307
+
1308
+ async def fetch(budget: RequestBudget, _: bool) -> Agenda:
1309
+ days = await self._page(
1310
+ "agenda",
1311
+ budget,
1312
+ lambda body: parse_agenda(body, year, month, self._alias),
1313
+ form=form,
1314
+ )
1315
+ return Agenda(
1316
+ self._session_identity(),
1317
+ year,
1318
+ month,
1319
+ days,
1320
+ self._observation("agenda"),
1321
+ )
1322
+
1323
+ return await self._read(("agenda", year, month), fetch, budget, max_age_seconds)
1324
+
1325
+ async def agenda_detail(
1326
+ self,
1327
+ reference: SchoolReference,
1328
+ *,
1329
+ budget: RequestBudget | None = None,
1330
+ max_age_seconds: float = 0.0,
1331
+ ) -> SchoolDetail:
1332
+ return await self._school_detail(
1333
+ "agenda_detail", reference, budget, max_age_seconds
1334
+ )
1335
+
1336
+ async def homework(
1337
+ self,
1338
+ start: date,
1339
+ end: date,
1340
+ *,
1341
+ budget: RequestBudget | None = None,
1342
+ max_age_seconds: float = 0.0,
1343
+ ) -> Homework:
1344
+ """Assignments in an inclusive window of at most one calendar month."""
1345
+ form = homework_form(start, end)
1346
+
1347
+ async def fetch(budget: RequestBudget, _: bool) -> Homework:
1348
+ items = await self._page(
1349
+ "homework",
1350
+ budget,
1351
+ lambda body: parse_homework(body, self._alias),
1352
+ form=form,
1353
+ )
1354
+ return Homework(
1355
+ self._session_identity(),
1356
+ start,
1357
+ end,
1358
+ items,
1359
+ self._observation("homework"),
1360
+ )
1361
+
1362
+ return await self._read(
1363
+ ("homework", start, end), fetch, budget, max_age_seconds
1364
+ )
1365
+
1366
+ async def homework_range(
1367
+ self,
1368
+ request: HomeworkRangeRequest,
1369
+ *,
1370
+ budget: RequestBudget | None = None,
1371
+ max_age_seconds: float = 0.0,
1372
+ ) -> Homework:
1373
+ """Collect up to 370 days using bounded monthly selections and one budget.
1374
+
1375
+ Returns all selections or raises, with no partial cache publication.
1376
+ Dates retain the upstream selection semantics, not an inferred due-date
1377
+ filter. Identical reference-bearing rows are deduplicated in first-seen
1378
+ order; conflicting observations fail rather than choosing a version.
1379
+ """
1380
+ forms = homework_range_forms(request)
1381
+
1382
+ async def fetch(budget: RequestBudget, _: bool) -> Homework:
1383
+ accumulator = HomeworkAccumulator(request.max_items)
1384
+ for form in forms:
1385
+ items = await self._page(
1386
+ "homework",
1387
+ budget,
1388
+ lambda body: parse_homework(body, self._alias),
1389
+ form=form,
1390
+ )
1391
+ accumulator.extend(items)
1392
+ return Homework(
1393
+ self._session_identity(),
1394
+ request.start,
1395
+ request.end,
1396
+ tuple(accumulator.items),
1397
+ self._observation("homework"),
1398
+ )
1399
+
1400
+ return await self._read(
1401
+ ("homework", "range", request), fetch, budget, max_age_seconds
1402
+ )
1403
+
1404
+ async def homework_detail(
1405
+ self,
1406
+ reference: SchoolReference,
1407
+ *,
1408
+ budget: RequestBudget | None = None,
1409
+ max_age_seconds: float = 0.0,
1410
+ ) -> SchoolDetail:
1411
+ return await self._school_detail(
1412
+ "homework_detail", reference, budget, max_age_seconds
1413
+ )
1414
+
1415
+ async def completed_lessons_page(
1416
+ self,
1417
+ start: date,
1418
+ end: date,
1419
+ *,
1420
+ page: int = 0,
1421
+ budget: RequestBudget | None = None,
1422
+ max_age_seconds: float = 0.0,
1423
+ ) -> CompletedLessonsPage:
1424
+ completed_lessons_form(start, end, page)
1425
+
1426
+ async def fetch(budget: RequestBudget, _: bool) -> CompletedLessonsPage:
1427
+ return await self._lesson_page(start, end, page, budget)
1428
+
1429
+ return await self._read(
1430
+ ("completed_lessons", "page", start, end, page),
1431
+ fetch,
1432
+ budget,
1433
+ max_age_seconds,
1434
+ )
1435
+
1436
+ async def completed_lessons(
1437
+ self,
1438
+ start: date,
1439
+ end: date,
1440
+ *,
1441
+ cursor: CompletedLessonsCursor | None = None,
1442
+ max_pages: int = 4,
1443
+ limit: int = 128,
1444
+ budget: RequestBudget | None = None,
1445
+ max_age_seconds: float = 0.0,
1446
+ ) -> CompletedLessons:
1447
+ validate_selection(start, end, cursor, max_pages, limit, self._alias)
1448
+
1449
+ async def fetch(budget: RequestBudget, _: bool) -> CompletedLessons:
1450
+ return await self._lesson_batch(
1451
+ start, end, cursor, max_pages, limit, budget
1452
+ )
1453
+
1454
+ return await self._read(
1455
+ ("completed_lessons", "batch", start, end, cursor, max_pages, limit),
1456
+ fetch,
1457
+ budget,
1458
+ max_age_seconds,
1459
+ )
1460
+
1461
+ def stream_modern_attachment(
1462
+ self,
1463
+ reference: ModernMessageAttachmentReference,
1464
+ *,
1465
+ max_bytes: int = ATTACHMENT_MAX_BYTES,
1466
+ budget: RequestBudget | None = None,
1467
+ ) -> ModernAttachmentStream:
1468
+ """Uncached explicit modern download; never opens message content."""
1469
+ return ModernAttachmentStream(
1470
+ self, reference, max_bytes=max_bytes, budget=budget
1471
+ )
1472
+
1473
+ def stream_attachment(
1474
+ self,
1475
+ reference: MessageAttachmentReference,
1476
+ *,
1477
+ max_bytes: int = ATTACHMENT_MAX_BYTES,
1478
+ budget: RequestBudget | None = None,
1479
+ ) -> AttachmentStream:
1480
+ """Construct an uncached stream context; no content open or file writes."""
1481
+ return AttachmentStream(self, reference, max_bytes=max_bytes, budget=budget)
1482
+
1483
+ async def message_content(
1484
+ self,
1485
+ reference: MessageReference,
1486
+ *,
1487
+ allow_mark_read: bool = False,
1488
+ budget: RequestBudget | None = None,
1489
+ max_age_seconds: float = 0.0,
1490
+ ) -> MessageContent:
1491
+ """Open one message; received opens require explicit read-effect consent."""
1492
+ validate_reference(reference, self._alias)
1493
+ if type(allow_mark_read) is not bool or (
1494
+ reference.folder is MessageFolder.RECEIVED and not allow_mark_read
1495
+ ):
1496
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1497
+ operation: Literal["message_content_received", "message_content_sent"] = (
1498
+ "message_content_received"
1499
+ if reference.folder is MessageFolder.RECEIVED
1500
+ else "message_content_sent"
1501
+ )
1502
+
1503
+ async def fetch(budget: RequestBudget, _: bool) -> MessageContent:
1504
+ # An upstream read effect can occur even if dispatch, parsing or
1505
+ # cancellation later fails. Never retain pre-open mailbox summaries.
1506
+ if reference.folder is MessageFolder.RECEIVED:
1507
+ for key in tuple(self._cache):
1508
+ if isinstance(key, tuple) and key[0] == "messages_received":
1509
+ del self._cache[key]
1510
+ content = await self._page(
1511
+ operation,
1512
+ budget,
1513
+ lambda body: parse_message_content(body, reference),
1514
+ reference=reference.identifier,
1515
+ )
1516
+ return MessageContent(
1517
+ self._session_identity(),
1518
+ content,
1519
+ reference.folder is MessageFolder.RECEIVED,
1520
+ self._observation(operation),
1521
+ )
1522
+
1523
+ return await self._read((operation, reference), fetch, budget, max_age_seconds)
1524
+
1525
+ async def messages_page(
1526
+ self,
1527
+ folder: MessageFolder = MessageFolder.RECEIVED,
1528
+ *,
1529
+ page: int = 0,
1530
+ budget: RequestBudget | None = None,
1531
+ max_age_seconds: float = 0.0,
1532
+ ) -> MessagesPage:
1533
+ """One explicit mailbox page; no body open, mark-read or send."""
1534
+ message_page_form(folder, page)
1535
+ operation: Literal["messages_received", "messages_sent"] = (
1536
+ "messages_received" if folder is MessageFolder.RECEIVED else "messages_sent"
1537
+ )
1538
+
1539
+ async def fetch(budget: RequestBudget, _: bool) -> MessagesPage:
1540
+ return await self._message_page(folder, page, budget)
1541
+
1542
+ return await self._read(
1543
+ (operation, "page", page), fetch, budget, max_age_seconds
1544
+ )
1545
+
1546
+ async def messages(
1547
+ self,
1548
+ folder: MessageFolder = MessageFolder.RECEIVED,
1549
+ *,
1550
+ cursor: MessagesCursor | None = None,
1551
+ max_pages: int = 4,
1552
+ limit: int = 128,
1553
+ budget: RequestBudget | None = None,
1554
+ max_age_seconds: float = 0.0,
1555
+ ) -> Messages:
1556
+ """Bounded, deduplicated continuation under one account operation/budget."""
1557
+ validate_message_selection(folder, cursor, max_pages, limit, self._alias)
1558
+ operation: Literal["messages_received", "messages_sent"] = (
1559
+ "messages_received" if folder is MessageFolder.RECEIVED else "messages_sent"
1560
+ )
1561
+
1562
+ async def fetch(budget: RequestBudget, _: bool) -> Messages:
1563
+ return await self._message_batch(folder, cursor, max_pages, limit, budget)
1564
+
1565
+ return await self._read(
1566
+ (operation, "batch", cursor, max_pages, limit),
1567
+ fetch,
1568
+ budget,
1569
+ max_age_seconds,
1570
+ )
1571
+
1572
+ async def recipient_groups(
1573
+ self,
1574
+ *,
1575
+ budget: RequestBudget | None = None,
1576
+ max_age_seconds: float = 0.0,
1577
+ ) -> RecipientGroups:
1578
+ """Discover named group types; never send or open existing messages."""
1579
+
1580
+ async def fetch(budget: RequestBudget, _: bool) -> RecipientGroups:
1581
+ groups = await self._page(
1582
+ "recipient_groups",
1583
+ budget,
1584
+ lambda body: parse_recipient_groups(body, self._alias),
1585
+ )
1586
+ return RecipientGroups(
1587
+ self._session_identity(), groups, self._observation("recipient_groups")
1588
+ )
1589
+
1590
+ return await self._read(("recipient_groups",), fetch, budget, max_age_seconds)
1591
+
1592
+ async def recipients(
1593
+ self,
1594
+ group: RecipientGroupReference,
1595
+ *,
1596
+ budget: RequestBudget | None = None,
1597
+ max_age_seconds: float = 0.0,
1598
+ ) -> Recipients:
1599
+ """One typed group lookup; duplicate labels never overwrite distinct IDs."""
1600
+ validate_group(group, self._alias)
1601
+
1602
+ async def fetch(budget: RequestBudget, _: bool) -> Recipients:
1603
+ items = await self._page(
1604
+ "recipients",
1605
+ budget,
1606
+ lambda body: parse_recipients(body, group),
1607
+ form=recipient_form(group.identifier, selection_id=group.selection_id),
1608
+ )
1609
+ return Recipients(
1610
+ self._session_identity(), group, items, self._observation("recipients")
1611
+ )
1612
+
1613
+ return await self._read(
1614
+ ("recipients", group.identifier, group.selection_id),
1615
+ fetch,
1616
+ budget,
1617
+ max_age_seconds,
1618
+ )
1619
+
1620
+ async def recipient_group_choices(
1621
+ self,
1622
+ group: RecipientGroupReference,
1623
+ *,
1624
+ budget: RequestBudget | None = None,
1625
+ max_age_seconds: float = 0.0,
1626
+ ) -> RecipientGroupChoices:
1627
+ """Discover bounded nonzero group options, never guess virtual classes."""
1628
+ validate_group(group, self._alias, choices=True)
1629
+
1630
+ async def fetch(budget: RequestBudget, _: bool) -> RecipientGroupChoices:
1631
+ items = await self._page(
1632
+ "recipients",
1633
+ budget,
1634
+ lambda body: parse_recipient_group_choices(body, group),
1635
+ form=recipient_form(group.identifier),
1636
+ )
1637
+ return RecipientGroupChoices(
1638
+ self._session_identity(), group, items, self._observation("recipients")
1639
+ )
1640
+
1641
+ return await self._read(
1642
+ ("recipients", "choices", group.identifier), fetch, budget, max_age_seconds
1643
+ )
1644
+
1645
+ # Shared read machinery.
1646
+
1647
+ async def _school_detail(
1648
+ self,
1649
+ operation: Literal["agenda_detail", "homework_detail"],
1650
+ reference: SchoolReference,
1651
+ budget: RequestBudget | None,
1652
+ max_age_seconds: float,
1653
+ ) -> SchoolDetail:
1654
+ kind = operation.removesuffix("_detail")
1655
+ if (
1656
+ not isinstance(reference, SchoolReference)
1657
+ or reference.kind != kind
1658
+ or reference.account != self._alias
1659
+ or type(reference.identifier) is not str
1660
+ or not NUMERIC_ID.fullmatch(reference.identifier)
1661
+ ):
1662
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1663
+
1664
+ async def fetch(budget: RequestBudget, _: bool) -> SchoolDetail:
1665
+ title, fields, notes = await self._page(
1666
+ operation,
1667
+ budget,
1668
+ parse_school_detail,
1669
+ reference=reference.identifier,
1670
+ )
1671
+ return SchoolDetail(
1672
+ self._session_identity(),
1673
+ reference,
1674
+ title,
1675
+ fields,
1676
+ notes,
1677
+ self._observation(operation),
1678
+ normalize_detail_fields(fields, reference.kind),
1679
+ )
1680
+
1681
+ return await self._read(
1682
+ (operation, reference.identifier), fetch, budget, max_age_seconds
1683
+ )
1684
+
1685
+ async def _read[T](
1686
+ self,
1687
+ key: tuple[OperationName, *tuple[Hashable, ...]],
1688
+ fetch: Fetch[T],
1689
+ budget: RequestBudget | None,
1690
+ max_age: float,
1691
+ *,
1692
+ endpoint: str | None = None,
1693
+ ) -> T:
1694
+ """Admit, coalesce, and cache one read; the work runs as an owned task.
1695
+
1696
+ The key starts with the operation name and identifies the selection.
1697
+ """
1698
+ operation = key[0]
1699
+ service = self._service
1700
+ service._bind()
1701
+ if (
1702
+ type(max_age) not in (int, float)
1703
+ or not math.isfinite(max_age)
1704
+ or not 0 <= max_age <= MAX_AGE_LIMIT_SECONDS
1705
+ ):
1706
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1707
+ if budget is not None:
1708
+ budget._bind_loop()
1709
+ budget.remaining_seconds()
1710
+ # Resolve policy before admission so a lookup failure cannot leak a slot.
1711
+ retry_safe = ENDPOINTS[endpoint or operation].retry_safe
1712
+ self._admit_operation()
1713
+ flight_key = (key, max_age, budget)
1714
+ flight = self._flights.get(flight_key)
1715
+ if flight is None:
1716
+ task = asyncio.create_task(
1717
+ self._execute(
1718
+ operation,
1719
+ key,
1720
+ fetch,
1721
+ budget or service._budget(),
1722
+ max_age,
1723
+ retry_safe,
1724
+ )
1725
+ )
1726
+ flight = _Flight(task)
1727
+ self._flights[flight_key] = flight
1728
+ service._tasks.add(task)
1729
+ task.add_done_callback(service._tasks.discard)
1730
+ flight.waiters += 1
1731
+ try:
1732
+ return cast(T, await asyncio.shield(flight.task))
1733
+ except asyncio.CancelledError:
1734
+ if service._closed:
1735
+ raise LibrusError(ErrorKind.CLOSED) from None
1736
+ raise
1737
+ finally:
1738
+ flight.waiters -= 1
1739
+ if flight.waiters == 0:
1740
+ # Remove before joining so new callers never inherit cancellation.
1741
+ if self._flights.get(flight_key) is flight:
1742
+ del self._flights[flight_key]
1743
+ if not flight.task.done() and not flight.task.cancelling():
1744
+ flight.task.cancel()
1745
+ await join_owned(flight.task)
1746
+ self._release_operation()
1747
+
1748
+ def _admit_operation(self) -> None:
1749
+ service = self._service
1750
+ if (
1751
+ service._operations >= service._limits.operations
1752
+ or self._operations >= service._limits.operations_per_account
1753
+ ):
1754
+ raise LibrusError(ErrorKind.LIMIT)
1755
+ service._operations += 1
1756
+ self._operations += 1
1757
+
1758
+ def _release_operation(self) -> None:
1759
+ self._service._operations -= 1
1760
+ self._operations -= 1
1761
+ assert self._operations >= 0
1762
+ assert self._service._operations >= 0
1763
+
1764
+ async def _execute[T](
1765
+ self,
1766
+ operation: OperationName,
1767
+ key: Hashable,
1768
+ fetch: Fetch[T],
1769
+ budget: RequestBudget,
1770
+ max_age: float,
1771
+ retry_safe: bool,
1772
+ ) -> T:
1773
+ started = time.monotonic()
1774
+ outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
1775
+ try:
1776
+ async with asyncio.timeout(budget.remaining_seconds()):
1777
+ async with self._lock:
1778
+ return await self._cached(
1779
+ operation, key, fetch, budget, max_age, retry_safe
1780
+ )
1781
+ except LibrusError as error:
1782
+ outcome = error.kind
1783
+ raise
1784
+ except asyncio.CancelledError:
1785
+ outcome = "cancelled"
1786
+ raise
1787
+ except TimeoutError:
1788
+ outcome = ErrorKind.TIMEOUT
1789
+ raise LibrusError(ErrorKind.TIMEOUT) from None
1790
+ finally:
1791
+ self._emit(
1792
+ DiagnosticEvent(
1793
+ operation,
1794
+ outcome,
1795
+ time.monotonic() - started,
1796
+ budget.requests_dispatched,
1797
+ budget.response_bytes,
1798
+ )
1799
+ )
1800
+
1801
+ def _emit(self, event: DiagnosticEvent) -> None:
1802
+ sink = self._service._diagnostic_sink
1803
+ if sink is None:
1804
+ return
1805
+ # Diagnostics must never replace an operation result or expose errors
1806
+ # from caller-supplied sinks.
1807
+ try:
1808
+ sink(event)
1809
+ except Exception:
1810
+ pass
1811
+
1812
+ async def _cached[T](
1813
+ self,
1814
+ operation: OperationName,
1815
+ key: Hashable,
1816
+ fetch: Fetch[T],
1817
+ budget: RequestBudget,
1818
+ max_age: float,
1819
+ retry_safe: bool,
1820
+ ) -> T:
1821
+ self._check_cooldown("authentication")
1822
+ self._check_cooldown(operation)
1823
+ cached = self._cache.get(key)
1824
+ if max_age > 0 and cached and time.monotonic() - cached[0] <= max_age:
1825
+ return cast(T, cached[1])
1826
+ try:
1827
+ result = await self._authenticated(fetch, budget, retry_safe)
1828
+ except LibrusError as error:
1829
+ if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
1830
+ self._cooldowns[operation] = (
1831
+ time.monotonic() + self._service._transport_limits.cooldown_seconds,
1832
+ error.kind,
1833
+ )
1834
+ raise
1835
+ self._cache[key] = (time.monotonic(), result)
1836
+ if len(self._cache) > ATTENDANCE_RESULT_CACHE_SIZE:
1837
+ del self._cache[next(iter(self._cache))]
1838
+ return result
1839
+
1840
+ async def _authenticated[T](
1841
+ self, fetch: Fetch[T], budget: RequestBudget, retry_safe: bool
1842
+ ) -> T:
1843
+ """Run a read in a session; recover proven expiry once for safe reads.
1844
+
1845
+ Initial login is never retried: a failed credential submission must not
1846
+ be replayed. A view-changing POST is never replayed either; expiry only
1847
+ clears the session so a later explicit call can log in again.
1848
+ """
1849
+ fresh_login = self._identity is None
1850
+ if fresh_login:
1851
+ await self._authenticate(budget)
1852
+ try:
1853
+ return await fetch(budget, fresh_login)
1854
+ except SessionExpiredError as error:
1855
+ modern_expired = error._messages_origin
1856
+ if modern_expired:
1857
+ self._invalidate_modern()
1858
+ else:
1859
+ self._invalidate()
1860
+ if not retry_safe:
1861
+ raise
1862
+ if not modern_expired:
1863
+ await self._authenticate(budget)
1864
+ try:
1865
+ return await fetch(budget, not modern_expired)
1866
+ except SessionExpiredError as error:
1867
+ if error._messages_origin:
1868
+ self._invalidate_modern()
1869
+ else:
1870
+ self._invalidate()
1871
+ raise
1872
+
1873
+ async def _page[T](
1874
+ self,
1875
+ endpoint: str,
1876
+ budget: RequestBudget,
1877
+ parse: Callable[[bytes], T],
1878
+ *,
1879
+ form: RequestForm = None,
1880
+ reference: str | None = None,
1881
+ content_type: str = HTML,
1882
+ query: Mapping[str, str] | None = None,
1883
+ ) -> T:
1884
+ """One authenticated request whose body a pure parser turns into data."""
1885
+ try:
1886
+ if query is None:
1887
+ response = await self._transport.request(
1888
+ endpoint, budget, form=form, reference_id=reference
1889
+ )
1890
+ else:
1891
+ response = await self._transport.request(
1892
+ endpoint, budget, form=form, reference_id=reference, query=query
1893
+ )
1894
+ self._validate_read_response(response, content_type)
1895
+ return await self._service._parsers.run(parse, response.body, budget)
1896
+ except BaseException as error:
1897
+ if ENDPOINTS[endpoint].origin == "messages":
1898
+ self._invalidate_modern()
1899
+ if isinstance(error, SessionExpiredError):
1900
+ error._messages_origin = True
1901
+ raise
1902
+
1903
+ def _check_cooldown(self, key: str) -> None:
1904
+ cooldown = self._cooldowns.get(key)
1905
+ if cooldown and time.monotonic() < cooldown[0]:
1906
+ raise LibrusError(cooldown[1])
1907
+
1908
+ def _invalidate(self) -> None:
1909
+ self._identity = None
1910
+ self._modern_account = None
1911
+ self._cache.clear()
1912
+ self._metadata.clear()
1913
+ self._transport.clear_auth()
1914
+
1915
+ def _invalidate_modern(self) -> None:
1916
+ self._modern_account = None
1917
+ for key in tuple(self._cache):
1918
+ if isinstance(key, tuple) and str(key[0]).startswith("modern_"):
1919
+ del self._cache[key]
1920
+ if self._transport_instance is not None:
1921
+ clear = getattr(self._transport_instance, "clear_modern_auth", None)
1922
+ if callable(clear):
1923
+ clear()
1924
+
1925
+ def _session_identity(self) -> Identity:
1926
+ assert self._identity is not None
1927
+ return self._identity
1928
+
1929
+ def _observation(self, source: str) -> Observation:
1930
+ return Observation(self._alias, datetime.now(UTC), self._generation, source)
1931
+
1932
+ # Multi-request reads.
1933
+
1934
+ async def _message_page(
1935
+ self, folder: MessageFolder, page: int, budget: RequestBudget
1936
+ ) -> MessagesPage:
1937
+ operation = "messages_" + folder.value
1938
+ items, count, fingerprint = await self._page(
1939
+ operation,
1940
+ budget,
1941
+ lambda body: parse_messages(body, folder, page, self._alias),
1942
+ form=message_page_form(folder, page),
1943
+ )
1944
+ return MessagesPage(
1945
+ self._session_identity(),
1946
+ folder,
1947
+ page,
1948
+ count,
1949
+ items,
1950
+ fingerprint,
1951
+ self._observation(operation),
1952
+ )
1953
+
1954
+ async def _message_batch(
1955
+ self,
1956
+ folder: MessageFolder,
1957
+ cursor: MessagesCursor | None,
1958
+ max_pages: int,
1959
+ limit: int,
1960
+ budget: RequestBudget,
1961
+ ) -> Messages:
1962
+ page, offset = (cursor.page, cursor.offset) if cursor else (0, 0)
1963
+ count = cursor.page_count if cursor else None
1964
+ history = list(cursor.seen_ids) if cursor else []
1965
+ seen = set(history)
1966
+ fingerprints = {cursor.fingerprint} if cursor and not offset else set()
1967
+ items: list[MessageSummary] = []
1968
+ duplicates = 0
1969
+ next_cursor = None
1970
+ for fetched in range(1, max_pages + 1):
1971
+ result = await self._message_page(folder, page, budget)
1972
+ drifted = (
1973
+ cursor is not None
1974
+ and fetched == 1
1975
+ and offset > 0
1976
+ and cursor.fingerprint != result.fingerprint
1977
+ )
1978
+ if (
1979
+ (count is not None and count != result.page_count)
1980
+ or drifted
1981
+ or result.fingerprint in fingerprints
1982
+ or (offset and offset >= len(result.items))
1983
+ or (
1984
+ result.items
1985
+ and offset == 0
1986
+ and all(r.reference.identifier in seen for r in result.items)
1987
+ )
1988
+ ):
1989
+ raise LibrusError(ErrorKind.STALE_CURSOR)
1990
+ count = result.page_count
1991
+ fingerprints.add(result.fingerprint)
1992
+ while offset < len(result.items) and len(items) < limit:
1993
+ item = result.items[offset]
1994
+ offset += 1
1995
+ if item.reference.identifier in seen:
1996
+ duplicates += 1
1997
+ continue
1998
+ if len(seen) >= MESSAGE_MAX_CURSOR_IDS:
1999
+ raise LibrusError(ErrorKind.LIMIT)
2000
+ seen.add(item.reference.identifier)
2001
+ history.append(item.reference.identifier)
2002
+ items.append(item)
2003
+ if offset == len(result.items):
2004
+ if page + 1 == count:
2005
+ break
2006
+ page, offset = page + 1, 0
2007
+ if offset or len(items) == limit or fetched == max_pages:
2008
+ next_cursor = MessagesCursor(
2009
+ self._alias,
2010
+ folder,
2011
+ page,
2012
+ offset,
2013
+ count,
2014
+ result.fingerprint,
2015
+ tuple(history),
2016
+ )
2017
+ break
2018
+ return Messages(
2019
+ self._session_identity(),
2020
+ folder,
2021
+ tuple(items),
2022
+ fetched,
2023
+ duplicates,
2024
+ next_cursor,
2025
+ ("item_limit" if len(items) == limit else "page_limit")
2026
+ if next_cursor
2027
+ else None,
2028
+ self._observation("messages_" + folder.value),
2029
+ )
2030
+
2031
+ async def _lesson_page(
2032
+ self, start: date, end: date, page: int, budget: RequestBudget
2033
+ ) -> CompletedLessonsPage:
2034
+ items, count, fingerprint = await self._page(
2035
+ "completed_lessons",
2036
+ budget,
2037
+ lambda body: parse_completed_lessons(body, start, end, page),
2038
+ form=completed_lessons_form(start, end, page),
2039
+ )
2040
+ return CompletedLessonsPage(
2041
+ self._session_identity(),
2042
+ start,
2043
+ end,
2044
+ page,
2045
+ count,
2046
+ items,
2047
+ fingerprint,
2048
+ self._observation("completed_lessons"),
2049
+ )
2050
+
2051
+ async def _lesson_batch(
2052
+ self,
2053
+ start: date,
2054
+ end: date,
2055
+ cursor: CompletedLessonsCursor | None,
2056
+ max_pages: int,
2057
+ limit: int,
2058
+ budget: RequestBudget,
2059
+ ) -> CompletedLessons:
2060
+ """Read pages until the limit, the last page, or max_pages.
2061
+
2062
+ Pages are not a snapshot: a resumed page must still match the cursor's
2063
+ fingerprint, and a repeated page or changed page count fails the batch.
2064
+ """
2065
+ page, offset = (cursor.page, cursor.offset) if cursor else (0, 0)
2066
+ count = cursor.page_count if cursor else None
2067
+ seen = {cursor.fingerprint} if cursor and not cursor.offset else set()
2068
+ items: list[CompletedLesson] = []
2069
+ next_cursor = None
2070
+ fetched = 0
2071
+ while fetched < max_pages:
2072
+ fetched += 1
2073
+ result = await self._lesson_page(start, end, page, budget)
2074
+ drifted = (
2075
+ cursor is not None
2076
+ and fetched == 1
2077
+ and offset > 0
2078
+ and cursor.fingerprint != result.fingerprint
2079
+ )
2080
+ if (
2081
+ (count is not None and count != result.page_count)
2082
+ or drifted
2083
+ or result.fingerprint in seen
2084
+ or (offset and offset >= len(result.items))
2085
+ ):
2086
+ raise LibrusError(ErrorKind.STALE_CURSOR)
2087
+ count = result.page_count
2088
+ seen.add(result.fingerprint)
2089
+ take = min(len(result.items) - offset, limit - len(items))
2090
+ items.extend(result.items[offset : offset + take])
2091
+ offset += take
2092
+ if offset == len(result.items):
2093
+ if page + 1 == count:
2094
+ break
2095
+ page, offset = page + 1, 0
2096
+ if offset or len(items) == limit or fetched == max_pages:
2097
+ next_cursor = CompletedLessonsCursor(
2098
+ self._alias, start, end, page, offset, count, result.fingerprint
2099
+ )
2100
+ break
2101
+ return CompletedLessons(
2102
+ self._session_identity(),
2103
+ start,
2104
+ end,
2105
+ tuple(items),
2106
+ fetched,
2107
+ next_cursor,
2108
+ self._observation("completed_lessons"),
2109
+ )
2110
+
2111
+ async def _metadata_value(
2112
+ self, endpoint: str, identifier: str, budget: RequestBudget
2113
+ ) -> str:
2114
+ key = endpoint, identifier
2115
+ cached = self._metadata.get(key)
2116
+ if cached and time.monotonic() - cached[0] <= ATTENDANCE_METADATA_TTL_SECONDS:
2117
+ return cached[1]
2118
+ parser = (
2119
+ parse_lesson_subject
2120
+ if endpoint == "attendance_lesson"
2121
+ else parse_subject_name
2122
+ )
2123
+ value = await self._page(
2124
+ endpoint,
2125
+ budget,
2126
+ lambda body: parser(body, identifier),
2127
+ reference=identifier,
2128
+ content_type=JSON,
2129
+ )
2130
+ self._metadata[key] = time.monotonic(), value
2131
+ if len(self._metadata) > ATTENDANCE_METADATA_CACHE_SIZE:
2132
+ del self._metadata[next(iter(self._metadata))]
2133
+ return value
2134
+
2135
+ async def _subject_frequencies(
2136
+ self, budget: RequestBudget, start: date | None, end: date | None
2137
+ ) -> SubjectFrequencies:
2138
+ collection = await self._page(
2139
+ "gateway_attendance", budget, parse_gateway_attendance, content_type=JSON
2140
+ )
2141
+ observed = self._observation("gateway_attendance")
2142
+ rows = tuple(
2143
+ r
2144
+ for r in collection
2145
+ if (start is None or r.day >= start) and (end is None or r.day <= end)
2146
+ )
2147
+ lessons = dict.fromkeys(row.lesson_id for row in rows)
2148
+ if len(lessons) > ATTENDANCE_METADATA_CACHE_SIZE:
2149
+ raise LibrusError(ErrorKind.LIMIT)
2150
+ lesson_subjects = {
2151
+ lesson: await self._metadata_value("attendance_lesson", lesson, budget)
2152
+ for lesson in lessons
2153
+ }
2154
+ subjects: dict[str, list[GatewayAttendanceRecord]] = {}
2155
+ for row in rows:
2156
+ subjects.setdefault(lesson_subjects[row.lesson_id], []).append(row)
2157
+ if len(subjects) > ATTENDANCE_METADATA_CACHE_SIZE:
2158
+ raise LibrusError(ErrorKind.LIMIT)
2159
+ items = [
2160
+ SubjectFrequency(
2161
+ subject,
2162
+ await self._metadata_value("attendance_subject", subject, budget),
2163
+ summarize_frequency(tuple(records), subject_policy=True),
2164
+ )
2165
+ for subject, records in subjects.items()
2166
+ ]
2167
+ return SubjectFrequencies(
2168
+ self._session_identity(), tuple(items), start, end, observed
2169
+ )
2170
+
2171
+ # Authentication and response validation.
2172
+
2173
+ def _validate_read_response(
2174
+ self, response: TransportResponse, content_type: str
2175
+ ) -> None:
2176
+ if 300 <= response.status < 400:
2177
+ # A redirect proves expiry only when it targets a login route.
2178
+ # Other redirects must not trigger a new credential submission.
2179
+ if self._is_login_redirect(response):
2180
+ raise LibrusError(ErrorKind.SESSION_EXPIRED)
2181
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
2182
+ if response.status != 200 or _media_type(response) != content_type:
2183
+ raise LibrusError(ErrorKind.PARSE)
2184
+
2185
+ def _is_login_redirect(self, response: TransportResponse) -> bool:
2186
+ location = response.headers.get("location", "")
2187
+ try:
2188
+ target = urlsplit(urljoin(response.url, location))
2189
+ except ValueError:
2190
+ return False
2191
+ callback = ENDPOINTS["login_callback"]
2192
+ expected = urlsplit(self._service._connection.origin(callback))
2193
+ return (
2194
+ bool(location)
2195
+ and not (target.username or target.password or target.fragment)
2196
+ and (target.scheme, target.netloc) == (expected.scheme, expected.netloc)
2197
+ and target.path in (callback.path, ENDPOINTS["login_portal"].path)
2198
+ )
2199
+
2200
+ async def _fetch_identity(self, budget: RequestBudget) -> Identity:
2201
+ owner, student = await self._page(
2202
+ "identity", budget, parse_identity, content_type=JSON
2203
+ )
2204
+ credentials, previous = self._credentials, self._identity
2205
+ changed = previous is not None and (owner.id, student.id) != (
2206
+ previous.owner.id,
2207
+ previous.student.id,
2208
+ )
2209
+ unexpected = (
2210
+ credentials.expected_owner_id is not None
2211
+ and owner.id != credentials.expected_owner_id
2212
+ ) or (
2213
+ credentials.expected_student_id is not None
2214
+ and student.id != credentials.expected_student_id
2215
+ )
2216
+ if changed or unexpected:
2217
+ self._invalidate()
2218
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
2219
+ return Identity(owner, student, self._observation("identity"))
2220
+
2221
+ async def _authenticate(self, budget: RequestBudget) -> None:
2222
+ self._invalidate()
2223
+ try:
2224
+ response = await self._transport.request("login_portal", budget)
2225
+ response = await self._redirects(response, budget)
2226
+ authorization = ENDPOINTS["login_authorization"]
2227
+ form_url = (
2228
+ self._service._connection.origin(authorization) + authorization.path
2229
+ )
2230
+ current_url = (
2231
+ urlsplit(response.url)._replace(query="", fragment="").geturl()
2232
+ )
2233
+ # A portal redirect already established API-side cookies. Fetch the
2234
+ # form only when that hop did not land on its exact approved route.
2235
+ if current_url != form_url:
2236
+ response = await self._transport.request("login_authorization", budget)
2237
+ await self._redirects(response, budget)
2238
+ response = await self._transport.request(
2239
+ "login_submit",
2240
+ budget,
2241
+ form=LoginSubmission(
2242
+ self._credentials.login, self._credentials.password
2243
+ ),
2244
+ )
2245
+ if _media_type(response) != JSON:
2246
+ raise LibrusError(ErrorKind.ACCOUNT_ACTION_REQUIRED)
2247
+ location = await self._service._parsers.run(
2248
+ parse_login, response.body, budget
2249
+ )
2250
+ response = await self._transport.follow(response.url, location, budget)
2251
+ await self._redirects(response, budget)
2252
+ if not self._transport.has_cookie(SESSION_COOKIE, "identity"):
2253
+ raise LibrusError(ErrorKind.ACCOUNT_ACTION_REQUIRED)
2254
+ self._generation += 1
2255
+ self._identity = await self._fetch_identity(budget)
2256
+ except BaseException as error:
2257
+ self._invalidate()
2258
+ if isinstance(error, LibrusError):
2259
+ self._cooldowns["authentication"] = (
2260
+ time.monotonic() + self._service._transport_limits.cooldown_seconds,
2261
+ error.kind,
2262
+ )
2263
+ raise
2264
+
2265
+ async def _redirects(
2266
+ self,
2267
+ response: TransportResponse,
2268
+ budget: RequestBudget,
2269
+ ) -> TransportResponse:
2270
+ for _ in range(self._service._transport_limits.max_redirects):
2271
+ if not 300 <= response.status < 400:
2272
+ if response.status != 200:
2273
+ raise LibrusError(ErrorKind.PARSE)
2274
+ return response
2275
+ if response.status not in (301, 302, 303, 307, 308):
2276
+ raise LibrusError(ErrorKind.PARSE)
2277
+ location = response.headers.get("location", "")
2278
+ response = await self._transport.follow(response.url, location, budget)
2279
+ if 300 <= response.status < 400:
2280
+ raise LibrusError(ErrorKind.LIMIT)
2281
+ return response
2282
+
2283
+
2284
+ def _media_type(response: TransportResponse) -> str:
2285
+ return response.headers.get("content-type", "").partition(";")[0].strip().lower()