a12s 0.2.1__tar.gz
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.
- a12s-0.2.1/.gitignore +6 -0
- a12s-0.2.1/PKG-INFO +16 -0
- a12s-0.2.1/a12s/__init__.py +64 -0
- a12s-0.2.1/a12s/_async.py +404 -0
- a12s-0.2.1/a12s/_client.py +440 -0
- a12s-0.2.1/a12s/_connect.py +110 -0
- a12s-0.2.1/a12s/_core.py +279 -0
- a12s-0.2.1/a12s/_errors.py +41 -0
- a12s-0.2.1/a12s/_parse.py +124 -0
- a12s-0.2.1/a12s/_sse.py +50 -0
- a12s-0.2.1/a12s/_types.py +269 -0
- a12s-0.2.1/a12s/py.typed +0 -0
- a12s-0.2.1/pyproject.toml +35 -0
a12s-0.2.1/.gitignore
ADDED
a12s-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: a12s
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Python client for a12s: send work to agent Computers, stream their events and Turns
|
|
5
|
+
Project-URL: Repository, https://github.com/coltonkirsten/a12s
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: a12s,agents,claude-code,codex,sdk
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: anyio>=4; extra == 'dev'
|
|
15
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Python client for a12s: send work to agent Computers, stream their events and Turns."""
|
|
2
|
+
|
|
3
|
+
from ._async import AsyncClient, AsyncEventStream, AsyncTurnStream
|
|
4
|
+
from ._client import Client, EventStream, TurnStream
|
|
5
|
+
from ._connect import connect, connect_async
|
|
6
|
+
from ._errors import A12sError, A12sGapError, A12sStreamError
|
|
7
|
+
from ._types import (
|
|
8
|
+
ComputerStatus,
|
|
9
|
+
DriveRow,
|
|
10
|
+
EnqueueResponse,
|
|
11
|
+
Event,
|
|
12
|
+
Failure,
|
|
13
|
+
Harness,
|
|
14
|
+
InboxEnded,
|
|
15
|
+
InboxLimits,
|
|
16
|
+
InboxList,
|
|
17
|
+
InboxSummary,
|
|
18
|
+
InboxUsage,
|
|
19
|
+
LimitValue,
|
|
20
|
+
Message,
|
|
21
|
+
PowerStatus,
|
|
22
|
+
PromptResult,
|
|
23
|
+
ServiceStatus,
|
|
24
|
+
Status,
|
|
25
|
+
Token,
|
|
26
|
+
ToolCall,
|
|
27
|
+
Turn,
|
|
28
|
+
TurnInboxMessage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"A12sError",
|
|
33
|
+
"A12sGapError",
|
|
34
|
+
"A12sStreamError",
|
|
35
|
+
"AsyncClient",
|
|
36
|
+
"AsyncEventStream",
|
|
37
|
+
"AsyncTurnStream",
|
|
38
|
+
"Client",
|
|
39
|
+
"ComputerStatus",
|
|
40
|
+
"DriveRow",
|
|
41
|
+
"EnqueueResponse",
|
|
42
|
+
"Event",
|
|
43
|
+
"EventStream",
|
|
44
|
+
"Failure",
|
|
45
|
+
"Harness",
|
|
46
|
+
"InboxEnded",
|
|
47
|
+
"InboxLimits",
|
|
48
|
+
"InboxList",
|
|
49
|
+
"InboxSummary",
|
|
50
|
+
"InboxUsage",
|
|
51
|
+
"LimitValue",
|
|
52
|
+
"Message",
|
|
53
|
+
"PowerStatus",
|
|
54
|
+
"PromptResult",
|
|
55
|
+
"ServiceStatus",
|
|
56
|
+
"Status",
|
|
57
|
+
"Token",
|
|
58
|
+
"ToolCall",
|
|
59
|
+
"Turn",
|
|
60
|
+
"TurnInboxMessage",
|
|
61
|
+
"TurnStream",
|
|
62
|
+
"connect",
|
|
63
|
+
"connect_async",
|
|
64
|
+
]
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence
|
|
5
|
+
from contextlib import aclosing
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Literal, Union
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _core as core
|
|
12
|
+
from ._errors import A12sError, A12sStreamError
|
|
13
|
+
from ._parse import from_json
|
|
14
|
+
from ._sse import SSEDecoder
|
|
15
|
+
from ._types import (
|
|
16
|
+
ComputerStatus,
|
|
17
|
+
EnqueueResponse,
|
|
18
|
+
Event,
|
|
19
|
+
InboxList,
|
|
20
|
+
Message,
|
|
21
|
+
PromptResult,
|
|
22
|
+
ServiceStatus,
|
|
23
|
+
Status,
|
|
24
|
+
Token,
|
|
25
|
+
Turn,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncClient:
|
|
30
|
+
"""The asyncio twin of Client: the same methods, awaited."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, url: str, token: str | None = None, *, http: httpx.AsyncClient | None = None) -> None:
|
|
33
|
+
self.url = url.rstrip("/")
|
|
34
|
+
self._token = token
|
|
35
|
+
self._owns_http = http is None
|
|
36
|
+
self._http = http if http is not None else httpx.AsyncClient(timeout=core.TIMEOUT)
|
|
37
|
+
|
|
38
|
+
async def aclose(self) -> None:
|
|
39
|
+
if self._owns_http:
|
|
40
|
+
await self._http.aclose()
|
|
41
|
+
|
|
42
|
+
async def __aenter__(self) -> AsyncClient:
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
46
|
+
await self.aclose()
|
|
47
|
+
|
|
48
|
+
async def _request(self, method: str, path: str, body: Any = None) -> Any:
|
|
49
|
+
res = await self._http.request(method, self.url + path, **core.json_request(self._token, body))
|
|
50
|
+
return core.decode(res)
|
|
51
|
+
|
|
52
|
+
async def status(self) -> Status:
|
|
53
|
+
"""Every Computer and Service: phase, session, active Turn, inbox, power."""
|
|
54
|
+
return from_json(Status, await self._request("GET", "/v1/status"))
|
|
55
|
+
|
|
56
|
+
async def computer(self, name: str) -> ComputerStatus:
|
|
57
|
+
for c in (await self.status()).computers:
|
|
58
|
+
if c.name == name:
|
|
59
|
+
return c
|
|
60
|
+
raise A12sError(404, "not_found", f"no Computer {name}")
|
|
61
|
+
|
|
62
|
+
async def send(
|
|
63
|
+
self,
|
|
64
|
+
computer: str,
|
|
65
|
+
body: str,
|
|
66
|
+
*,
|
|
67
|
+
sender: str | None = None,
|
|
68
|
+
metadata: dict[str, str] | None = None,
|
|
69
|
+
fresh: bool = False,
|
|
70
|
+
idempotency_key: str | None = None,
|
|
71
|
+
expires: int | None = None,
|
|
72
|
+
replace_key: str | None = None,
|
|
73
|
+
) -> EnqueueResponse:
|
|
74
|
+
"""See Client.send."""
|
|
75
|
+
m = core.enqueue_body(body, sender, metadata, fresh, idempotency_key, expires, replace_key)
|
|
76
|
+
return from_json(EnqueueResponse, await self._request("POST", f"/v1/computers/{core.enc(computer)}/inbox", m))
|
|
77
|
+
|
|
78
|
+
async def prompt(
|
|
79
|
+
self,
|
|
80
|
+
computer: str,
|
|
81
|
+
body: str,
|
|
82
|
+
*,
|
|
83
|
+
on_event: Callable[[Event], None] | None = None,
|
|
84
|
+
sender: str | None = None,
|
|
85
|
+
metadata: dict[str, str] | None = None,
|
|
86
|
+
fresh: bool = False,
|
|
87
|
+
idempotency_key: str | None = None,
|
|
88
|
+
expires: int | None = None,
|
|
89
|
+
replace_key: str | None = None,
|
|
90
|
+
) -> PromptResult:
|
|
91
|
+
"""See Client.prompt."""
|
|
92
|
+
message = await self.send(
|
|
93
|
+
computer,
|
|
94
|
+
body,
|
|
95
|
+
sender=sender,
|
|
96
|
+
metadata=metadata,
|
|
97
|
+
fresh=fresh,
|
|
98
|
+
idempotency_key=idempotency_key,
|
|
99
|
+
expires=expires,
|
|
100
|
+
replace_key=replace_key,
|
|
101
|
+
)
|
|
102
|
+
turn = await self._delivered_in(computer, message.id)
|
|
103
|
+
builder = core.TurnBuilder()
|
|
104
|
+
# A gap mid-Turn would silently drop text: fail loudly instead.
|
|
105
|
+
state = core.StreamState(computer, None, "throw")
|
|
106
|
+
async with aclosing(self._stream(core.events_path(computer, turn), state, 1.0)) as events:
|
|
107
|
+
async for ev in events:
|
|
108
|
+
if on_event is not None:
|
|
109
|
+
on_event(ev)
|
|
110
|
+
done = builder.add(ev)
|
|
111
|
+
if done is not None and done.id == turn:
|
|
112
|
+
return core.prompt_result(done, message)
|
|
113
|
+
raise A12sStreamError(f"event stream for turn {turn} ended early")
|
|
114
|
+
|
|
115
|
+
def events(
|
|
116
|
+
self,
|
|
117
|
+
computers: str | Sequence[str] | None = None,
|
|
118
|
+
*,
|
|
119
|
+
turn: str | None = None,
|
|
120
|
+
after: int | None = None,
|
|
121
|
+
reconnect: float = 1.0,
|
|
122
|
+
on_gap: core.OnGap = "yield",
|
|
123
|
+
) -> AsyncEventStream:
|
|
124
|
+
"""See Client.events."""
|
|
125
|
+
names = core.stream_names(computers, turn, after)
|
|
126
|
+
|
|
127
|
+
def source(name: str, live: Callable[[], None]) -> AsyncGenerator[Event, None]:
|
|
128
|
+
state = core.StreamState(name, after, on_gap, live)
|
|
129
|
+
return self._stream(core.events_path(name, turn), state, reconnect)
|
|
130
|
+
|
|
131
|
+
async def all_names() -> list[str]:
|
|
132
|
+
return [c.name for c in (await self.status()).computers]
|
|
133
|
+
|
|
134
|
+
return AsyncEventStream(source, names if names is not None else all_names)
|
|
135
|
+
|
|
136
|
+
def turns(
|
|
137
|
+
self,
|
|
138
|
+
computers: str | Sequence[str] | None = None,
|
|
139
|
+
*,
|
|
140
|
+
reconnect: float = 1.0,
|
|
141
|
+
on_gap: core.OnGap = "yield",
|
|
142
|
+
) -> AsyncTurnStream:
|
|
143
|
+
"""See Client.turns."""
|
|
144
|
+
return AsyncTurnStream(self.events(computers, reconnect=reconnect, on_gap=on_gap))
|
|
145
|
+
|
|
146
|
+
async def inbox(
|
|
147
|
+
self, computer: str, *, state: str | None = None, bodies: bool = False, after: int | None = None
|
|
148
|
+
) -> InboxList:
|
|
149
|
+
return from_json(InboxList, await self._request("GET", core.inbox_path(computer, state, bodies, after)))
|
|
150
|
+
|
|
151
|
+
async def message(self, computer: str, id: str) -> Message:
|
|
152
|
+
path = f"/v1/computers/{core.enc(computer)}/inbox/{core.enc(id)}"
|
|
153
|
+
return from_json(Message, await self._request("GET", path))
|
|
154
|
+
|
|
155
|
+
async def withdraw(self, computer: str, id: str) -> Message:
|
|
156
|
+
"""Withdraws a pending Message; a 409 A12sError names its state if it is too late."""
|
|
157
|
+
path = f"/v1/computers/{core.enc(computer)}/inbox/{core.enc(id)}"
|
|
158
|
+
return from_json(Message, await self._request("DELETE", path))
|
|
159
|
+
|
|
160
|
+
async def stop(self, computer: str, turn: str | None = None) -> None:
|
|
161
|
+
"""Interrupts a Turn (default: the active one). The Turn ends ``interrupted``; nothing is replayed."""
|
|
162
|
+
id = turn if turn is not None else (await self.computer(computer)).active_turn
|
|
163
|
+
if id:
|
|
164
|
+
await self._request("POST", f"/v1/computers/{core.enc(computer)}/turns/{core.enc(id)}/stop", {})
|
|
165
|
+
|
|
166
|
+
async def sleep(self, computer: str) -> None:
|
|
167
|
+
"""Stops the Computer's processes and keeps its disks, session and inbox."""
|
|
168
|
+
await self._request("POST", f"/v1/computers/{core.enc(computer)}/power", {"state": "asleep"})
|
|
169
|
+
|
|
170
|
+
async def wake(self, computer: str) -> None:
|
|
171
|
+
"""Starts a sleeping Computer; its session resumes on the next Turn."""
|
|
172
|
+
await self._request("POST", f"/v1/computers/{core.enc(computer)}/power", {"state": "awake"})
|
|
173
|
+
|
|
174
|
+
async def services(self) -> list[ServiceStatus]:
|
|
175
|
+
res = await self._request("GET", "/v1/services")
|
|
176
|
+
return [from_json(ServiceStatus, s) for s in (res or {}).get("services") or []]
|
|
177
|
+
|
|
178
|
+
async def service(self, name: str) -> ServiceStatus:
|
|
179
|
+
return from_json(ServiceStatus, await self._request("GET", f"/v1/services/{core.enc(name)}"))
|
|
180
|
+
|
|
181
|
+
async def create_token(self, name: str, actions: Sequence[str], computers: Sequence[str]) -> Token:
|
|
182
|
+
"""A scoped token for another program; owner only. The secret is in the response once."""
|
|
183
|
+
req = {"name": name, "actions": list(actions), "computers": list(computers)}
|
|
184
|
+
return from_json(Token, await self._request("POST", "/v1/tokens", req))
|
|
185
|
+
|
|
186
|
+
async def put_fleet(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""What ``a12s up`` sends: validate and apply a whole Fleet. Most apps leave this to the CLI."""
|
|
188
|
+
res: dict[str, Any] = await self._request("PUT", "/v1/fleet", request)
|
|
189
|
+
return res
|
|
190
|
+
|
|
191
|
+
async def _delivered_in(self, computer: str, id: str) -> str:
|
|
192
|
+
# Every subscription starts with a snapshot, so resubscribing after a drop never misses the delivery.
|
|
193
|
+
while True:
|
|
194
|
+
state = core.StreamState(computer, None)
|
|
195
|
+
async with aclosing(self._stream(core.message_events_path(computer, id), state, 1.0, once=True)) as s:
|
|
196
|
+
async for ev in s:
|
|
197
|
+
turn = core.delivered_turn(ev, computer, id)
|
|
198
|
+
if turn is not None:
|
|
199
|
+
return turn
|
|
200
|
+
await asyncio.sleep(1.0)
|
|
201
|
+
|
|
202
|
+
async def _stream(
|
|
203
|
+
self, path: str, state: core.StreamState, reconnect: float, *, once: bool = False
|
|
204
|
+
) -> AsyncGenerator[Event, None]:
|
|
205
|
+
while True:
|
|
206
|
+
try:
|
|
207
|
+
async with self._http.stream("GET", self.url + path, headers=state.headers(self._token)) as res:
|
|
208
|
+
if not res.is_success:
|
|
209
|
+
await res.aread()
|
|
210
|
+
state.refused(res.status_code, res.text)
|
|
211
|
+
else:
|
|
212
|
+
# Only reading the body is retried: a read that fails is the connection dropping. A
|
|
213
|
+
# malformed frame or an exception thrown into the generator ends the stream instead.
|
|
214
|
+
decoder = SSEDecoder()
|
|
215
|
+
try:
|
|
216
|
+
async for chunk in res.aiter_bytes():
|
|
217
|
+
for frame in decoder.feed(chunk):
|
|
218
|
+
ev = state.handle(frame)
|
|
219
|
+
if ev is not None:
|
|
220
|
+
yield ev
|
|
221
|
+
except httpx.TransportError:
|
|
222
|
+
pass
|
|
223
|
+
if once:
|
|
224
|
+
return
|
|
225
|
+
except httpx.TransportError:
|
|
226
|
+
pass
|
|
227
|
+
await asyncio.sleep(reconnect)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_Item = Union[
|
|
231
|
+
tuple[Literal["event"], Event],
|
|
232
|
+
tuple[Literal["error"], BaseException],
|
|
233
|
+
tuple[Literal["done", "wake"], None],
|
|
234
|
+
]
|
|
235
|
+
_Source = Callable[[str, Callable[[], None]], AsyncGenerator[Event, None]]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class AsyncEventStream:
|
|
239
|
+
"""Events of one or more Computers, merged in arrival order: one task per Computer feeds the iterator.
|
|
240
|
+
|
|
241
|
+
``async with client.events() as events:`` awaits ``ready()`` on entry and closes the stream on exit.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, source: _Source, names: list[str] | Callable[[], Awaitable[list[str]]]) -> None:
|
|
245
|
+
self._source = source
|
|
246
|
+
self._names = names
|
|
247
|
+
self._queue: asyncio.Queue[_Item] = asyncio.Queue()
|
|
248
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
249
|
+
self._started = False
|
|
250
|
+
self._closed = False
|
|
251
|
+
self._remaining = -1
|
|
252
|
+
self._live = 0
|
|
253
|
+
self._failed = False
|
|
254
|
+
self._ready = asyncio.Event()
|
|
255
|
+
self._ready_error: BaseException | None = None
|
|
256
|
+
|
|
257
|
+
async def ready(self) -> None:
|
|
258
|
+
"""See EventStream.ready."""
|
|
259
|
+
self._start()
|
|
260
|
+
await self._ready.wait()
|
|
261
|
+
if self._ready_error is not None:
|
|
262
|
+
raise self._ready_error
|
|
263
|
+
|
|
264
|
+
def close(self) -> None:
|
|
265
|
+
self._closed = True
|
|
266
|
+
for t in self._tasks:
|
|
267
|
+
t.cancel()
|
|
268
|
+
self._ready.set()
|
|
269
|
+
self._queue.put_nowait(("wake", None))
|
|
270
|
+
|
|
271
|
+
async def aclose(self) -> None:
|
|
272
|
+
"""close(), then waits for the streams' connections to be released."""
|
|
273
|
+
self.close()
|
|
274
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
275
|
+
|
|
276
|
+
def __aiter__(self) -> AsyncEventStream:
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
async def __anext__(self) -> Event:
|
|
280
|
+
self._start()
|
|
281
|
+
while True:
|
|
282
|
+
if self._closed or self._remaining == 0:
|
|
283
|
+
try:
|
|
284
|
+
item = self._queue.get_nowait()
|
|
285
|
+
except asyncio.QueueEmpty:
|
|
286
|
+
raise StopAsyncIteration from None
|
|
287
|
+
else:
|
|
288
|
+
item = await self._queue.get()
|
|
289
|
+
if item[0] == "event":
|
|
290
|
+
return item[1]
|
|
291
|
+
if item[0] == "error":
|
|
292
|
+
self.close()
|
|
293
|
+
raise item[1]
|
|
294
|
+
if item[0] == "done":
|
|
295
|
+
self._remaining -= 1
|
|
296
|
+
|
|
297
|
+
async def __aenter__(self) -> AsyncEventStream:
|
|
298
|
+
try:
|
|
299
|
+
await self.ready()
|
|
300
|
+
except BaseException:
|
|
301
|
+
await self.aclose()
|
|
302
|
+
raise
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
async def __aexit__(
|
|
306
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
307
|
+
) -> None:
|
|
308
|
+
await self.aclose()
|
|
309
|
+
|
|
310
|
+
def _start(self) -> None:
|
|
311
|
+
if self._started:
|
|
312
|
+
return
|
|
313
|
+
self._started = True
|
|
314
|
+
self._spawn(self._run())
|
|
315
|
+
|
|
316
|
+
def _spawn(self, coro: Awaitable[None]) -> None:
|
|
317
|
+
task: asyncio.Task[None] = asyncio.ensure_future(coro)
|
|
318
|
+
self._tasks.add(task)
|
|
319
|
+
task.add_done_callback(self._tasks.discard)
|
|
320
|
+
|
|
321
|
+
async def _run(self) -> None:
|
|
322
|
+
try:
|
|
323
|
+
names = await self._names() if callable(self._names) else self._names
|
|
324
|
+
except Exception as err:
|
|
325
|
+
self._remaining = 0
|
|
326
|
+
self._fail(err)
|
|
327
|
+
return
|
|
328
|
+
self._remaining = len(names)
|
|
329
|
+
if not names:
|
|
330
|
+
self._ready.set()
|
|
331
|
+
for name in names:
|
|
332
|
+
self._spawn(self._pump(name, self._live_once(len(names))))
|
|
333
|
+
self._queue.put_nowait(("wake", None))
|
|
334
|
+
|
|
335
|
+
def _live_once(self, total: int) -> Callable[[], None]:
|
|
336
|
+
counted = False
|
|
337
|
+
|
|
338
|
+
def on_live() -> None:
|
|
339
|
+
nonlocal counted
|
|
340
|
+
if counted:
|
|
341
|
+
return
|
|
342
|
+
counted = True
|
|
343
|
+
self._live += 1
|
|
344
|
+
if self._live == total:
|
|
345
|
+
self._ready.set()
|
|
346
|
+
|
|
347
|
+
return on_live
|
|
348
|
+
|
|
349
|
+
async def _pump(self, name: str, on_live: Callable[[], None]) -> None:
|
|
350
|
+
try:
|
|
351
|
+
async with aclosing(self._source(name, on_live)) as events:
|
|
352
|
+
async for ev in events:
|
|
353
|
+
self._queue.put_nowait(("event", ev))
|
|
354
|
+
except Exception as err:
|
|
355
|
+
if not self._closed:
|
|
356
|
+
self._fail(err)
|
|
357
|
+
finally:
|
|
358
|
+
on_live()
|
|
359
|
+
self._queue.put_nowait(("done", None))
|
|
360
|
+
|
|
361
|
+
def _fail(self, err: BaseException) -> None:
|
|
362
|
+
if self._failed:
|
|
363
|
+
return
|
|
364
|
+
self._failed = True
|
|
365
|
+
if not self._ready.is_set():
|
|
366
|
+
self._ready_error = err
|
|
367
|
+
self._ready.set()
|
|
368
|
+
self._queue.put_nowait(("error", err))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class AsyncTurnStream:
|
|
372
|
+
"""Finished Turns of one or more Computers. Same ``ready()``, ``close()`` and ``async with`` as AsyncEventStream."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, events: AsyncEventStream) -> None:
|
|
375
|
+
self._events = events
|
|
376
|
+
self._builder = core.TurnBuilder()
|
|
377
|
+
|
|
378
|
+
async def ready(self) -> None:
|
|
379
|
+
await self._events.ready()
|
|
380
|
+
|
|
381
|
+
def close(self) -> None:
|
|
382
|
+
self._events.close()
|
|
383
|
+
|
|
384
|
+
async def aclose(self) -> None:
|
|
385
|
+
await self._events.aclose()
|
|
386
|
+
|
|
387
|
+
def __aiter__(self) -> AsyncTurnStream:
|
|
388
|
+
return self
|
|
389
|
+
|
|
390
|
+
async def __anext__(self) -> Turn:
|
|
391
|
+
async for ev in self._events:
|
|
392
|
+
turn = self._builder.add(ev)
|
|
393
|
+
if turn is not None:
|
|
394
|
+
return turn
|
|
395
|
+
raise StopAsyncIteration
|
|
396
|
+
|
|
397
|
+
async def __aenter__(self) -> AsyncTurnStream:
|
|
398
|
+
await self._events.__aenter__()
|
|
399
|
+
return self
|
|
400
|
+
|
|
401
|
+
async def __aexit__(
|
|
402
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
403
|
+
) -> None:
|
|
404
|
+
await self.aclose()
|