librus-python-api 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_codec.py +310 -0
  3. librus_python_api/_storage.py +367 -0
  4. librus_python_api/announcements.py +159 -0
  5. librus_python_api/attachment_routes.py +114 -0
  6. librus_python_api/attachments.py +297 -0
  7. librus_python_api/attendance.py +182 -0
  8. librus_python_api/attendance_frequency.py +112 -0
  9. librus_python_api/budget.py +79 -0
  10. librus_python_api/checkpoint.py +61 -0
  11. librus_python_api/completed_lessons.py +216 -0
  12. librus_python_api/config.py +1410 -0
  13. librus_python_api/detail_fields.py +50 -0
  14. librus_python_api/diagnostics.py +25 -0
  15. librus_python_api/exceptions.py +172 -0
  16. librus_python_api/files.py +156 -0
  17. librus_python_api/grade_parsers.py +169 -0
  18. librus_python_api/grade_records.py +454 -0
  19. librus_python_api/homework_range.py +41 -0
  20. librus_python_api/lifecycle.py +24 -0
  21. librus_python_api/markup.py +147 -0
  22. librus_python_api/message_content.py +230 -0
  23. librus_python_api/messages.py +288 -0
  24. librus_python_api/models.py +1160 -0
  25. librus_python_api/modern_body.py +75 -0
  26. librus_python_api/modern_mailbox.py +459 -0
  27. librus_python_api/modern_messages.py +276 -0
  28. librus_python_api/notification_models.py +67 -0
  29. librus_python_api/notification_persistence.py +1044 -0
  30. librus_python_api/notification_workflow.py +303 -0
  31. librus_python_api/notifications.py +216 -0
  32. librus_python_api/parsers.py +232 -0
  33. librus_python_api/parsing.py +49 -0
  34. librus_python_api/persistence.py +405 -0
  35. librus_python_api/py.typed +0 -0
  36. librus_python_api/recipients.py +271 -0
  37. librus_python_api/scheduler.py +287 -0
  38. librus_python_api/school_reads.py +400 -0
  39. librus_python_api/sending.py +125 -0
  40. librus_python_api/service.py +2285 -0
  41. librus_python_api/timetable.py +261 -0
  42. librus_python_api/transport.py +956 -0
  43. librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
  44. librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
  45. librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
  46. librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,956 @@
1
+ """Account-isolated aiohttp transport and explicit injection contract."""
2
+
3
+ import asyncio
4
+ import math
5
+ import re
6
+ import zlib
7
+ from base64 import b64encode
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from datetime import UTC, datetime
10
+ from email.utils import parsedate_to_datetime
11
+ from functools import partial
12
+ from types import MappingProxyType
13
+ from typing import Protocol
14
+ from urllib.parse import urljoin, urlsplit
15
+
16
+ import aiohttp
17
+ from yarl import URL
18
+
19
+ from librus_python_api.attachment_routes import (
20
+ validate_attachment_reference,
21
+ validate_key,
22
+ validate_max_bytes,
23
+ validate_modern_attachment_reference,
24
+ )
25
+ from librus_python_api.budget import RequestBudget
26
+ from librus_python_api.checkpoint import CheckpointState, handoff, validate_checkpoint
27
+ from librus_python_api.config import (
28
+ ATTACHMENT_CHUNK_BYTES,
29
+ AUTH_COOKIES,
30
+ ENDPOINTS,
31
+ FORM_FIELDS,
32
+ FORM_MAX_VALUE_LENGTH,
33
+ MESSAGE_MAX_PAGE_COUNT,
34
+ MESSAGE_PAGE_FIELDS,
35
+ MODERN_DIRECTORY_QUERIES,
36
+ MODERN_HANDOFF_PATTERN,
37
+ MODERN_TERMINAL_PATHS,
38
+ OAUTH_QUERY,
39
+ USER_AGENT,
40
+ ConnectionSettings,
41
+ Endpoint,
42
+ SideEffect,
43
+ TransportLimits,
44
+ encode_modern_send,
45
+ encode_send_form,
46
+ recipient_form,
47
+ validate_modern_query,
48
+ )
49
+ from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
50
+ from librus_python_api.models import (
51
+ AttachmentHeaders,
52
+ LoginSubmission,
53
+ MessageAttachmentReference,
54
+ ModernMessageAttachmentReference,
55
+ ModernSendSubmission,
56
+ RequestForm,
57
+ ScheduleEventWire,
58
+ SendSubmission,
59
+ TransportResponse,
60
+ )
61
+ from librus_python_api.scheduler import RequestScheduler
62
+
63
+
64
+ class AccountTransport(Protocol):
65
+ """A factory-created, exclusively owned transport for one login context.
66
+
67
+ Custom implementations must preserve the scheduler, isolation, byte limits,
68
+ destination validation, and joined cancellation guarantees. The service
69
+ owns and closes factory results; shared transport instances are rejected.
70
+ """
71
+
72
+ async def request(
73
+ self,
74
+ endpoint_id: str,
75
+ budget: RequestBudget,
76
+ *,
77
+ form: RequestForm = None,
78
+ reference_id: str | None = None,
79
+ query: Mapping[str, str] | None = None,
80
+ ) -> TransportResponse: ...
81
+
82
+ async def send_message(
83
+ self,
84
+ submission: SendSubmission,
85
+ budget: RequestBudget,
86
+ dispatched: Callable[[], None],
87
+ ) -> TransportResponse: ...
88
+
89
+ async def authenticate_modern(
90
+ self, expected_login: str, budget: RequestBudget
91
+ ) -> None: ...
92
+
93
+ async def send_modern_message(
94
+ self,
95
+ submission: ModernSendSubmission,
96
+ budget: RequestBudget,
97
+ dispatched: Callable[[], None],
98
+ ) -> TransportResponse: ...
99
+
100
+ def clear_modern_auth(self) -> None: ...
101
+
102
+ async def follow(
103
+ self,
104
+ previous_url: str,
105
+ location: str,
106
+ budget: RequestBudget,
107
+ ) -> TransportResponse: ...
108
+
109
+ async def resolve_attachment(
110
+ self, reference: MessageAttachmentReference, budget: RequestBudget
111
+ ) -> TransportResponse: ...
112
+
113
+ async def consume_schedule_events(
114
+ self,
115
+ budget: RequestBudget,
116
+ checkpoint: Callable[[ScheduleEventWire], Awaitable[None]],
117
+ checkpoint_timeout_seconds: float,
118
+ ) -> TransportResponse: ...
119
+
120
+ async def stream_download(
121
+ self,
122
+ key: str,
123
+ budget: RequestBudget,
124
+ max_bytes: int,
125
+ opened: Callable[[AttachmentHeaders], None],
126
+ demand: Callable[[], Awaitable[None]],
127
+ deliver: Callable[[bytes], None],
128
+ ) -> None: ...
129
+
130
+ def has_cookie(self, name: str, endpoint_id: str) -> bool: ...
131
+ def clear_auth(self) -> None: ...
132
+ async def aclose(self) -> None: ...
133
+
134
+
135
+ class TransportFactory(Protocol):
136
+ def __call__(
137
+ self,
138
+ account: str,
139
+ scheduler: RequestScheduler,
140
+ connection: ConnectionSettings,
141
+ limits: TransportLimits,
142
+ ) -> AccountTransport: ...
143
+
144
+
145
+ def _check_form(endpoint: Endpoint, form: RequestForm) -> None:
146
+ """Credentials go only to the login submission; other POSTs carry only
147
+ their own known fields; a GET carries no form."""
148
+ if endpoint.operation_id == "login_submit":
149
+ valid = isinstance(form, LoginSubmission) and (
150
+ 1 <= len(form.login.get_secret_value()) <= 256
151
+ and 1 <= len(form.password.get_secret_value()) <= 1024
152
+ )
153
+ elif endpoint.method == "POST":
154
+ allowed = FORM_FIELDS.get(endpoint.operation_id, frozenset())
155
+ valid = (
156
+ isinstance(form, Mapping)
157
+ and 0 < len(form) <= len(allowed)
158
+ and all(
159
+ type(key) is str
160
+ and key in allowed
161
+ and type(value) is str
162
+ and len(value) <= FORM_MAX_VALUE_LENGTH
163
+ for key, value in form.items()
164
+ )
165
+ )
166
+ if valid and endpoint.operation_id in {"messages_received", "messages_sent"}:
167
+ assert isinstance(form, Mapping)
168
+ page = form.get("numer_strony105", "")
169
+ valid = (
170
+ set(form) == MESSAGE_PAGE_FIELDS
171
+ and form.get("porcjowanie_pojemnik105") == "105"
172
+ and re.fullmatch(r"0|[1-9][0-9]{0,3}", page) is not None
173
+ and int(page) < MESSAGE_MAX_PAGE_COUNT
174
+ )
175
+ if valid and endpoint.operation_id == "recipients":
176
+ assert isinstance(form, Mapping)
177
+ valid = form == recipient_form(
178
+ form.get("typAdresata", ""), selection_id=form.get("idGrupy", "")
179
+ )
180
+ else:
181
+ valid = form is None
182
+ if not valid:
183
+ raise LibrusError(ErrorKind.INVALID_INPUT)
184
+
185
+
186
+ def _require_no_connection_retry() -> None:
187
+ """Fail closed if aiohttp drops the private switch that stops GET replays.
188
+
189
+ aiohttp otherwise silently replays a GET on a stale keep-alive connection.
190
+ Some GETs mark messages read or consume read-once events, so a missing
191
+ switch must stop all traffic, not degrade to silent replays.
192
+ """
193
+ if "_retry_connection" not in getattr(aiohttp.ClientSession, "ATTRS", ()):
194
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
195
+
196
+
197
+ class AiohttpTransport:
198
+ def __init__(
199
+ self,
200
+ account: str,
201
+ scheduler: RequestScheduler,
202
+ connection: ConnectionSettings,
203
+ limits: TransportLimits,
204
+ ) -> None:
205
+ self._account, self._scheduler = account, scheduler
206
+ self._connection, self._limits = connection, limits
207
+ self._session: aiohttp.ClientSession | None = None
208
+ self._download_session: aiohttp.ClientSession | None = None
209
+ self._modern_session: aiohttp.ClientSession | None = None
210
+ self._closed = False
211
+
212
+ def _get_session(self) -> aiohttp.ClientSession:
213
+ if self._closed:
214
+ raise LibrusError(ErrorKind.CLOSED)
215
+ if self._session is None:
216
+ _require_no_connection_retry()
217
+ self._session = aiohttp.ClientSession(
218
+ connector=aiohttp.TCPConnector(
219
+ limit=1,
220
+ ssl=self._connection.ssl_context or True,
221
+ ),
222
+ cookie_jar=aiohttp.CookieJar(),
223
+ trust_env=False,
224
+ auto_decompress=False,
225
+ read_bufsize=16 * 1024,
226
+ timeout=aiohttp.ClientTimeout(
227
+ total=self._limits.request_timeout_seconds,
228
+ connect=self._limits.connect_timeout_seconds,
229
+ ),
230
+ headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
231
+ )
232
+ # aiohttp otherwise silently replays GET on a stale keepalive
233
+ # connection. Some GETs mark read; every attempt must be budgeted.
234
+ self._session._retry_connection = False
235
+ return self._session
236
+
237
+ def _get_modern_session(self) -> aiohttp.ClientSession:
238
+ if self._closed:
239
+ raise LibrusError(ErrorKind.CLOSED)
240
+ if self._modern_session is None:
241
+ _require_no_connection_retry()
242
+ self._modern_session = aiohttp.ClientSession(
243
+ connector=aiohttp.TCPConnector(
244
+ limit=1, ssl=self._connection.ssl_context or True
245
+ ),
246
+ cookie_jar=aiohttp.CookieJar(),
247
+ trust_env=False,
248
+ auto_decompress=False,
249
+ read_bufsize=16 * 1024,
250
+ timeout=aiohttp.ClientTimeout(
251
+ total=self._limits.request_timeout_seconds,
252
+ connect=self._limits.connect_timeout_seconds,
253
+ ),
254
+ headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
255
+ )
256
+ self._modern_session._retry_connection = False
257
+ return self._modern_session
258
+
259
+ def _url(self, endpoint: Endpoint) -> str:
260
+ return self._connection.origin(endpoint) + endpoint.path
261
+
262
+ async def request(
263
+ self,
264
+ endpoint_id: str,
265
+ budget: RequestBudget,
266
+ *,
267
+ form: RequestForm = None,
268
+ reference_id: str | None = None,
269
+ query: Mapping[str, str] | None = None,
270
+ ) -> TransportResponse:
271
+ endpoint = ENDPOINTS.get(endpoint_id)
272
+ if endpoint is None:
273
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
274
+ if endpoint.origin == "download" or endpoint_id in {
275
+ "attachment_resolve",
276
+ "modern_attachment_resolve",
277
+ "modern_archive_attachment_resolve",
278
+ "consume_schedule_events",
279
+ "send_message",
280
+ "modern_send_message",
281
+ "modern_launch",
282
+ "modern_handoff",
283
+ }:
284
+ raise LibrusError(ErrorKind.INVALID_INPUT)
285
+ url = self._url(endpoint)
286
+ if "{id}" in endpoint.path:
287
+ if type(reference_id) is not str or not re.fullmatch(
288
+ r"[0-9]{1,64}", reference_id
289
+ ):
290
+ raise LibrusError(ErrorKind.INVALID_INPUT)
291
+ url = url.replace("{id}", reference_id)
292
+ elif reference_id is not None:
293
+ raise LibrusError(ErrorKind.INVALID_INPUT)
294
+ if endpoint.origin == "api":
295
+ url = str(URL(url).with_query(OAUTH_QUERY))
296
+ if endpoint_id in MODERN_DIRECTORY_QUERIES:
297
+ url = str(URL(url).with_query(MODERN_DIRECTORY_QUERIES[endpoint_id]))
298
+ if (
299
+ endpoint_id
300
+ in {
301
+ "modern_school_recipients",
302
+ "modern_class_parents",
303
+ "modern_messages_received",
304
+ "modern_messages_sent",
305
+ }
306
+ and query is None
307
+ ):
308
+ raise LibrusError(ErrorKind.INVALID_INPUT)
309
+ if query is not None:
310
+ validate_modern_query(endpoint_id, query)
311
+ url = str(URL(url).with_query(query))
312
+ return await self._request(endpoint, url, budget, form)
313
+
314
+ async def authenticate_modern(
315
+ self, expected_login: str, budget: RequestBudget
316
+ ) -> None:
317
+ try:
318
+ response = await self._request(
319
+ ENDPOINTS["modern_launch"],
320
+ self._url(ENDPOINTS["modern_launch"]),
321
+ budget,
322
+ None,
323
+ )
324
+ if response.status != 302:
325
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
326
+ if self._native_login_redirect(
327
+ response.url, response.headers.get("location", "")
328
+ ):
329
+ raise LibrusError(ErrorKind.SESSION_EXPIRED)
330
+ url = self._modern_redirect(
331
+ response.url, response.headers.get("location", "")
332
+ )
333
+ parsed = urlsplit(url)
334
+ match = MODERN_HANDOFF_PATTERN.fullmatch(parsed.path)
335
+ if match is None or match["login"] != b64encode(
336
+ expected_login.encode()
337
+ ).decode("ascii").rstrip("="):
338
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
339
+ response = await self._request(
340
+ ENDPOINTS["modern_handoff"], url, budget, None
341
+ )
342
+ if response.status != 302:
343
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
344
+ final = self._modern_redirect(
345
+ response.url, response.headers.get("location", "")
346
+ )
347
+ if urlsplit(final).path not in MODERN_TERMINAL_PATHS:
348
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
349
+ except BaseException:
350
+ self.clear_modern_auth()
351
+ raise
352
+
353
+ def _modern_redirect(self, previous: str, location: str) -> str:
354
+ valid = False
355
+ url = ""
356
+ try:
357
+ if type(location) is str and 1 <= len(location) <= 4096:
358
+ url = urljoin(previous, location)
359
+ parsed = urlsplit(url)
360
+ valid = not (
361
+ parsed.username
362
+ or parsed.password
363
+ or parsed.query
364
+ or parsed.fragment
365
+ or "%" in parsed.path
366
+ ) and URL(url).origin() == URL(self._connection.messages_origin)
367
+ except ValueError:
368
+ pass
369
+ if not valid:
370
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
371
+ return url
372
+
373
+ def _native_login_redirect(self, previous: str, location: str) -> bool:
374
+ try:
375
+ target = urlsplit(urljoin(previous, location))
376
+ return (
377
+ bool(location)
378
+ and not (target.username or target.password or target.fragment)
379
+ and (
380
+ URL(target.geturl()).origin()
381
+ == URL(self._connection.synergia_origin)
382
+ and target.path
383
+ in (
384
+ ENDPOINTS["login_callback"].path,
385
+ ENDPOINTS["login_portal"].path,
386
+ )
387
+ )
388
+ )
389
+ except ValueError:
390
+ return False
391
+
392
+ async def send_modern_message(
393
+ self,
394
+ submission: ModernSendSubmission,
395
+ budget: RequestBudget,
396
+ dispatched: Callable[[], None],
397
+ ) -> TransportResponse:
398
+ payload = encode_modern_send(submission, self._account)
399
+ if self._closed:
400
+ raise LibrusError(ErrorKind.CLOSED)
401
+ kind: ErrorKind | None = None
402
+ try:
403
+ return await self._scheduler.run(
404
+ self._account,
405
+ budget,
406
+ partial(self._send_exchange, payload, budget, dispatched, modern=True),
407
+ preserve_completed=True,
408
+ )
409
+ except aiohttp.ClientError:
410
+ kind = ErrorKind.CONNECTION
411
+ except TimeoutError:
412
+ kind = ErrorKind.TIMEOUT
413
+ except (ValueError, OverflowError, zlib.error):
414
+ kind = ErrorKind.PARSE
415
+ assert kind is not None
416
+ raise LibrusError(kind)
417
+
418
+ async def send_message(
419
+ self,
420
+ submission: SendSubmission,
421
+ budget: RequestBudget,
422
+ dispatched: Callable[[], None],
423
+ ) -> TransportResponse:
424
+ payload = encode_send_form(submission, self._account)
425
+ if self._closed:
426
+ raise LibrusError(ErrorKind.CLOSED)
427
+ kind: ErrorKind | None = None
428
+ try:
429
+ return await self._scheduler.run(
430
+ self._account,
431
+ budget,
432
+ partial(self._send_exchange, payload, budget, dispatched),
433
+ preserve_completed=True,
434
+ )
435
+ except aiohttp.ClientError:
436
+ kind = ErrorKind.CONNECTION
437
+ except TimeoutError:
438
+ kind = ErrorKind.TIMEOUT
439
+ except (ValueError, OverflowError, zlib.error):
440
+ kind = ErrorKind.PARSE
441
+ assert kind is not None
442
+ raise LibrusError(kind)
443
+
444
+ async def _send_exchange(
445
+ self,
446
+ payload: bytes,
447
+ budget: RequestBudget,
448
+ dispatched: Callable[[], None],
449
+ *,
450
+ modern: bool = False,
451
+ ) -> TransportResponse:
452
+ endpoint = ENDPOINTS["modern_send_message" if modern else "send_message"]
453
+ session = self._get_modern_session() if modern else self._get_session()
454
+ proxy = self._connection.proxy_url
455
+ dispatched()
456
+ async with session.post(
457
+ self._url(endpoint),
458
+ data=payload,
459
+ headers={
460
+ "Content-Type": "application/json"
461
+ if modern
462
+ else "application/x-www-form-urlencoded; charset=utf-8"
463
+ },
464
+ allow_redirects=False,
465
+ proxy=proxy.get_secret_value() if proxy is not None else None,
466
+ ) as response:
467
+ self._check_headers(response)
468
+ if len(session.cookie_jar) > self._limits.max_cookies:
469
+ session.cookie_jar.clear()
470
+ raise LibrusError(ErrorKind.LIMIT)
471
+ if not modern or response.status not in (400, 422):
472
+ self._check_status(response)
473
+ body = await self._read_body(response, budget)
474
+ return TransportResponse(
475
+ response.status,
476
+ body,
477
+ str(response.url),
478
+ MappingProxyType({k.lower(): v for k, v in response.headers.items()}),
479
+ )
480
+
481
+ async def consume_schedule_events(
482
+ self,
483
+ budget: RequestBudget,
484
+ checkpoint: Callable[[ScheduleEventWire], Awaitable[None]],
485
+ checkpoint_timeout_seconds: float,
486
+ ) -> TransportResponse:
487
+ validate_checkpoint(checkpoint, checkpoint_timeout_seconds)
488
+ if self._closed:
489
+ raise LibrusError(ErrorKind.CLOSED)
490
+ state = CheckpointState()
491
+ kind: ErrorKind | None = None
492
+ cancelled = False
493
+ try:
494
+ return await self._scheduler.run(
495
+ self._account,
496
+ budget,
497
+ partial(
498
+ self._consume_exchange,
499
+ budget,
500
+ checkpoint,
501
+ checkpoint_timeout_seconds,
502
+ state,
503
+ ),
504
+ )
505
+ except asyncio.CancelledError:
506
+ cancelled = True
507
+ except LibrusError as error:
508
+ kind = error.kind
509
+ except aiohttp.ClientError:
510
+ kind = ErrorKind.CONNECTION
511
+ except TimeoutError:
512
+ kind = ErrorKind.TIMEOUT
513
+ except (ValueError, OverflowError):
514
+ kind = ErrorKind.PARSE
515
+ # Scheduler cancellation joins but discards a worker exception. Preserve
516
+ # the stronger durability failure even when cancellation/close overlaps.
517
+ if state.failed:
518
+ raise LibrusError(ErrorKind.CHECKPOINT)
519
+ if cancelled:
520
+ raise asyncio.CancelledError
521
+ assert kind is not None
522
+ raise LibrusError(kind)
523
+
524
+ async def _consume_exchange(
525
+ self,
526
+ budget: RequestBudget,
527
+ checkpoint: Callable[[ScheduleEventWire], Awaitable[None]],
528
+ seconds: float,
529
+ state: CheckpointState,
530
+ ) -> TransportResponse:
531
+ endpoint = ENDPOINTS["consume_schedule_events"]
532
+ session = self._get_session()
533
+ proxy = self._connection.proxy_url
534
+ async with session.get(
535
+ self._url(endpoint),
536
+ allow_redirects=False,
537
+ proxy=proxy.get_secret_value() if proxy is not None else None,
538
+ ) as response:
539
+ self._check_headers(response)
540
+ if len(session.cookie_jar) > self._limits.max_cookies:
541
+ session.cookie_jar.clear()
542
+ raise LibrusError(ErrorKind.LIMIT)
543
+ self._check_status(response)
544
+ body = await self._read_payload(response, budget)
545
+ if response.status == 200:
546
+ wire = ScheduleEventWire(
547
+ body,
548
+ response.headers.get("Content-Type"),
549
+ tuple(response.headers.getall("Content-Encoding", [])),
550
+ tuple(response.headers.getall("Transfer-Encoding", [])),
551
+ )
552
+ await handoff(lambda: checkpoint(wire), seconds, state)
553
+ return TransportResponse(
554
+ response.status,
555
+ body,
556
+ str(response.url),
557
+ MappingProxyType({k.lower(): v for k, v in response.headers.items()}),
558
+ )
559
+
560
+ async def _read_payload(
561
+ self, response: aiohttp.ClientResponse, budget: RequestBudget
562
+ ) -> bytes:
563
+ """Dechunked encoded payload, before decompression or semantic decoding."""
564
+ length = response.content_length
565
+ if length is not None and (
566
+ length > self._limits.response_max_bytes
567
+ or length > budget.remaining_response_bytes
568
+ ):
569
+ raise LibrusError(ErrorKind.LIMIT)
570
+ body = bytearray()
571
+ async for chunk in response.content.iter_chunked(16 * 1024):
572
+ budget._receive(len(chunk))
573
+ if len(body) + len(chunk) > self._limits.response_max_bytes:
574
+ raise LibrusError(ErrorKind.LIMIT)
575
+ body.extend(chunk)
576
+ return bytes(body)
577
+
578
+ async def resolve_attachment(
579
+ self, reference: MessageAttachmentReference, budget: RequestBudget
580
+ ) -> TransportResponse:
581
+ validate_attachment_reference(reference, self._account)
582
+ endpoint = ENDPOINTS["attachment_resolve"]
583
+ path = endpoint.path.format(
584
+ message_id=reference.message.identifier, file_id=reference.identifier
585
+ )
586
+ return await self._request(
587
+ endpoint, self._connection.origin(endpoint) + path, budget, None
588
+ )
589
+
590
+ async def resolve_modern_attachment(
591
+ self,
592
+ reference: ModernMessageAttachmentReference,
593
+ budget: RequestBudget,
594
+ ) -> TransportResponse:
595
+ validate_modern_attachment_reference(reference, self._account)
596
+ endpoint = ENDPOINTS[
597
+ "modern_archive_attachment_resolve"
598
+ if reference.archived
599
+ else "modern_attachment_resolve"
600
+ ]
601
+ path = endpoint.path.format(
602
+ message_id=reference.message.identifier,
603
+ file_id=reference.identifier,
604
+ )
605
+ return await self._request(
606
+ endpoint, self._connection.origin(endpoint) + path, budget, None
607
+ )
608
+
609
+ def _get_download_session(self) -> aiohttp.ClientSession:
610
+ if self._closed:
611
+ raise LibrusError(ErrorKind.CLOSED)
612
+ if self._download_session is None:
613
+ _require_no_connection_retry()
614
+ self._download_session = aiohttp.ClientSession(
615
+ connector=aiohttp.TCPConnector(
616
+ limit=1, ssl=self._connection.ssl_context or True
617
+ ),
618
+ cookie_jar=aiohttp.DummyCookieJar(),
619
+ trust_env=False,
620
+ auto_decompress=False,
621
+ read_bufsize=ATTACHMENT_CHUNK_BYTES,
622
+ headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
623
+ )
624
+ self._download_session._retry_connection = False
625
+ return self._download_session
626
+
627
+ async def stream_download(
628
+ self,
629
+ key: str,
630
+ budget: RequestBudget,
631
+ max_bytes: int,
632
+ opened: Callable[[AttachmentHeaders], None],
633
+ demand: Callable[[], Awaitable[None]],
634
+ deliver: Callable[[bytes], None],
635
+ ) -> None:
636
+ validate_key(key)
637
+ validate_max_bytes(max_bytes)
638
+ if self._closed:
639
+ raise LibrusError(ErrorKind.CLOSED)
640
+ kind: ErrorKind | None = None
641
+ try:
642
+ await self._scheduler.run(
643
+ self._account,
644
+ budget,
645
+ partial(
646
+ self._download_exchange,
647
+ key,
648
+ budget,
649
+ max_bytes,
650
+ opened,
651
+ demand,
652
+ deliver,
653
+ ),
654
+ )
655
+ return
656
+ except aiohttp.ClientPayloadError:
657
+ kind = ErrorKind.PARSE
658
+ except aiohttp.ClientError:
659
+ kind = ErrorKind.CONNECTION
660
+ except TimeoutError:
661
+ kind = ErrorKind.TIMEOUT
662
+ except (ValueError, OverflowError):
663
+ kind = ErrorKind.PARSE
664
+ except LibrusError as error:
665
+ # A signed-server denial does not prove Librus session expiry.
666
+ kind = (
667
+ ErrorKind.ACCESS_DENIED
668
+ if error.kind is ErrorKind.SESSION_EXPIRED
669
+ else error.kind
670
+ )
671
+ assert kind is not None
672
+ raise LibrusError(kind)
673
+
674
+ async def _download_exchange(
675
+ self,
676
+ key: str,
677
+ budget: RequestBudget,
678
+ max_bytes: int,
679
+ opened: Callable[[AttachmentHeaders], None],
680
+ demand: Callable[[], Awaitable[None]],
681
+ deliver: Callable[[bytes], None],
682
+ ) -> None:
683
+ validate_key(key)
684
+ endpoint = ENDPOINTS["attachment_download"]
685
+ url = self._connection.origin(endpoint) + endpoint.path.format(key=key)
686
+ proxy = self._connection.proxy_url
687
+ timeout = aiohttp.ClientTimeout(
688
+ total=budget.remaining_seconds(),
689
+ connect=self._limits.connect_timeout_seconds,
690
+ )
691
+ async with self._get_download_session().get(
692
+ url,
693
+ allow_redirects=False,
694
+ timeout=timeout,
695
+ proxy=proxy.get_secret_value() if proxy is not None else None,
696
+ ) as response:
697
+ self._check_headers(response)
698
+ self._check_status(response)
699
+ if response.status != 200:
700
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
701
+ content_codings = response.headers.getall("Content-Encoding", [])
702
+ transfer_codings = response.headers.getall("Transfer-Encoding", [])
703
+ if (
704
+ len(content_codings) > 1
705
+ or any(v.strip().casefold() != "identity" for v in content_codings)
706
+ or len(transfer_codings) > 1
707
+ or any(v.strip().casefold() != "chunked" for v in transfer_codings)
708
+ ):
709
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
710
+ length = response.content_length
711
+ if length is not None and (
712
+ length > max_bytes or length > budget.remaining_response_bytes
713
+ ):
714
+ raise LibrusError(ErrorKind.LIMIT)
715
+ opened(
716
+ AttachmentHeaders(
717
+ response.headers.get("Content-Type"),
718
+ length,
719
+ response.headers.get("Content-Disposition"),
720
+ )
721
+ )
722
+ received = 0
723
+ while True:
724
+ await demand()
725
+ chunk = await response.content.read(ATTACHMENT_CHUNK_BYTES)
726
+ if not chunk:
727
+ if length is not None and received != length:
728
+ raise LibrusError(ErrorKind.PARSE)
729
+ return
730
+ budget._receive(len(chunk))
731
+ received += len(chunk)
732
+ if received > max_bytes:
733
+ raise LibrusError(ErrorKind.LIMIT)
734
+ deliver(chunk)
735
+
736
+ async def follow(
737
+ self,
738
+ previous_url: str,
739
+ location: str,
740
+ budget: RequestBudget,
741
+ ) -> TransportResponse:
742
+ failed = False
743
+ try:
744
+ if not location or len(location) > 4096:
745
+ raise ValueError
746
+ url = urljoin(previous_url, location)
747
+ parsed = urlsplit(url)
748
+ if (
749
+ parsed.username
750
+ or parsed.password
751
+ or parsed.fragment
752
+ or "%" in parsed.path
753
+ ):
754
+ raise ValueError
755
+ endpoint = next(
756
+ (
757
+ item
758
+ for item in ENDPOINTS.values()
759
+ if item.method == "GET"
760
+ and item.side_effect == SideEffect.AUTHENTICATION
761
+ and not item.operation_id.startswith("modern_")
762
+ and parsed.path == item.path
763
+ and URL(url).origin() == URL(self._connection.origin(item))
764
+ ),
765
+ None,
766
+ )
767
+ if endpoint is None:
768
+ raise ValueError
769
+ except ValueError:
770
+ failed = True
771
+ if failed:
772
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
773
+ assert endpoint is not None
774
+ return await self._request(endpoint, url, budget, None)
775
+
776
+ async def _request(
777
+ self,
778
+ endpoint: Endpoint,
779
+ url: str,
780
+ budget: RequestBudget,
781
+ form: RequestForm,
782
+ ) -> TransportResponse:
783
+ if self._closed:
784
+ raise LibrusError(ErrorKind.CLOSED)
785
+ _check_form(endpoint, form)
786
+ kind: ErrorKind | None = None
787
+ try:
788
+ return await self._scheduler.run(
789
+ self._account,
790
+ budget,
791
+ partial(self._exchange, endpoint, url, budget, form),
792
+ )
793
+ except SessionExpiredError as error:
794
+ error._messages_origin = endpoint.origin == "messages"
795
+ raise
796
+ except aiohttp.ClientError:
797
+ kind = ErrorKind.CONNECTION
798
+ except TimeoutError:
799
+ kind = ErrorKind.TIMEOUT
800
+ except zlib.error:
801
+ kind = ErrorKind.PARSE
802
+ except (ValueError, OverflowError):
803
+ kind = ErrorKind.PARSE
804
+ assert kind is not None
805
+ raise LibrusError(kind)
806
+
807
+ async def _exchange(
808
+ self,
809
+ endpoint: Endpoint,
810
+ url: str,
811
+ budget: RequestBudget,
812
+ form: RequestForm,
813
+ ) -> TransportResponse:
814
+ session = (
815
+ self._get_modern_session()
816
+ if endpoint.origin == "messages"
817
+ else self._get_session()
818
+ )
819
+ proxy = self._connection.proxy_url
820
+ headers = None
821
+ if isinstance(form, LoginSubmission):
822
+ headers = {
823
+ "Origin": self._connection.api_origin,
824
+ "Referer": str(URL(self._url(endpoint)).with_query(OAUTH_QUERY)),
825
+ "X-Requested-With": "XMLHttpRequest",
826
+ }
827
+ payload: dict[str, str] | None = None
828
+ if isinstance(form, LoginSubmission):
829
+ payload = {
830
+ "action": "login",
831
+ "login": form.login.get_secret_value(),
832
+ "pass": form.password.get_secret_value(),
833
+ }
834
+ elif form is not None:
835
+ payload = dict(form)
836
+ async with session.request(
837
+ endpoint.method,
838
+ url,
839
+ data=payload,
840
+ headers=headers,
841
+ allow_redirects=False,
842
+ proxy=proxy.get_secret_value() if proxy is not None else None,
843
+ ) as response:
844
+ self._check_headers(response)
845
+ if len(session.cookie_jar) > self._limits.max_cookies:
846
+ session.cookie_jar.clear()
847
+ raise LibrusError(ErrorKind.LIMIT)
848
+ self._check_status(response)
849
+ body = await self._read_body(response, budget)
850
+ return TransportResponse(
851
+ response.status,
852
+ body,
853
+ str(response.url),
854
+ MappingProxyType({k.lower(): v for k, v in response.headers.items()}),
855
+ )
856
+
857
+ def _check_headers(self, response: aiohttp.ClientResponse) -> None:
858
+ if (
859
+ len(response.headers) > 128
860
+ or sum(len(k) + len(v) for k, v in response.headers.items()) > 32 * 1024
861
+ or len(response.headers.getall("Location", [])) > 1
862
+ ):
863
+ raise LibrusError(ErrorKind.LIMIT)
864
+
865
+ def _check_status(self, response: aiohttp.ClientResponse) -> None:
866
+ if response.status in (429, 503):
867
+ pause = self._limits.cooldown_seconds
868
+ value = response.headers.get("Retry-After", "")
869
+ try:
870
+ if value and len(value) <= 64:
871
+ try:
872
+ seconds = float(value)
873
+ except ValueError:
874
+ instant = parsedate_to_datetime(value)
875
+ seconds = (instant - datetime.now(UTC)).total_seconds()
876
+ if math.isfinite(seconds) and seconds >= 0:
877
+ # Avoid platform timer overflow from untrusted headers.
878
+ pause = max(pause, min(seconds, 24 * 60 * 60))
879
+ except (ValueError, TypeError, OverflowError):
880
+ pass
881
+ self._scheduler.pause_for(pause)
882
+ kinds = {
883
+ 401: ErrorKind.SESSION_EXPIRED,
884
+ 403: ErrorKind.ACCESS_DENIED,
885
+ 429: ErrorKind.THROTTLED,
886
+ 503: ErrorKind.MAINTENANCE,
887
+ }
888
+ if response.status in kinds:
889
+ raise LibrusError(kinds[response.status])
890
+ if response.status >= 400:
891
+ raise LibrusError(ErrorKind.CONNECTION)
892
+
893
+ async def _read_body(
894
+ self,
895
+ response: aiohttp.ClientResponse,
896
+ budget: RequestBudget,
897
+ ) -> bytes:
898
+ if response.content_length is not None and (
899
+ response.content_length > self._limits.response_max_bytes
900
+ or response.content_length > budget.remaining_response_bytes
901
+ ):
902
+ raise LibrusError(ErrorKind.LIMIT)
903
+ encoding = response.headers.get("Content-Encoding", "identity").lower()
904
+ if encoding not in ("identity", "gzip"):
905
+ raise LibrusError(ErrorKind.PARSE)
906
+ inflater = (
907
+ zlib.decompressobj(16 + zlib.MAX_WBITS) if encoding == "gzip" else None
908
+ )
909
+ body = bytearray()
910
+ wire_bytes = 0
911
+ async for chunk in response.content.iter_chunked(16 * 1024):
912
+ wire_bytes += len(chunk)
913
+ budget._receive(len(chunk))
914
+ if wire_bytes > self._limits.response_max_bytes:
915
+ raise LibrusError(ErrorKind.LIMIT)
916
+ if inflater is not None:
917
+ available = min(
918
+ self._limits.response_max_bytes - len(body),
919
+ budget.remaining_response_bytes,
920
+ )
921
+ chunk = inflater.decompress(chunk, available + 1)
922
+ budget._receive(len(chunk))
923
+ if len(body) + len(chunk) > self._limits.response_max_bytes:
924
+ raise LibrusError(ErrorKind.LIMIT)
925
+ body.extend(chunk)
926
+ if inflater is not None and (not inflater.eof or inflater.unused_data):
927
+ raise LibrusError(ErrorKind.PARSE)
928
+ return bytes(body)
929
+
930
+ def has_cookie(self, name: str, endpoint_id: str) -> bool:
931
+ if self._session is None:
932
+ return False
933
+ url = URL(self._url(ENDPOINTS[endpoint_id]))
934
+ return name in self._session.cookie_jar.filter_cookies(url)
935
+
936
+ def clear_auth(self) -> None:
937
+ if self._session is not None:
938
+ self._session.cookie_jar.clear(lambda cookie: cookie.key in AUTH_COOKIES)
939
+ self.clear_modern_auth()
940
+
941
+ def clear_modern_auth(self) -> None:
942
+ if self._modern_session is not None:
943
+ self._modern_session.cookie_jar.clear()
944
+
945
+ async def aclose(self) -> None:
946
+ self._closed = True
947
+ try:
948
+ if self._session is not None:
949
+ await self._session.close()
950
+ finally:
951
+ try:
952
+ if self._download_session is not None:
953
+ await self._download_session.close()
954
+ finally:
955
+ if self._modern_session is not None:
956
+ await self._modern_session.close()