pi-agent-python-sdk 0.1.0__py3-none-any.whl → 0.2.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.
- pi_agent/__init__.py +3 -0
- pi_agent/_collections.py +59 -0
- pi_agent/_events.py +58 -17
- pi_agent/_observation.py +73 -0
- pi_agent/_runs.py +49 -24
- pi_agent/_transport.py +107 -25
- pi_agent/client.py +273 -46
- pi_agent/errors.py +1 -1
- pi_agent/sync.py +222 -37
- pi_agent/types.py +21 -16
- {pi_agent_python_sdk-0.1.0.dist-info → pi_agent_python_sdk-0.2.0.dist-info}/METADATA +46 -28
- pi_agent_python_sdk-0.2.0.dist-info/RECORD +17 -0
- pi_agent_python_sdk-0.1.0.dist-info/RECORD +0 -15
- {pi_agent_python_sdk-0.1.0.dist-info → pi_agent_python_sdk-0.2.0.dist-info}/WHEEL +0 -0
- {pi_agent_python_sdk-0.1.0.dist-info → pi_agent_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
pi_agent/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Importing this package never launches Pi or changes its configuration.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from ._observation import ObservationStatus, ProcessOutput
|
|
6
7
|
from .client import AsyncPiClient
|
|
7
8
|
from .errors import (
|
|
8
9
|
PiBusyError,
|
|
@@ -27,6 +28,8 @@ __all__ = [
|
|
|
27
28
|
"Event",
|
|
28
29
|
"ImageContent",
|
|
29
30
|
"Limits",
|
|
31
|
+
"ObservationStatus",
|
|
32
|
+
"ProcessOutput",
|
|
30
33
|
"PiBusyError",
|
|
31
34
|
"PiCommandError",
|
|
32
35
|
"PiClient",
|
pi_agent/_collections.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Bounded observation through the next settlement, without run ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from ._events import EventSubscription
|
|
9
|
+
from .errors import PiProcessError, PiResultOverflow, PiTimeoutError
|
|
10
|
+
from .types import Event, Limits, _validate_timeout
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def deadline(timeout: float | None) -> float | None:
|
|
14
|
+
if timeout is None:
|
|
15
|
+
return None
|
|
16
|
+
_validate_timeout(timeout, "Collection timeout")
|
|
17
|
+
return asyncio.get_running_loop().time() + timeout
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def start_collection(
|
|
21
|
+
subscription: EventSubscription,
|
|
22
|
+
limits: Limits,
|
|
23
|
+
*,
|
|
24
|
+
timeout: float | None,
|
|
25
|
+
retain: bool,
|
|
26
|
+
) -> asyncio.Task[list[Event] | None]:
|
|
27
|
+
expires = deadline(timeout)
|
|
28
|
+
# Register now, not when the task first runs: callers may submit immediately.
|
|
29
|
+
subscription._enter()
|
|
30
|
+
|
|
31
|
+
async def consume() -> list[Event] | None:
|
|
32
|
+
events: list[Event] = []
|
|
33
|
+
size = 0
|
|
34
|
+
timer = asyncio.timeout_at(expires)
|
|
35
|
+
try:
|
|
36
|
+
async with timer:
|
|
37
|
+
async for event in subscription:
|
|
38
|
+
if retain:
|
|
39
|
+
size += len(json.dumps(event.raw).encode("utf-8"))
|
|
40
|
+
if (
|
|
41
|
+
len(events) >= limits.collection_event_count
|
|
42
|
+
or size > limits.collection_event_bytes
|
|
43
|
+
):
|
|
44
|
+
raise PiResultOverflow("Event collection exceeded its count/byte limit")
|
|
45
|
+
events.append(event)
|
|
46
|
+
if event.type == "agent_settled":
|
|
47
|
+
return events if retain else None
|
|
48
|
+
raise PiProcessError("Event collection closed before settlement")
|
|
49
|
+
except TimeoutError as exc:
|
|
50
|
+
if not timer.expired():
|
|
51
|
+
raise
|
|
52
|
+
raise PiTimeoutError("Event collection deadline elapsed", uncertain=False) from exc
|
|
53
|
+
finally:
|
|
54
|
+
await subscription.aclose()
|
|
55
|
+
|
|
56
|
+
task = asyncio.create_task(consume(), name="pi-event-collection")
|
|
57
|
+
# Cancellation before the task's first instruction cannot run its finally.
|
|
58
|
+
task.add_done_callback(lambda _: subscription._discard())
|
|
59
|
+
return task
|
pi_agent/_events.py
CHANGED
|
@@ -6,13 +6,15 @@ import asyncio
|
|
|
6
6
|
from collections import deque
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from types import TracebackType
|
|
9
|
-
from typing import Self
|
|
9
|
+
from typing import Generic, Self, TypeVar
|
|
10
10
|
|
|
11
11
|
from .errors import PiSubscriptionOverflow
|
|
12
12
|
from .types import Event, Limits
|
|
13
13
|
|
|
14
|
+
T = TypeVar("T")
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
class _Subscription(Generic[T]):
|
|
16
18
|
"""Enter before submitting work to observe its events without a history buffer.
|
|
17
19
|
|
|
18
20
|
A slow consumer receives PiSubscriptionOverflow rather than silently losing
|
|
@@ -22,13 +24,13 @@ class EventSubscription:
|
|
|
22
24
|
def __init__(
|
|
23
25
|
self,
|
|
24
26
|
limits: Limits,
|
|
25
|
-
register: Callable[[
|
|
26
|
-
unregister: Callable[[
|
|
27
|
+
register: Callable[[], None],
|
|
28
|
+
unregister: Callable[[], None],
|
|
27
29
|
) -> None:
|
|
28
30
|
self._limits = limits
|
|
29
31
|
self._register = register
|
|
30
32
|
self._unregister = unregister
|
|
31
|
-
self._records: deque[tuple[
|
|
33
|
+
self._records: deque[tuple[T, int]] = deque()
|
|
32
34
|
self._bytes = 0
|
|
33
35
|
self._ready = asyncio.Event()
|
|
34
36
|
self._entered = False
|
|
@@ -37,9 +39,12 @@ class EventSubscription:
|
|
|
37
39
|
self._reading = False
|
|
38
40
|
|
|
39
41
|
async def __aenter__(self) -> Self:
|
|
42
|
+
return self._enter()
|
|
43
|
+
|
|
44
|
+
def _enter(self) -> Self:
|
|
40
45
|
if self._entered or self._closed:
|
|
41
46
|
raise RuntimeError("Event subscriptions are single-use")
|
|
42
|
-
self._register(
|
|
47
|
+
self._register()
|
|
43
48
|
self._entered = True
|
|
44
49
|
return self
|
|
45
50
|
|
|
@@ -53,22 +58,29 @@ class EventSubscription:
|
|
|
53
58
|
|
|
54
59
|
async def aclose(self) -> None:
|
|
55
60
|
"""Unsubscribe and release queued payloads; safe to call repeatedly."""
|
|
61
|
+
self._discard()
|
|
62
|
+
|
|
63
|
+
def _discard(self, *, buffered: bool = False) -> None:
|
|
64
|
+
"""Also used by the sync facade after closing the subscription."""
|
|
56
65
|
self._finish()
|
|
57
66
|
self._records.clear()
|
|
58
67
|
self._bytes = 0
|
|
68
|
+
self._error = None
|
|
59
69
|
|
|
60
70
|
def _finish(self, error: Exception | None = None) -> None:
|
|
61
71
|
if self._closed:
|
|
62
72
|
return
|
|
63
73
|
self._closed = True
|
|
64
74
|
self._error = error
|
|
65
|
-
self._unregister(
|
|
66
|
-
if error
|
|
75
|
+
self._unregister()
|
|
76
|
+
if isinstance(error, PiSubscriptionOverflow):
|
|
77
|
+
# Overflow is loss, not a complete prefix ending at a process failure.
|
|
78
|
+
# Keep it immediate; a healthy terminal queue can still be drained.
|
|
67
79
|
self._records.clear()
|
|
68
80
|
self._bytes = 0
|
|
69
81
|
self._ready.set()
|
|
70
82
|
|
|
71
|
-
def _put(self, event:
|
|
83
|
+
def _put(self, event: T, size: int) -> Exception | None:
|
|
72
84
|
if self._closed:
|
|
73
85
|
return self._error
|
|
74
86
|
if (
|
|
@@ -86,7 +98,7 @@ class EventSubscription:
|
|
|
86
98
|
def __aiter__(self) -> Self:
|
|
87
99
|
return self
|
|
88
100
|
|
|
89
|
-
async def __anext__(self) ->
|
|
101
|
+
async def __anext__(self) -> T:
|
|
90
102
|
if not self._entered:
|
|
91
103
|
raise RuntimeError("Enter the event subscription context before iterating")
|
|
92
104
|
if self._reading:
|
|
@@ -94,15 +106,44 @@ class EventSubscription:
|
|
|
94
106
|
self._reading = True
|
|
95
107
|
try:
|
|
96
108
|
while True:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if self._records:
|
|
100
|
-
event, size = self._records.popleft()
|
|
101
|
-
self._bytes -= size
|
|
109
|
+
event = self._next_nowait()
|
|
110
|
+
if event is not None:
|
|
102
111
|
return event
|
|
103
|
-
if self._closed:
|
|
104
|
-
raise StopAsyncIteration
|
|
105
112
|
self._ready.clear()
|
|
106
113
|
await self._ready.wait()
|
|
107
114
|
finally:
|
|
108
115
|
self._reading = False
|
|
116
|
+
|
|
117
|
+
async def _next_batch(self) -> list[T]:
|
|
118
|
+
records = [await self.__anext__()]
|
|
119
|
+
# Transfer only records already buffered; never wait to fill a batch.
|
|
120
|
+
# This retains at most one queue's byte budget in the blocking facade.
|
|
121
|
+
while self._records and len(records) < 64:
|
|
122
|
+
record = self._next_nowait()
|
|
123
|
+
assert record is not None
|
|
124
|
+
records.append(record)
|
|
125
|
+
return records
|
|
126
|
+
|
|
127
|
+
def _next_nowait(self) -> T | None:
|
|
128
|
+
"""Read on the owning loop, or after that loop has completely stopped."""
|
|
129
|
+
if self._records:
|
|
130
|
+
event, size = self._records.popleft()
|
|
131
|
+
self._bytes -= size
|
|
132
|
+
return event
|
|
133
|
+
if self._error is not None:
|
|
134
|
+
raise self._error
|
|
135
|
+
if self._closed:
|
|
136
|
+
raise StopAsyncIteration
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class EventSubscription(_Subscription[Event]):
|
|
141
|
+
"""Bounded future events; enter before startup to include startup events."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
limits: Limits,
|
|
146
|
+
register: Callable[[EventSubscription], None],
|
|
147
|
+
unregister: Callable[[EventSubscription], None],
|
|
148
|
+
) -> None:
|
|
149
|
+
super().__init__(limits, lambda: register(self), lambda: unregister(self))
|
pi_agent/_observation.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Opt-in process output delivery; storage and forwarding belong to callers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from ._events import _Subscription
|
|
11
|
+
from .errors import PiSubscriptionOverflow
|
|
12
|
+
from .types import Limits
|
|
13
|
+
|
|
14
|
+
OutputSource = Literal["stderr", "stdout", "rpc"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ProcessOutput:
|
|
19
|
+
"""Original bytes or a parsed object from the RPC child, excluding the version probe."""
|
|
20
|
+
|
|
21
|
+
source: OutputSource
|
|
22
|
+
time_ns: int
|
|
23
|
+
data: bytes | dict[str, Any] = field(repr=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ObservationStatus:
|
|
28
|
+
"""Delivery coverage, independent of RPC success and consumer storage."""
|
|
29
|
+
|
|
30
|
+
started_at_ns: int | None = None
|
|
31
|
+
ended_at_ns: int | None = None
|
|
32
|
+
from_start: bool = False
|
|
33
|
+
complete: bool = False
|
|
34
|
+
lost: bool = False
|
|
35
|
+
stderr_eof: bool = False
|
|
36
|
+
stdout_eof: bool = False
|
|
37
|
+
rpc_complete: bool = True
|
|
38
|
+
error: Exception | None = field(default=None, repr=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProcessObservation(_Subscription[ProcessOutput]):
|
|
42
|
+
"""Bounded output iterator. Check status after draining to detect incomplete output."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
limits: Limits,
|
|
47
|
+
register: Callable[[ProcessObservation], None],
|
|
48
|
+
unregister: Callable[[ProcessObservation], None],
|
|
49
|
+
sources: frozenset[OutputSource],
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(limits, lambda: register(self), lambda: unregister(self))
|
|
52
|
+
self._status = ObservationStatus()
|
|
53
|
+
self._sources = sources
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def status(self) -> ObservationStatus:
|
|
57
|
+
return self._status
|
|
58
|
+
|
|
59
|
+
def _finish(self, error: Exception | None = None) -> None:
|
|
60
|
+
if not self._closed and self._status.ended_at_ns is None:
|
|
61
|
+
self._status = ObservationStatus(
|
|
62
|
+
started_at_ns=self._status.started_at_ns,
|
|
63
|
+
ended_at_ns=time.time_ns(),
|
|
64
|
+
from_start=self._status.from_start,
|
|
65
|
+
lost=self._status.lost or isinstance(error, PiSubscriptionOverflow),
|
|
66
|
+
error=error,
|
|
67
|
+
)
|
|
68
|
+
super()._finish(error)
|
|
69
|
+
|
|
70
|
+
def _discard(self, *, buffered: bool = False) -> None:
|
|
71
|
+
if self._records or buffered:
|
|
72
|
+
self._status = replace(self._status, complete=False, lost=True)
|
|
73
|
+
super()._discard()
|
pi_agent/_runs.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import math
|
|
7
6
|
import time
|
|
8
7
|
from types import TracebackType
|
|
9
8
|
from typing import TYPE_CHECKING, Any, Self
|
|
@@ -20,14 +19,14 @@ from .errors import (
|
|
|
20
19
|
PiRunStartTimeout,
|
|
21
20
|
PiTimeoutError,
|
|
22
21
|
)
|
|
23
|
-
from .types import Event, ImageContent, RunResult
|
|
22
|
+
from .types import Event, ImageContent, RunResult, _validate_timeout
|
|
24
23
|
|
|
25
24
|
if TYPE_CHECKING:
|
|
26
25
|
from .client import AsyncPiClient, Timeout
|
|
27
26
|
|
|
28
27
|
|
|
29
28
|
class RunStream:
|
|
30
|
-
"""A single-use owned run. Enter
|
|
29
|
+
"""A single-use owned run. Enter on acceptance or start, then consume events.
|
|
31
30
|
|
|
32
31
|
Breaking iteration must be followed by leaving the context so queued work
|
|
33
32
|
is cleared and Pi is aborted. An accepted command without a start event
|
|
@@ -43,10 +42,8 @@ class RunStream:
|
|
|
43
42
|
timeout: float | None,
|
|
44
43
|
command_timeout: Timeout,
|
|
45
44
|
) -> None:
|
|
46
|
-
if timeout is not None
|
|
47
|
-
|
|
48
|
-
):
|
|
49
|
-
raise ValueError("run timeout must be a positive finite number or None")
|
|
45
|
+
if timeout is not None:
|
|
46
|
+
_validate_timeout(timeout, "run timeout")
|
|
50
47
|
self._client = client
|
|
51
48
|
self._message = message
|
|
52
49
|
self._images = images
|
|
@@ -55,10 +52,12 @@ class RunStream:
|
|
|
55
52
|
self._events = EventSubscription(
|
|
56
53
|
client.limits, lambda subscription: None, lambda subscription: None
|
|
57
54
|
)
|
|
55
|
+
self._retain_events = True
|
|
58
56
|
self._started = asyncio.Event()
|
|
59
57
|
self._settled = asyncio.Event()
|
|
60
58
|
self._task: asyncio.Task[RunResult] | None = None
|
|
61
|
-
self.
|
|
59
|
+
self._ready: asyncio.Future[None] | None = None
|
|
60
|
+
self._accepted = False
|
|
62
61
|
self._error: Exception | None = None
|
|
63
62
|
self._messages: list[dict[str, Any]] = []
|
|
64
63
|
self._message_bytes = 0
|
|
@@ -86,12 +85,14 @@ class RunStream:
|
|
|
86
85
|
"run()/stream() because delayed session events cannot be attributed safely"
|
|
87
86
|
)
|
|
88
87
|
self._client._owner = self
|
|
89
|
-
self.
|
|
88
|
+
self._ready = asyncio.get_running_loop().create_future()
|
|
90
89
|
await self._events.__aenter__()
|
|
91
90
|
self._task = asyncio.create_task(self._drive(), name="pi-run")
|
|
92
91
|
self._task.add_done_callback(self._consume_task_exception)
|
|
93
92
|
try:
|
|
94
|
-
await
|
|
93
|
+
await self._ready
|
|
94
|
+
if self._error is not None:
|
|
95
|
+
raise self._error
|
|
95
96
|
except BaseException:
|
|
96
97
|
await self.aclose()
|
|
97
98
|
raise
|
|
@@ -120,8 +121,8 @@ class RunStream:
|
|
|
120
121
|
await asyncio.shield(self._task)
|
|
121
122
|
except (Exception, asyncio.CancelledError):
|
|
122
123
|
pass
|
|
123
|
-
if self.
|
|
124
|
-
self.
|
|
124
|
+
if self._ready is not None and self._ready.done() and not self._ready.cancelled():
|
|
125
|
+
self._ready.exception()
|
|
125
126
|
await self._events.aclose()
|
|
126
127
|
|
|
127
128
|
def _fail(self, error: Exception) -> None:
|
|
@@ -137,6 +138,10 @@ class RunStream:
|
|
|
137
138
|
return
|
|
138
139
|
if event.type == "agent_start":
|
|
139
140
|
self._started.set()
|
|
141
|
+
# Extensions may wait for the entire run before acknowledging the
|
|
142
|
+
# prompt. Make consumption possible without declaring acceptance.
|
|
143
|
+
if self._ready is not None and not self._ready.done():
|
|
144
|
+
self._ready.set_result(None)
|
|
140
145
|
elif event.type == "agent_settled" and self._started.is_set():
|
|
141
146
|
self._end = time.monotonic()
|
|
142
147
|
self._settled.set()
|
|
@@ -162,12 +167,13 @@ class RunStream:
|
|
|
162
167
|
self._message_bytes += size
|
|
163
168
|
self._messages.append(message)
|
|
164
169
|
self._usage.add(message)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
if self._retain_events:
|
|
171
|
+
error = self._events._put(event, size)
|
|
172
|
+
if error is not None:
|
|
173
|
+
self._fail(error)
|
|
168
174
|
|
|
169
175
|
async def _drive(self) -> RunResult:
|
|
170
|
-
assert self.
|
|
176
|
+
assert self._ready is not None
|
|
171
177
|
failure: BaseException | None = None
|
|
172
178
|
try:
|
|
173
179
|
async with asyncio.timeout(self._timeout):
|
|
@@ -182,7 +188,9 @@ class RunStream:
|
|
|
182
188
|
await self._client._request(
|
|
183
189
|
"prompt", fields, timeout=self._command_timeout, owner=self
|
|
184
190
|
)
|
|
185
|
-
self._accepted
|
|
191
|
+
self._accepted = True
|
|
192
|
+
if not self._ready.done():
|
|
193
|
+
self._ready.set_result(None)
|
|
186
194
|
try:
|
|
187
195
|
async with asyncio.timeout(self._client.limits.run_start_timeout):
|
|
188
196
|
await self._started.wait()
|
|
@@ -211,27 +219,34 @@ class RunStream:
|
|
|
211
219
|
failure = PiTimeoutError(
|
|
212
220
|
"Pi run deadline elapsed", command="prompt", uncertain=self._submitted
|
|
213
221
|
)
|
|
214
|
-
|
|
222
|
+
if self._client._ui_error is not None and failure is self._client._ui_error:
|
|
223
|
+
# The owner now reports this failure. Do not report it again as
|
|
224
|
+
# a failure of an otherwise successful cleanup command.
|
|
225
|
+
self._client._ui_error = None
|
|
215
226
|
if (
|
|
216
227
|
self._submitted
|
|
217
|
-
and not isinstance(failure, PiCommandError)
|
|
218
|
-
and (not self._settled.is_set() or not
|
|
228
|
+
and (not isinstance(failure, PiCommandError) or self._started.is_set())
|
|
229
|
+
and (not self._settled.is_set() or not self._accepted)
|
|
219
230
|
):
|
|
220
231
|
# abort() cannot cancel an extension's pending input/UI preflight.
|
|
221
232
|
# Keep delayed work from escaping a failed owned operation.
|
|
222
|
-
if not
|
|
233
|
+
if not self._accepted or not self._started.is_set():
|
|
223
234
|
await self._client.aclose()
|
|
224
235
|
else:
|
|
225
236
|
await self._cleanup()
|
|
226
237
|
if failure is exc:
|
|
227
238
|
raise
|
|
239
|
+
if self._error is not None and failure is self._error:
|
|
240
|
+
# Cancellation only woke the driver; retain the recorded
|
|
241
|
+
# error's original cause (for example the UI callback error).
|
|
242
|
+
raise failure from failure.__cause__
|
|
228
243
|
raise failure from exc
|
|
229
244
|
finally:
|
|
230
|
-
if not self.
|
|
245
|
+
if not self._ready.done():
|
|
231
246
|
if isinstance(failure, asyncio.CancelledError):
|
|
232
|
-
self.
|
|
247
|
+
self._ready.cancel()
|
|
233
248
|
elif failure is not None:
|
|
234
|
-
self.
|
|
249
|
+
self._ready.set_exception(failure)
|
|
235
250
|
# Final model errors are delivered by result(), after their events are consumed.
|
|
236
251
|
event_error = (
|
|
237
252
|
failure
|
|
@@ -300,6 +315,16 @@ class RunStream:
|
|
|
300
315
|
self._iteration_done = True
|
|
301
316
|
raise
|
|
302
317
|
|
|
318
|
+
async def _next_batch(self) -> list[Event]:
|
|
319
|
+
if self._draining:
|
|
320
|
+
raise PiBusyError("Cannot iterate while result() drains the stream")
|
|
321
|
+
self._iterating = True
|
|
322
|
+
try:
|
|
323
|
+
return await self._events._next_batch()
|
|
324
|
+
except StopAsyncIteration:
|
|
325
|
+
self._iteration_done = True
|
|
326
|
+
raise
|
|
327
|
+
|
|
303
328
|
async def result(self) -> RunResult:
|
|
304
329
|
"""Drain without retaining events, or return the result after iteration ends."""
|
|
305
330
|
if self._task is None:
|