librus-python-api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_bootstrap.py +124 -0
- librus_python_api/_notification_codec.py +396 -0
- librus_python_api/_storage.py +403 -0
- librus_python_api/_windows_filesystem.py +390 -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 +242 -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 +145 -0
- librus_python_api/notification_persistence.py +1243 -0
- librus_python_api/notification_workflow.py +337 -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 +419 -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.0.dist-info/METADATA +262 -0
- librus_python_api-1.0.0.dist-info/RECORD +48 -0
- librus_python_api-1.0.0.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0.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()
|