librus-python-api 1.0.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_codec.py +310 -0
- librus_python_api/_storage.py +367 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +156 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +67 -0
- librus_python_api/notification_persistence.py +1044 -0
- librus_python_api/notification_workflow.py +303 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +405 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
- librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
- librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Independent bounded named-group and ID-bearing recipient parsing."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from lxml import html
|
|
6
|
+
|
|
7
|
+
from librus_python_api.config import (
|
|
8
|
+
MESSAGE_INFORMATION_NOTICES,
|
|
9
|
+
RECIPIENT_CLASS_UNAVAILABLE_NOTICE,
|
|
10
|
+
RECIPIENT_GROUP_TYPE_PATTERN,
|
|
11
|
+
RECIPIENT_MAX_GROUPS,
|
|
12
|
+
RECIPIENT_MAX_ITEMS,
|
|
13
|
+
RECIPIENT_MAX_LABEL_LENGTH,
|
|
14
|
+
RECIPIENT_MAX_TOTAL_TEXT_LENGTH,
|
|
15
|
+
RECIPIENT_SELECTION_PROMPT,
|
|
16
|
+
RECIPIENT_UNSUPPORTED_TYPES,
|
|
17
|
+
recipient_form,
|
|
18
|
+
)
|
|
19
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
20
|
+
from librus_python_api.markup import rows, text
|
|
21
|
+
from librus_python_api.models import (
|
|
22
|
+
Recipient,
|
|
23
|
+
RecipientGroup,
|
|
24
|
+
RecipientGroupChoice,
|
|
25
|
+
RecipientGroupReference,
|
|
26
|
+
RecipientReference,
|
|
27
|
+
)
|
|
28
|
+
from librus_python_api.parsers import page_notices, parse_page
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_group(
|
|
32
|
+
reference: RecipientGroupReference, account: str, *, choices: bool = False
|
|
33
|
+
) -> None:
|
|
34
|
+
if (
|
|
35
|
+
not isinstance(reference, RecipientGroupReference)
|
|
36
|
+
or reference.account != account
|
|
37
|
+
or type(reference.identifier) is not str
|
|
38
|
+
or RECIPIENT_GROUP_TYPE_PATTERN.fullmatch(reference.identifier) is None
|
|
39
|
+
):
|
|
40
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
41
|
+
recipient_form(reference.identifier, selection_id=reference.selection_id)
|
|
42
|
+
if choices and (reference.identifier != "grupa" or reference.selection_id != "0"):
|
|
43
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
44
|
+
if (
|
|
45
|
+
not choices
|
|
46
|
+
and reference.identifier in RECIPIENT_UNSUPPORTED_TYPES
|
|
47
|
+
and reference.selection_id == "0"
|
|
48
|
+
):
|
|
49
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _unavailable(document: html.HtmlElement) -> None:
|
|
53
|
+
# Localized UI text is a capability failure, not proof of zero recipients.
|
|
54
|
+
for marker in document.xpath('//p[@class="msgEmptyTable"]'):
|
|
55
|
+
if text(marker) == RECIPIENT_CLASS_UNAVAILABLE_NOTICE:
|
|
56
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _recipient_document(body: bytes) -> html.HtmlElement:
|
|
60
|
+
document = parse_page(body)
|
|
61
|
+
_unavailable(document)
|
|
62
|
+
if any(n not in MESSAGE_INFORMATION_NOTICES for n in page_notices(document)):
|
|
63
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
64
|
+
return document
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_recipient_group_choices(
|
|
68
|
+
body: bytes, group: RecipientGroupReference
|
|
69
|
+
) -> tuple[RecipientGroupChoice, ...]:
|
|
70
|
+
validate_group(group, group.account, choices=True)
|
|
71
|
+
document = _recipient_document(body)
|
|
72
|
+
selectors = document.xpath('//select[@name="idGrupy" and @id="idGrupy"]')
|
|
73
|
+
if (
|
|
74
|
+
len(selectors) != 1
|
|
75
|
+
or len(document.xpath("//select")) != 1
|
|
76
|
+
or document.xpath('//label|//input[@type="checkbox"]')
|
|
77
|
+
):
|
|
78
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
79
|
+
markers = document.xpath('//p[@class="msgEmptyTable"]')
|
|
80
|
+
if len(markers) != 1 or text(markers[0]) != RECIPIENT_SELECTION_PROMPT:
|
|
81
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
82
|
+
options = list(selectors[0])
|
|
83
|
+
if not options or len(options) > RECIPIENT_MAX_ITEMS + 1:
|
|
84
|
+
raise LibrusError(ErrorKind.LIMIT if options else ErrorKind.PARSE)
|
|
85
|
+
result = []
|
|
86
|
+
seen = set()
|
|
87
|
+
total = 0
|
|
88
|
+
for option in options:
|
|
89
|
+
if option.tag != "option":
|
|
90
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
91
|
+
identifier = option.get("value", "")
|
|
92
|
+
if re.fullmatch(r"0|[1-9][0-9]{0,63}", identifier) is None:
|
|
93
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
94
|
+
if identifier in seen:
|
|
95
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
96
|
+
seen.add(identifier)
|
|
97
|
+
label = text(option, RECIPIENT_MAX_LABEL_LENGTH)
|
|
98
|
+
if identifier == "0":
|
|
99
|
+
if label:
|
|
100
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
101
|
+
continue
|
|
102
|
+
if not label:
|
|
103
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
104
|
+
total += len(label)
|
|
105
|
+
if total > RECIPIENT_MAX_TOTAL_TEXT_LENGTH:
|
|
106
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
107
|
+
result.append(
|
|
108
|
+
RecipientGroupChoice(
|
|
109
|
+
RecipientGroupReference("grupa", group.account, identifier),
|
|
110
|
+
label,
|
|
111
|
+
option.get("disabled") is None and selectors[0].get("disabled") is None,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
if "0" not in seen:
|
|
115
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
116
|
+
return tuple(result)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_recipient_groups(body: bytes, account: str) -> tuple[RecipientGroup, ...]:
|
|
120
|
+
document = _recipient_document(body)
|
|
121
|
+
tables = document.xpath(
|
|
122
|
+
'//table[contains(concat(" ",normalize-space(@class)," "),'
|
|
123
|
+
'" message-recipients ")]'
|
|
124
|
+
)
|
|
125
|
+
if len(tables) != 1:
|
|
126
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
127
|
+
result: list[RecipientGroup] = []
|
|
128
|
+
seen: set[str] = set()
|
|
129
|
+
for row in rows(tables[0]):
|
|
130
|
+
parent = row.getparent()
|
|
131
|
+
if parent is None or parent.tag != "tbody":
|
|
132
|
+
continue
|
|
133
|
+
inputs = row.xpath(
|
|
134
|
+
'.//input[contains(concat(" ",normalize-space(@class)," "),'
|
|
135
|
+
'" recipiantTypeRadio ")]'
|
|
136
|
+
)
|
|
137
|
+
if len(inputs) != 1:
|
|
138
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
139
|
+
radio = inputs[0]
|
|
140
|
+
identifier = radio.get("value", "")
|
|
141
|
+
if RECIPIENT_GROUP_TYPE_PATTERN.fullmatch(identifier) is None:
|
|
142
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
143
|
+
if (
|
|
144
|
+
radio.get("type") != "radio"
|
|
145
|
+
or radio.get("id") != "radio_" + identifier
|
|
146
|
+
or identifier in seen
|
|
147
|
+
):
|
|
148
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
149
|
+
labels = row.xpath(".//label")
|
|
150
|
+
if len(labels) != 1 or labels[0].get("for") != radio.get("id"):
|
|
151
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
152
|
+
label = text(labels[0], RECIPIENT_MAX_LABEL_LENGTH)
|
|
153
|
+
if not label:
|
|
154
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
155
|
+
if len(result) >= RECIPIENT_MAX_GROUPS:
|
|
156
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
157
|
+
seen.add(identifier)
|
|
158
|
+
result.append(
|
|
159
|
+
RecipientGroup(
|
|
160
|
+
RecipientGroupReference(identifier, account),
|
|
161
|
+
label,
|
|
162
|
+
radio.get("disabled") is None,
|
|
163
|
+
identifier not in RECIPIENT_UNSUPPORTED_TYPES,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
if not result:
|
|
167
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
168
|
+
return tuple(result)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_recipients(
|
|
172
|
+
body: bytes, group: RecipientGroupReference
|
|
173
|
+
) -> tuple[Recipient, ...]:
|
|
174
|
+
validate_group(group, group.account)
|
|
175
|
+
document = _recipient_document(body)
|
|
176
|
+
if document.xpath('//p[@class="msgEmptyTable"]'):
|
|
177
|
+
# Explicit class-unavailable is handled above; unknown empty/prompt
|
|
178
|
+
# states must not silently coexist with a successful recipient list.
|
|
179
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
180
|
+
result: list[Recipient] = []
|
|
181
|
+
seen: set[str] = set()
|
|
182
|
+
total = 0
|
|
183
|
+
labels = list(document.iter("label"))
|
|
184
|
+
controls_by_id = {n.get("id"): n for n in document.iter("input") if n.get("id")}
|
|
185
|
+
if not labels:
|
|
186
|
+
if group.identifier == "sadmin":
|
|
187
|
+
return _anonymous_recipient(document, group)
|
|
188
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
189
|
+
for label in labels:
|
|
190
|
+
target = label.get("for", "")
|
|
191
|
+
match = re.fullmatch(r"(?:[A-Za-z][A-Za-z0-9]*_)+([0-9]{1,64})", target)
|
|
192
|
+
if match is None:
|
|
193
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
194
|
+
identifier = match[1]
|
|
195
|
+
control = controls_by_id.get(target)
|
|
196
|
+
if (
|
|
197
|
+
control is None
|
|
198
|
+
or control.get("type") != "checkbox"
|
|
199
|
+
or control.get("value") != identifier
|
|
200
|
+
or identifier in seen
|
|
201
|
+
):
|
|
202
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
203
|
+
name = text(label, RECIPIENT_MAX_LABEL_LENGTH)
|
|
204
|
+
if not name:
|
|
205
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
206
|
+
total += len(name)
|
|
207
|
+
if (
|
|
208
|
+
len(result) >= RECIPIENT_MAX_ITEMS
|
|
209
|
+
or total > RECIPIENT_MAX_TOTAL_TEXT_LENGTH
|
|
210
|
+
):
|
|
211
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
212
|
+
seen.add(identifier)
|
|
213
|
+
result.append(
|
|
214
|
+
Recipient(
|
|
215
|
+
RecipientReference(
|
|
216
|
+
identifier, group.account, group.identifier, group.selection_id
|
|
217
|
+
),
|
|
218
|
+
name,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
# The observed response has one ID/value-free select-all checkbox. Every
|
|
222
|
+
# recipient checkbox must instead have a numeric value and a matching label.
|
|
223
|
+
controls = 0
|
|
224
|
+
for node in document.iter("input"):
|
|
225
|
+
if node.get("type") != "checkbox":
|
|
226
|
+
continue
|
|
227
|
+
identifier, value = node.get("id"), node.get("value")
|
|
228
|
+
if identifier is None and value is None:
|
|
229
|
+
continue
|
|
230
|
+
if not identifier or re.fullmatch(r"[0-9]{1,64}", value or "") is None:
|
|
231
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
232
|
+
controls += 1
|
|
233
|
+
if controls != len(result):
|
|
234
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
235
|
+
return tuple(result)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _anonymous_recipient(
|
|
239
|
+
document: html.HtmlElement, group: RecipientGroupReference
|
|
240
|
+
) -> tuple[Recipient, ...]:
|
|
241
|
+
controls = list(document.iter("input"))
|
|
242
|
+
if (
|
|
243
|
+
len(controls) != 2
|
|
244
|
+
or [c.get("name") for c in controls] != ["DoKogo", "DoKogo_hid[]"]
|
|
245
|
+
or any(
|
|
246
|
+
c.get("type", "").casefold() != "hidden" or c.get("id") for c in controls
|
|
247
|
+
)
|
|
248
|
+
or any(
|
|
249
|
+
isinstance(n.tag, str)
|
|
250
|
+
and n.tag not in {"html", "head", "body", "input", "script"}
|
|
251
|
+
for n in document.iter()
|
|
252
|
+
)
|
|
253
|
+
# Page-level scripts are inert and not display labels. They are never
|
|
254
|
+
# executed; only the exact hidden control pair defines this target.
|
|
255
|
+
or "".join(document.xpath("//text()[not(ancestor::script)]")).strip()
|
|
256
|
+
):
|
|
257
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
258
|
+
identifier = controls[0].get("value", "")
|
|
259
|
+
if (
|
|
260
|
+
re.fullmatch(r"[0-9]{1,64}", identifier) is None
|
|
261
|
+
or controls[1].get("value") != identifier
|
|
262
|
+
):
|
|
263
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
264
|
+
return (
|
|
265
|
+
Recipient(
|
|
266
|
+
RecipientReference(
|
|
267
|
+
identifier, group.account, group.identifier, group.selection_id
|
|
268
|
+
),
|
|
269
|
+
None,
|
|
270
|
+
),
|
|
271
|
+
)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Bounded, round-robin admission across isolated configured accounts."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
from librus_python_api.budget import RequestBudget
|
|
13
|
+
from librus_python_api.config import SchedulerLimits
|
|
14
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
15
|
+
from librus_python_api.lifecycle import join_owned
|
|
16
|
+
from librus_python_api.models import SchedulerSnapshot
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class _Waiter:
|
|
21
|
+
ready: asyncio.Future[None]
|
|
22
|
+
granted: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class _Account:
|
|
27
|
+
queue: deque[_Waiter] = field(default_factory=deque)
|
|
28
|
+
active: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RequestScheduler:
|
|
32
|
+
"""One event-loop-local scheduler reused by all account clients in a service.
|
|
33
|
+
|
|
34
|
+
Only admitted work gets an owned worker task. Excess work fails immediately;
|
|
35
|
+
no hidden semaphore waiters or unbounded worker queue is created. Calls are
|
|
36
|
+
scheduled FIFO within accounts and round-robin between eligible accounts.
|
|
37
|
+
`aclose` cancels and joins active workers before returning. This does not
|
|
38
|
+
enforce a distributed quota across independent services/processes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, accounts: tuple[str, ...], *, limits: SchedulerLimits | None = None
|
|
43
|
+
) -> None:
|
|
44
|
+
self._limits = limits or SchedulerLimits()
|
|
45
|
+
if not accounts or len(accounts) > self._limits.accounts:
|
|
46
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
47
|
+
if any(
|
|
48
|
+
not isinstance(key, str) or not key or len(key) > 80 for key in accounts
|
|
49
|
+
):
|
|
50
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
51
|
+
if len(set(accounts)) != len(accounts):
|
|
52
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
53
|
+
self._accounts = {key: _Account() for key in accounts}
|
|
54
|
+
self._ring: deque[str] = deque()
|
|
55
|
+
self._active = self._queued = self._requests_dispatched = 0
|
|
56
|
+
self._tokens = float(self._limits.burst)
|
|
57
|
+
self._updated_at = time.monotonic()
|
|
58
|
+
self._pause_until = 0.0
|
|
59
|
+
self._resuming_without_burst = False
|
|
60
|
+
self._timer: asyncio.TimerHandle | None = None
|
|
61
|
+
self._workers: set[asyncio.Future[Any]] = set()
|
|
62
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
63
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
64
|
+
self._closed = False
|
|
65
|
+
|
|
66
|
+
def snapshot(self) -> SchedulerSnapshot:
|
|
67
|
+
return SchedulerSnapshot(self._active, self._queued, self._requests_dispatched)
|
|
68
|
+
|
|
69
|
+
def _bind_loop(self) -> asyncio.AbstractEventLoop:
|
|
70
|
+
loop = asyncio.get_running_loop()
|
|
71
|
+
if self._loop is not None and self._loop is not loop:
|
|
72
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
73
|
+
self._loop = loop
|
|
74
|
+
if self._closed:
|
|
75
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
76
|
+
return loop
|
|
77
|
+
|
|
78
|
+
async def run[T](
|
|
79
|
+
self,
|
|
80
|
+
account: str,
|
|
81
|
+
budget: RequestBudget,
|
|
82
|
+
action: Callable[[], Awaitable[T]],
|
|
83
|
+
*,
|
|
84
|
+
preserve_completed: bool = False,
|
|
85
|
+
) -> T:
|
|
86
|
+
loop = self._bind_loop()
|
|
87
|
+
state = self._accounts.get(account)
|
|
88
|
+
if state is None:
|
|
89
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
90
|
+
budget._bind_loop()
|
|
91
|
+
budget.check()
|
|
92
|
+
waiter = _Waiter(loop.create_future())
|
|
93
|
+
self._enqueue(account, state, waiter)
|
|
94
|
+
try:
|
|
95
|
+
async with asyncio.timeout(budget.remaining_seconds()):
|
|
96
|
+
await waiter.ready
|
|
97
|
+
if self._closed:
|
|
98
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
99
|
+
budget._consume()
|
|
100
|
+
self._requests_dispatched += 1
|
|
101
|
+
worker = asyncio.ensure_future(action())
|
|
102
|
+
self._workers.add(worker)
|
|
103
|
+
try:
|
|
104
|
+
await asyncio.wait((worker,))
|
|
105
|
+
result = worker.result()
|
|
106
|
+
if not preserve_completed:
|
|
107
|
+
budget.remaining_seconds()
|
|
108
|
+
return result
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
if not isinstance(worker, asyncio.Task) or not worker.cancelling():
|
|
111
|
+
worker.cancel()
|
|
112
|
+
await join_owned(worker)
|
|
113
|
+
if self._closed:
|
|
114
|
+
raise LibrusError(ErrorKind.CLOSED) from None
|
|
115
|
+
raise
|
|
116
|
+
finally:
|
|
117
|
+
self._workers.discard(worker)
|
|
118
|
+
except TimeoutError:
|
|
119
|
+
raise LibrusError(ErrorKind.TIMEOUT) from None
|
|
120
|
+
finally:
|
|
121
|
+
self._release(account, state, waiter)
|
|
122
|
+
|
|
123
|
+
def _enqueue(self, account: str, state: _Account, waiter: _Waiter) -> None:
|
|
124
|
+
self._refill()
|
|
125
|
+
immediate = (
|
|
126
|
+
not self._ring
|
|
127
|
+
and self._active < self._limits.active_requests
|
|
128
|
+
and state.active < self._limits.active_requests_per_account
|
|
129
|
+
and self._tokens >= 1
|
|
130
|
+
and time.monotonic() >= self._pause_until
|
|
131
|
+
)
|
|
132
|
+
if immediate:
|
|
133
|
+
self._grant(state, waiter)
|
|
134
|
+
return
|
|
135
|
+
if (
|
|
136
|
+
self._queued >= self._limits.queued_requests
|
|
137
|
+
or len(state.queue) >= self._limits.queued_requests_per_account
|
|
138
|
+
):
|
|
139
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
140
|
+
if not state.queue:
|
|
141
|
+
self._ring.append(account)
|
|
142
|
+
state.queue.append(waiter)
|
|
143
|
+
self._queued += 1
|
|
144
|
+
self._drain()
|
|
145
|
+
|
|
146
|
+
def _grant(self, state: _Account, waiter: _Waiter) -> None:
|
|
147
|
+
assert self._active < self._limits.active_requests
|
|
148
|
+
assert state.active < self._limits.active_requests_per_account
|
|
149
|
+
assert self._tokens >= 1
|
|
150
|
+
self._tokens -= 1
|
|
151
|
+
state.active += 1
|
|
152
|
+
self._active += 1
|
|
153
|
+
waiter.granted = True
|
|
154
|
+
waiter.ready.set_result(None)
|
|
155
|
+
|
|
156
|
+
def _release(self, account: str, state: _Account, waiter: _Waiter) -> None:
|
|
157
|
+
if waiter.granted:
|
|
158
|
+
state.active -= 1
|
|
159
|
+
self._active -= 1
|
|
160
|
+
assert state.active >= 0
|
|
161
|
+
assert self._active >= 0
|
|
162
|
+
elif waiter in state.queue:
|
|
163
|
+
state.queue.remove(waiter)
|
|
164
|
+
self._queued -= 1
|
|
165
|
+
if not state.queue:
|
|
166
|
+
self._ring.remove(account)
|
|
167
|
+
if not waiter.ready.done():
|
|
168
|
+
waiter.ready.cancel()
|
|
169
|
+
self._drain()
|
|
170
|
+
if (
|
|
171
|
+
waiter.granted
|
|
172
|
+
and self._active == 0
|
|
173
|
+
and not self._ring
|
|
174
|
+
and time.monotonic() >= self._pause_until
|
|
175
|
+
):
|
|
176
|
+
self._resuming_without_burst = False
|
|
177
|
+
|
|
178
|
+
def _refill(self) -> None:
|
|
179
|
+
now = time.monotonic()
|
|
180
|
+
elapsed = max(0.0, now - self._updated_at)
|
|
181
|
+
capacity = 1 if self._resuming_without_burst else self._limits.burst
|
|
182
|
+
self._tokens = min(
|
|
183
|
+
float(capacity),
|
|
184
|
+
self._tokens + elapsed * self._limits.requests_per_second,
|
|
185
|
+
)
|
|
186
|
+
self._updated_at = max(now, self._pause_until)
|
|
187
|
+
|
|
188
|
+
def _drain(self) -> None:
|
|
189
|
+
if self._timer is not None:
|
|
190
|
+
self._timer.cancel()
|
|
191
|
+
self._timer = None
|
|
192
|
+
if self._closed:
|
|
193
|
+
return
|
|
194
|
+
self._refill()
|
|
195
|
+
while self._ring and self._active < self._limits.active_requests:
|
|
196
|
+
state = self._next_eligible()
|
|
197
|
+
if state is None:
|
|
198
|
+
return
|
|
199
|
+
delay = max(
|
|
200
|
+
self._pause_until - time.monotonic(),
|
|
201
|
+
(1 - self._tokens) / self._limits.requests_per_second,
|
|
202
|
+
)
|
|
203
|
+
if delay > 0:
|
|
204
|
+
assert self._loop is not None
|
|
205
|
+
self._timer = self._loop.call_later(delay, self._drain)
|
|
206
|
+
return
|
|
207
|
+
waiter = state.queue.popleft()
|
|
208
|
+
self._queued -= 1
|
|
209
|
+
account = self._ring.popleft()
|
|
210
|
+
if state.queue:
|
|
211
|
+
self._ring.append(account)
|
|
212
|
+
self._grant(state, waiter)
|
|
213
|
+
|
|
214
|
+
def _next_eligible(self) -> _Account | None:
|
|
215
|
+
for _ in range(len(self._ring)):
|
|
216
|
+
if not self._ring:
|
|
217
|
+
return None
|
|
218
|
+
state = self._accounts[self._ring[0]]
|
|
219
|
+
while state.queue and state.queue[0].ready.done():
|
|
220
|
+
state.queue.popleft()
|
|
221
|
+
self._queued -= 1
|
|
222
|
+
if not state.queue:
|
|
223
|
+
self._ring.popleft()
|
|
224
|
+
continue
|
|
225
|
+
if state.active < self._limits.active_requests_per_account:
|
|
226
|
+
return state
|
|
227
|
+
self._ring.rotate(-1)
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def pause_for(self, seconds: float) -> None:
|
|
231
|
+
"""Pause shared dispatch and resume gradually, without a full burst."""
|
|
232
|
+
self._bind_loop()
|
|
233
|
+
if (
|
|
234
|
+
type(seconds) not in (int, float)
|
|
235
|
+
or not math.isfinite(seconds)
|
|
236
|
+
or seconds < 0
|
|
237
|
+
):
|
|
238
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
239
|
+
self._pause_until = max(self._pause_until, time.monotonic() + seconds)
|
|
240
|
+
# Keep resume credit capped until admitted work and its backlog drain.
|
|
241
|
+
# A delayed event-loop timer must not rebuild a burst during a pause.
|
|
242
|
+
self._resuming_without_burst = True
|
|
243
|
+
self._tokens = 0.0
|
|
244
|
+
self._updated_at = self._pause_until
|
|
245
|
+
self._drain()
|
|
246
|
+
|
|
247
|
+
async def aclose(self) -> None:
|
|
248
|
+
loop = asyncio.get_running_loop()
|
|
249
|
+
if self._loop is not None and self._loop is not loop:
|
|
250
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
251
|
+
self._loop = loop
|
|
252
|
+
if asyncio.current_task() in self._workers:
|
|
253
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
254
|
+
if self._close_task is None:
|
|
255
|
+
self._closed = True
|
|
256
|
+
if self._timer is not None:
|
|
257
|
+
self._timer.cancel()
|
|
258
|
+
self._timer = None
|
|
259
|
+
for state in self._accounts.values():
|
|
260
|
+
for waiter in state.queue:
|
|
261
|
+
if not waiter.ready.done():
|
|
262
|
+
waiter.ready.set_exception(LibrusError(ErrorKind.CLOSED))
|
|
263
|
+
self._close_task = asyncio.create_task(self._finish_close())
|
|
264
|
+
try:
|
|
265
|
+
await asyncio.shield(self._close_task)
|
|
266
|
+
except asyncio.CancelledError:
|
|
267
|
+
await join_owned(self._close_task)
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
async def _finish_close(self) -> None:
|
|
271
|
+
workers = tuple(self._workers)
|
|
272
|
+
for worker in workers:
|
|
273
|
+
if not isinstance(worker, asyncio.Task) or not worker.cancelling():
|
|
274
|
+
worker.cancel()
|
|
275
|
+
await asyncio.gather(*workers, return_exceptions=True)
|
|
276
|
+
|
|
277
|
+
async def __aenter__(self) -> Self:
|
|
278
|
+
self._bind_loop()
|
|
279
|
+
return self
|
|
280
|
+
|
|
281
|
+
async def __aexit__(
|
|
282
|
+
self,
|
|
283
|
+
exc_type: type[BaseException] | None,
|
|
284
|
+
exc: BaseException | None,
|
|
285
|
+
traceback: TracebackType | None,
|
|
286
|
+
) -> None:
|
|
287
|
+
await self.aclose()
|