pi-agent-python-sdk 0.1.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 ADDED
@@ -0,0 +1,47 @@
1
+ """Python access to an existing Pi agent runtime.
2
+
3
+ Importing this package never launches Pi or changes its configuration.
4
+ """
5
+
6
+ from .client import AsyncPiClient
7
+ from .errors import (
8
+ PiBusyError,
9
+ PiCommandError,
10
+ PiError,
11
+ PiProcessError,
12
+ PiProtocolError,
13
+ PiResultOverflow,
14
+ PiRunError,
15
+ PiRunOwnershipError,
16
+ PiRunStartTimeout,
17
+ PiSubscriptionOverflow,
18
+ PiTimeoutError,
19
+ PiUIHandlerError,
20
+ PiVersionError,
21
+ )
22
+ from .sync import PiClient
23
+ from .types import Event, ImageContent, Limits, RunResult, SessionInfo, UsageSummary
24
+
25
+ __all__ = [
26
+ "AsyncPiClient",
27
+ "Event",
28
+ "ImageContent",
29
+ "Limits",
30
+ "PiBusyError",
31
+ "PiCommandError",
32
+ "PiClient",
33
+ "PiError",
34
+ "PiProcessError",
35
+ "PiProtocolError",
36
+ "PiResultOverflow",
37
+ "PiRunError",
38
+ "PiRunOwnershipError",
39
+ "PiRunStartTimeout",
40
+ "PiSubscriptionOverflow",
41
+ "PiTimeoutError",
42
+ "PiUIHandlerError",
43
+ "PiVersionError",
44
+ "RunResult",
45
+ "SessionInfo",
46
+ "UsageSummary",
47
+ ]
pi_agent/_events.py ADDED
@@ -0,0 +1,108 @@
1
+ """Bounded future-only event subscriptions; response routing never waits here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import deque
7
+ from collections.abc import Callable
8
+ from types import TracebackType
9
+ from typing import Self
10
+
11
+ from .errors import PiSubscriptionOverflow
12
+ from .types import Event, Limits
13
+
14
+
15
+ class EventSubscription:
16
+ """Enter before submitting work to observe its events without a history buffer.
17
+
18
+ A slow consumer receives PiSubscriptionOverflow rather than silently losing
19
+ events. Closing the context unregisters immediately and releases its buffer.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ limits: Limits,
25
+ register: Callable[[EventSubscription], None],
26
+ unregister: Callable[[EventSubscription], None],
27
+ ) -> None:
28
+ self._limits = limits
29
+ self._register = register
30
+ self._unregister = unregister
31
+ self._records: deque[tuple[Event, int]] = deque()
32
+ self._bytes = 0
33
+ self._ready = asyncio.Event()
34
+ self._entered = False
35
+ self._closed = False
36
+ self._error: Exception | None = None
37
+ self._reading = False
38
+
39
+ async def __aenter__(self) -> Self:
40
+ if self._entered or self._closed:
41
+ raise RuntimeError("Event subscriptions are single-use")
42
+ self._register(self)
43
+ self._entered = True
44
+ return self
45
+
46
+ async def __aexit__(
47
+ self,
48
+ exc_type: type[BaseException] | None,
49
+ exc: BaseException | None,
50
+ traceback: TracebackType | None,
51
+ ) -> None:
52
+ await self.aclose()
53
+
54
+ async def aclose(self) -> None:
55
+ """Unsubscribe and release queued payloads; safe to call repeatedly."""
56
+ self._finish()
57
+ self._records.clear()
58
+ self._bytes = 0
59
+
60
+ def _finish(self, error: Exception | None = None) -> None:
61
+ if self._closed:
62
+ return
63
+ self._closed = True
64
+ self._error = error
65
+ self._unregister(self)
66
+ if error is not None:
67
+ self._records.clear()
68
+ self._bytes = 0
69
+ self._ready.set()
70
+
71
+ def _put(self, event: Event, size: int) -> Exception | None:
72
+ if self._closed:
73
+ return self._error
74
+ if (
75
+ len(self._records) >= self._limits.event_queue_size
76
+ or self._bytes + size > self._limits.event_queue_bytes
77
+ ):
78
+ error = PiSubscriptionOverflow("Event consumer exceeded its configured buffer limit")
79
+ self._finish(error)
80
+ return error
81
+ self._records.append((event, size))
82
+ self._bytes += size
83
+ self._ready.set()
84
+ return None
85
+
86
+ def __aiter__(self) -> Self:
87
+ return self
88
+
89
+ async def __anext__(self) -> Event:
90
+ if not self._entered:
91
+ raise RuntimeError("Enter the event subscription context before iterating")
92
+ if self._reading:
93
+ raise RuntimeError("Only one reader may iterate an event subscription")
94
+ self._reading = True
95
+ try:
96
+ while True:
97
+ if self._error is not None:
98
+ raise self._error
99
+ if self._records:
100
+ event, size = self._records.popleft()
101
+ self._bytes -= size
102
+ return event
103
+ if self._closed:
104
+ raise StopAsyncIteration
105
+ self._ready.clear()
106
+ await self._ready.wait()
107
+ finally:
108
+ self._reading = False
pi_agent/_launch.py ADDED
@@ -0,0 +1,203 @@
1
+ """CLI construction and offline compatibility checks for the owned process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ import shutil
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+
12
+ from ._transport import _close_process_pipes, _wait_for_exit
13
+ from .errors import PiProcessError, PiVersionError
14
+
15
+ MINIMUM_PI_VERSION = "0.85.1"
16
+ TESTED_PI_VERSION = "0.85.1"
17
+
18
+ # The client owns these options. In particular --print consumes an initial prompt.
19
+ _RESERVED = {
20
+ "--mode",
21
+ "--print",
22
+ "-p",
23
+ "--help",
24
+ "-h",
25
+ "--version",
26
+ "-v",
27
+ "--export",
28
+ "--list-models",
29
+ "--resume",
30
+ "-r",
31
+ "--continue",
32
+ "-c",
33
+ "--session",
34
+ "--session-dir",
35
+ "--session-id",
36
+ "--fork",
37
+ "--no-session",
38
+ "--provider",
39
+ "--model",
40
+ }
41
+ _VALUES = {
42
+ "--api-key",
43
+ "--system-prompt",
44
+ "--append-system-prompt",
45
+ "--name",
46
+ "-n",
47
+ "--models",
48
+ "--tools",
49
+ "-t",
50
+ "--exclude-tools",
51
+ "-xt",
52
+ "--thinking",
53
+ "--extension",
54
+ "-e",
55
+ "--skill",
56
+ "--prompt-template",
57
+ "--theme",
58
+ "--use-theme",
59
+ "--tui-mode",
60
+ }
61
+ _FLAGS = {
62
+ "--no-tools",
63
+ "-nt",
64
+ "--no-builtin-tools",
65
+ "-nbt",
66
+ "--no-extensions",
67
+ "-ne",
68
+ "--no-skills",
69
+ "-ns",
70
+ "--no-prompt-templates",
71
+ "-np",
72
+ "--no-themes",
73
+ "--no-context-files",
74
+ "-nc",
75
+ "--verbose",
76
+ "--approve",
77
+ "-a",
78
+ "--no-approve",
79
+ "-na",
80
+ "--offline",
81
+ }
82
+
83
+
84
+ def validate_extra_args(args: Sequence[str]) -> None:
85
+ """Reject positional input and conflicting flags, retaining extension flags."""
86
+ index = 0
87
+ while index < len(args):
88
+ arg = args[index]
89
+ name = arg.split("=", 1)[0]
90
+ if name in _RESERVED or arg == "--":
91
+ raise ValueError(f"Pi option {name} is managed by the client or incompatible with RPC")
92
+ if name in _VALUES:
93
+ if "=" in arg or index + 1 == len(args):
94
+ raise ValueError(f"Pi option {name} requires a separate value")
95
+ if name in {"--use-theme", "--tui-mode"} and args[index + 1].startswith("-"):
96
+ raise ValueError(f"Pi option {name} requires a value before another option")
97
+ index += 2
98
+ elif name in _FLAGS:
99
+ if "=" in arg:
100
+ raise ValueError(f"Pi option {name} takes no value")
101
+ index += 1
102
+ elif arg.startswith("--"):
103
+ # Pi accepts extension flags as --name=value or --name [value].
104
+ index += 1
105
+ if "=" not in arg and index < len(args) and not args[index].startswith(("-", "@")):
106
+ index += 1
107
+ else:
108
+ raise ValueError(
109
+ "extra_args cannot contain startup prompts, attachments, or unknown short flags"
110
+ )
111
+
112
+
113
+ def executable_argv(
114
+ executable: str | os.PathLike[str] | Sequence[str], env: Mapping[str, str]
115
+ ) -> list[str]:
116
+ """Resolve a binary or explicit argv; npm's Windows shim runs through Node."""
117
+ if isinstance(executable, (str, os.PathLike)):
118
+ argv = [os.fspath(executable)]
119
+ else:
120
+ argv = list(executable)
121
+ if not argv or not all(isinstance(item, str) and item for item in argv):
122
+ raise ValueError("executable must be a path or a nonempty sequence of arguments")
123
+ resolved = shutil.which(argv[0], path=env.get("PATH"))
124
+ if resolved is None:
125
+ raise PiProcessError("Pi executable was not found; install Pi or specify executable")
126
+ if os.name == "nt" and Path(resolved).suffix.lower() in {".cmd", ".bat"}:
127
+ # Avoid shell=True and cmd quoting. Only the known npm Pi shim layout is resolved.
128
+ root = Path(resolved).parent
129
+ cli = root / "node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js"
130
+ node = root / "node.exe"
131
+ node_path = str(node) if node.is_file() else shutil.which("node", path=env.get("PATH"))
132
+ if not cli.is_file() or node_path is None:
133
+ raise PiProcessError(
134
+ "Cannot resolve Pi npm shim; pass executable=[node_path, pi_cli_path]"
135
+ )
136
+ return [node_path, str(cli), *argv[1:]]
137
+ return [resolved, *argv[1:]]
138
+
139
+
140
+ async def check_version(
141
+ argv: Sequence[str],
142
+ *,
143
+ cwd: str | None,
144
+ env: Mapping[str, str],
145
+ timeout: float,
146
+ allow_unknown: bool,
147
+ strict: bool,
148
+ ) -> tuple[str | None, str]:
149
+ """Read --version without a network request or loading user configuration."""
150
+ process: asyncio.subprocess.Process | None = None
151
+ try:
152
+ async with asyncio.timeout(timeout):
153
+ process = await asyncio.create_subprocess_exec(
154
+ *argv,
155
+ "--version",
156
+ cwd=cwd,
157
+ env=dict(env),
158
+ stdin=asyncio.subprocess.DEVNULL,
159
+ stdout=asyncio.subprocess.PIPE,
160
+ stderr=asyncio.subprocess.DEVNULL,
161
+ )
162
+ assert process.stdout is not None
163
+ output = bytearray()
164
+ while chunk := await process.stdout.read(4097 - len(output)):
165
+ output.extend(chunk)
166
+ if len(output) > 4096:
167
+ raise PiVersionError("Pi --version returned too much output")
168
+ code = await process.wait()
169
+ match = re.fullmatch(rb"\s*(\d+)\.(\d+)\.(\d+)\s*", output)
170
+ if code != 0 or match is None:
171
+ if allow_unknown:
172
+ return None, "unknown"
173
+ raise PiVersionError(
174
+ "Cannot determine Pi version; custom wrappers may use "
175
+ "allow_unknown_version=True"
176
+ )
177
+ version = tuple(int(part) for part in match.groups())
178
+ version_text = ".".join(str(part) for part in version)
179
+ if version < tuple(map(int, MINIMUM_PI_VERSION.split("."))):
180
+ raise PiVersionError(
181
+ f"Pi {version_text} is older than minimum {MINIMUM_PI_VERSION}"
182
+ )
183
+ if version_text != TESTED_PI_VERSION and strict:
184
+ raise PiVersionError(
185
+ f"Pi {version_text} is untested; recorded tested version is {TESTED_PI_VERSION}"
186
+ )
187
+ return version_text, "tested" if version_text == TESTED_PI_VERSION else "untested"
188
+ except TimeoutError as exc:
189
+ raise PiVersionError("Timed out checking Pi version") from exc
190
+ except OSError as exc:
191
+ raise PiProcessError("Could not launch Pi for version checking") from exc
192
+ finally:
193
+ if process is not None:
194
+ if process.returncode is None:
195
+ try:
196
+ process.kill()
197
+ except ProcessLookupError:
198
+ pass
199
+ # Process.wait() may depend on full or inherited pipes closing.
200
+ # Wait for the owned child's exit first, then release those pipes.
201
+ await _wait_for_exit(process)
202
+ _close_process_pipes(process)
203
+ await process.wait()
pi_agent/_runs.py ADDED
@@ -0,0 +1,317 @@
1
+ """One owned conversation run, including settlement and cancellation cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import time
8
+ from types import TracebackType
9
+ from typing import TYPE_CHECKING, Any, Self
10
+
11
+ from ._events import EventSubscription
12
+ from ._usage import UsageAccumulator
13
+ from .errors import (
14
+ PiBusyError,
15
+ PiCommandError,
16
+ PiProtocolError,
17
+ PiResultOverflow,
18
+ PiRunError,
19
+ PiRunOwnershipError,
20
+ PiRunStartTimeout,
21
+ PiTimeoutError,
22
+ )
23
+ from .types import Event, ImageContent, RunResult
24
+
25
+ if TYPE_CHECKING:
26
+ from .client import AsyncPiClient, Timeout
27
+
28
+
29
+ class RunStream:
30
+ """A single-use owned run. Enter, then iterate or await result() to drain.
31
+
32
+ Breaking iteration must be followed by leaving the context so queued work
33
+ is cleared and Pi is aborted. An accepted command without a start event
34
+ has an uncertain disposition; its start deadline closes Pi.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ client: AsyncPiClient,
40
+ message: str,
41
+ *,
42
+ images: list[ImageContent] | None,
43
+ timeout: float | None,
44
+ command_timeout: Timeout,
45
+ ) -> None:
46
+ if timeout is not None and (
47
+ isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0
48
+ ):
49
+ raise ValueError("run timeout must be a positive finite number or None")
50
+ self._client = client
51
+ self._message = message
52
+ self._images = images
53
+ self._timeout = timeout
54
+ self._command_timeout = command_timeout
55
+ self._events = EventSubscription(
56
+ client.limits, lambda subscription: None, lambda subscription: None
57
+ )
58
+ self._started = asyncio.Event()
59
+ self._settled = asyncio.Event()
60
+ self._task: asyncio.Task[RunResult] | None = None
61
+ self._accepted: asyncio.Future[None] | None = None
62
+ self._error: Exception | None = None
63
+ self._messages: list[dict[str, Any]] = []
64
+ self._message_bytes = 0
65
+ self._usage = UsageAccumulator()
66
+ self._submitted = False
67
+ self._finishing = False
68
+ self._closed = False
69
+ self._iterating = False
70
+ self._draining = False
71
+ self._iteration_done = False
72
+ self._begin = 0.0
73
+ self._end = 0.0
74
+
75
+ async def __aenter__(self) -> Self:
76
+ self._client._check_loop()
77
+ if self._task is not None or self._closed:
78
+ raise RuntimeError("Run streams are single-use")
79
+ if self._client.busy:
80
+ raise PiBusyError("Another run owns this Pi conversation")
81
+ if not self._client.running:
82
+ raise RuntimeError("Start Pi before opening a run stream")
83
+ if self._client._unowned_submission:
84
+ raise PiRunOwnershipError(
85
+ "This client submitted low-level conversation work; use a fresh client for "
86
+ "run()/stream() because delayed session events cannot be attributed safely"
87
+ )
88
+ self._client._owner = self
89
+ self._accepted = asyncio.get_running_loop().create_future()
90
+ await self._events.__aenter__()
91
+ self._task = asyncio.create_task(self._drive(), name="pi-run")
92
+ self._task.add_done_callback(self._consume_task_exception)
93
+ try:
94
+ await asyncio.shield(self._accepted)
95
+ except BaseException:
96
+ await self.aclose()
97
+ raise
98
+ return self
99
+
100
+ @staticmethod
101
+ def _consume_task_exception(task: asyncio.Task[RunResult]) -> None:
102
+ if not task.cancelled():
103
+ task.exception()
104
+
105
+ async def __aexit__(
106
+ self,
107
+ exc_type: type[BaseException] | None,
108
+ exc: BaseException | None,
109
+ traceback: TracebackType | None,
110
+ ) -> None:
111
+ await self.aclose()
112
+
113
+ async def aclose(self) -> None:
114
+ """Finish cancellation cleanup before releasing the owned conversation."""
115
+ self._closed = True
116
+ if self._task is not None and not self._task.done():
117
+ self._task.cancel()
118
+ if self._task is not None:
119
+ try:
120
+ await asyncio.shield(self._task)
121
+ except (Exception, asyncio.CancelledError):
122
+ pass
123
+ if self._accepted is not None and self._accepted.done() and not self._accepted.cancelled():
124
+ self._accepted.exception()
125
+ await self._events.aclose()
126
+
127
+ def _fail(self, error: Exception) -> None:
128
+ if self._error is not None or self._finishing:
129
+ return
130
+ self._error = error
131
+ self._events._finish(error)
132
+ if self._task is not None and not self._task.done():
133
+ self._task.cancel()
134
+
135
+ def _on_event(self, event: Event, size: int) -> None:
136
+ if not self._submitted or self._settled.is_set() or self._error is not None:
137
+ return
138
+ if event.type == "agent_start":
139
+ self._started.set()
140
+ elif event.type == "agent_settled" and self._started.is_set():
141
+ self._end = time.monotonic()
142
+ self._settled.set()
143
+ elif event.type == "message_end" and self._started.is_set():
144
+ message = event.raw.get("message")
145
+ if not isinstance(message, dict) or not isinstance(message.get("role"), str):
146
+ self._fail(PiProtocolError("message_end requires a message with a role"))
147
+ return
148
+ if message.get("role") == "assistant":
149
+ if not isinstance(message.get("content"), list) or not isinstance(
150
+ message.get("stopReason"), str
151
+ ):
152
+ self._fail(
153
+ PiProtocolError("Final assistant message requires content and stopReason")
154
+ )
155
+ return
156
+ if (
157
+ len(self._messages) >= self._client.limits.result_message_count
158
+ or self._message_bytes + size > self._client.limits.result_message_bytes
159
+ ):
160
+ self._fail(PiResultOverflow("Run exceeded its retained message count/byte limit"))
161
+ return
162
+ self._message_bytes += size
163
+ self._messages.append(message)
164
+ self._usage.add(message)
165
+ error = self._events._put(event, size)
166
+ if error is not None:
167
+ self._fail(error)
168
+
169
+ async def _drive(self) -> RunResult:
170
+ assert self._accepted is not None
171
+ failure: BaseException | None = None
172
+ try:
173
+ async with asyncio.timeout(self._timeout):
174
+ state = await self._client.get_state()
175
+ if state["isStreaming"] or state["isCompacting"] or state["pendingMessageCount"]:
176
+ raise PiBusyError("Pi already has active or queued low-level work")
177
+ fields: dict[str, Any] = {"message": self._message}
178
+ if self._images is not None:
179
+ fields["images"] = self._images
180
+ self._submitted = True
181
+ self._begin = time.monotonic()
182
+ await self._client._request(
183
+ "prompt", fields, timeout=self._command_timeout, owner=self
184
+ )
185
+ self._accepted.set_result(None)
186
+ try:
187
+ async with asyncio.timeout(self._client.limits.run_start_timeout):
188
+ await self._started.wait()
189
+ except TimeoutError as exc:
190
+ raise PiRunStartTimeout(
191
+ "Prompt accepted but no run was observed; "
192
+ "Pi is closed to prevent delayed work",
193
+ command="prompt",
194
+ uncertain=True,
195
+ ) from exc
196
+ await self._settled.wait()
197
+ if self._error is not None:
198
+ raise self._error
199
+ # Extensions can switch sessions internally; do not reuse startup identity.
200
+ await self._client.get_state()
201
+ result = self._result()
202
+ if result.stop_reason in {"error", "aborted"}:
203
+ raise PiRunError(f"Pi run ended with {result.stop_reason}", result)
204
+ return result
205
+ except BaseException as exc:
206
+ self._finishing = True
207
+ failure = exc
208
+ if isinstance(exc, asyncio.CancelledError) and self._error is not None:
209
+ failure = self._error
210
+ elif isinstance(exc, TimeoutError) and not isinstance(exc, PiTimeoutError):
211
+ failure = PiTimeoutError(
212
+ "Pi run deadline elapsed", command="prompt", uncertain=self._submitted
213
+ )
214
+ accepted = self._accepted.done() and not self._accepted.cancelled()
215
+ if (
216
+ self._submitted
217
+ and not isinstance(failure, PiCommandError)
218
+ and (not self._settled.is_set() or not accepted)
219
+ ):
220
+ # abort() cannot cancel an extension's pending input/UI preflight.
221
+ # Keep delayed work from escaping a failed owned operation.
222
+ if not accepted or not self._started.is_set():
223
+ await self._client.aclose()
224
+ else:
225
+ await self._cleanup()
226
+ if failure is exc:
227
+ raise
228
+ raise failure from exc
229
+ finally:
230
+ if not self._accepted.done():
231
+ if isinstance(failure, asyncio.CancelledError):
232
+ self._accepted.cancel()
233
+ elif failure is not None:
234
+ self._accepted.set_exception(failure)
235
+ # Final model errors are delivered by result(), after their events are consumed.
236
+ event_error = (
237
+ failure
238
+ if isinstance(failure, Exception) and not isinstance(failure, PiRunError)
239
+ else None
240
+ )
241
+ self._events._finish(event_error)
242
+ if self._client._owner is self:
243
+ self._client._owner = None
244
+
245
+ async def _cleanup(self) -> None:
246
+ async def stop() -> None:
247
+ if not self._client.running:
248
+ await self._client.aclose()
249
+ return
250
+ try:
251
+ async with asyncio.timeout(self._client.limits.cleanup_timeout):
252
+ await self._client._request("clear_queue", owner=self)
253
+ await self._client._request("abort", owner=self)
254
+ except Exception:
255
+ await self._client.aclose()
256
+
257
+ # Shield cleanup from the caller's cancellation, retaining ownership until it completes.
258
+ task = asyncio.create_task(stop(), name="pi-run-cleanup")
259
+ try:
260
+ await asyncio.shield(task)
261
+ except asyncio.CancelledError:
262
+ await task
263
+
264
+ def _result(self) -> RunResult:
265
+ assistants = [message for message in self._messages if message["role"] == "assistant"]
266
+ last = assistants[-1] if assistants else None
267
+ content = last["content"] if last is not None else []
268
+ text_parts = []
269
+ for block in content:
270
+ if not isinstance(block, dict):
271
+ raise PiProtocolError("Assistant content blocks must be objects")
272
+ if block.get("type") == "text":
273
+ if not isinstance(block.get("text"), str):
274
+ raise PiProtocolError("Assistant text blocks require string text")
275
+ text_parts.append(block["text"])
276
+ return RunResult(
277
+ text="".join(text_parts),
278
+ messages=list(self._messages),
279
+ stop_reason=last["stopReason"] if last is not None else None,
280
+ session=self._client.session,
281
+ elapsed_seconds=max(0.0, self._end - self._begin),
282
+ usage=self._usage.snapshot(),
283
+ )
284
+
285
+ def __aiter__(self) -> Self:
286
+ if self._draining:
287
+ raise PiBusyError("Cannot iterate while result() drains the stream")
288
+ if self._iterating:
289
+ raise PiBusyError("A stream permits one event iterator")
290
+ self._iterating = True
291
+ return self
292
+
293
+ async def __anext__(self) -> Event:
294
+ if self._draining:
295
+ raise PiBusyError("Cannot iterate while result() drains the stream")
296
+ self._iterating = True
297
+ try:
298
+ return await self._events.__anext__()
299
+ except StopAsyncIteration:
300
+ self._iteration_done = True
301
+ raise
302
+
303
+ async def result(self) -> RunResult:
304
+ """Drain without retaining events, or return the result after iteration ends."""
305
+ if self._task is None:
306
+ raise RuntimeError("Enter the stream context before awaiting its result")
307
+ if self._draining or (self._iterating and not self._iteration_done):
308
+ raise PiBusyError("Finish iteration before calling result()")
309
+ self._draining = True
310
+ try:
311
+ if not self._iteration_done:
312
+ async for _ in self._events:
313
+ pass
314
+ self._iteration_done = True
315
+ return await asyncio.shield(self._task)
316
+ finally:
317
+ self._draining = False