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