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.
Files changed (48) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_bootstrap.py +124 -0
  3. librus_python_api/_notification_codec.py +396 -0
  4. librus_python_api/_storage.py +403 -0
  5. librus_python_api/_windows_filesystem.py +390 -0
  6. librus_python_api/announcements.py +159 -0
  7. librus_python_api/attachment_routes.py +114 -0
  8. librus_python_api/attachments.py +297 -0
  9. librus_python_api/attendance.py +182 -0
  10. librus_python_api/attendance_frequency.py +112 -0
  11. librus_python_api/budget.py +79 -0
  12. librus_python_api/checkpoint.py +61 -0
  13. librus_python_api/completed_lessons.py +216 -0
  14. librus_python_api/config.py +1410 -0
  15. librus_python_api/detail_fields.py +50 -0
  16. librus_python_api/diagnostics.py +25 -0
  17. librus_python_api/exceptions.py +172 -0
  18. librus_python_api/files.py +242 -0
  19. librus_python_api/grade_parsers.py +169 -0
  20. librus_python_api/grade_records.py +454 -0
  21. librus_python_api/homework_range.py +41 -0
  22. librus_python_api/lifecycle.py +24 -0
  23. librus_python_api/markup.py +147 -0
  24. librus_python_api/message_content.py +230 -0
  25. librus_python_api/messages.py +288 -0
  26. librus_python_api/models.py +1160 -0
  27. librus_python_api/modern_body.py +75 -0
  28. librus_python_api/modern_mailbox.py +459 -0
  29. librus_python_api/modern_messages.py +276 -0
  30. librus_python_api/notification_models.py +145 -0
  31. librus_python_api/notification_persistence.py +1243 -0
  32. librus_python_api/notification_workflow.py +337 -0
  33. librus_python_api/notifications.py +216 -0
  34. librus_python_api/parsers.py +232 -0
  35. librus_python_api/parsing.py +49 -0
  36. librus_python_api/persistence.py +419 -0
  37. librus_python_api/py.typed +0 -0
  38. librus_python_api/recipients.py +271 -0
  39. librus_python_api/scheduler.py +287 -0
  40. librus_python_api/school_reads.py +400 -0
  41. librus_python_api/sending.py +125 -0
  42. librus_python_api/service.py +2285 -0
  43. librus_python_api/timetable.py +261 -0
  44. librus_python_api/transport.py +956 -0
  45. librus_python_api-1.0.0.dist-info/METADATA +262 -0
  46. librus_python_api-1.0.0.dist-info/RECORD +48 -0
  47. librus_python_api-1.0.0.dist-info/WHEEL +4 -0
  48. librus_python_api-1.0.0.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()