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/sync.py ADDED
@@ -0,0 +1,684 @@
1
+ """Synchronous access through one persistent event loop per client.
2
+
3
+ RPC behavior lives in AsyncPiClient. This module only bridges threads and
4
+ context managers; it never reads pipes or implements command serialization.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import concurrent.futures
11
+ import os
12
+ import threading
13
+ from collections.abc import Callable, Coroutine, Mapping, Sequence
14
+ from types import TracebackType
15
+ from typing import Any, Generic, Self, TypeVar
16
+
17
+ from ._events import EventSubscription
18
+ from ._runs import RunStream
19
+ from .client import DEFAULT_TIMEOUT, IN_SYNC_UI, AsyncPiClient, Timeout, UIHandler
20
+ from .errors import PiProcessError
21
+ from .types import (
22
+ AcceptanceReceipt,
23
+ AgentMessage,
24
+ BashResult,
25
+ CompactionResult,
26
+ EntriesResult,
27
+ Event,
28
+ ForkMessage,
29
+ ForkResult,
30
+ ImageContent,
31
+ Limits,
32
+ Model,
33
+ ModelCycleResult,
34
+ QueueMode,
35
+ QueueState,
36
+ RunResult,
37
+ SessionChangeResult,
38
+ SessionInfo,
39
+ SessionState,
40
+ SessionStats,
41
+ SlashCommand,
42
+ ThinkingLevel,
43
+ TreeResult,
44
+ )
45
+
46
+ T = TypeVar("T")
47
+
48
+
49
+ class _Call(Generic[T]):
50
+ """Track task completion separately from a caller abandoning its result."""
51
+
52
+ def __init__(self, factory: Callable[[], Coroutine[Any, Any, T]]) -> None:
53
+ self.factory = factory
54
+ self.result: concurrent.futures.Future[T] = concurrent.futures.Future()
55
+ self.finished = threading.Event()
56
+ self.task: asyncio.Task[T] | None = None
57
+ self.cancelled = False
58
+
59
+ def start(self) -> None:
60
+ async def invoke() -> T:
61
+ return await self.factory()
62
+
63
+ self.task = asyncio.create_task(invoke(), name="pi-sync-call")
64
+ self.task.add_done_callback(self._done)
65
+ if self.cancelled:
66
+ self.task.cancel()
67
+
68
+ def cancel(self) -> None:
69
+ self.cancelled = True
70
+ if self.task is not None:
71
+ self.task.cancel()
72
+
73
+ def _done(self, task: asyncio.Task[T]) -> None:
74
+ try:
75
+ self.result.set_result(task.result())
76
+ except BaseException as exc:
77
+ self.result.set_exception(exc)
78
+ finally:
79
+ self.finished.set()
80
+
81
+
82
+ class PiClient:
83
+ """A blocking facade over AsyncPiClient with the same RPC semantics.
84
+
85
+ Construction has no thread or subprocess side effects. Enter a context or
86
+ call start(), and always close(). Calls from multiple threads are routed
87
+ to one loop; conversation ownership remains enforced by the async core.
88
+ Use AsyncPiClient from asynchronous applications.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ executable: str | os.PathLike[str] | Sequence[str] = "pi",
95
+ cwd: str | os.PathLike[str] | None = None,
96
+ provider: str | None = None,
97
+ model: str | None = None,
98
+ env: Mapping[str, str | None] | None = None,
99
+ inherit_env: bool = True,
100
+ session: str | None = None,
101
+ session_dir: str | None = None,
102
+ session_id: str | None = None,
103
+ continue_session: bool = False,
104
+ no_session: bool = False,
105
+ fork_session: str | None = None,
106
+ extra_args: Sequence[str] = (),
107
+ ui_handler: UIHandler | None = None,
108
+ limits: Limits | None = None,
109
+ strict_version: bool = False,
110
+ allow_unknown_version: bool = False,
111
+ ) -> None:
112
+ self._client = AsyncPiClient(
113
+ executable=executable,
114
+ cwd=cwd,
115
+ provider=provider,
116
+ model=model,
117
+ env=env,
118
+ inherit_env=inherit_env,
119
+ session=session,
120
+ session_dir=session_dir,
121
+ session_id=session_id,
122
+ continue_session=continue_session,
123
+ no_session=no_session,
124
+ fork_session=fork_session,
125
+ extra_args=extra_args,
126
+ ui_handler=ui_handler,
127
+ limits=limits,
128
+ strict_version=strict_version,
129
+ allow_unknown_version=allow_unknown_version,
130
+ )
131
+ self._lock = threading.Lock()
132
+ self._ready = threading.Event()
133
+ self._closed_event = threading.Event()
134
+ self._thread_done = threading.Event()
135
+ self._thread: threading.Thread | None = None
136
+ self._loop: asyncio.AbstractEventLoop | None = None
137
+ self._loop_error: BaseException | None = None
138
+ self._closing = False
139
+ self._closed = False
140
+
141
+ @property
142
+ def limits(self) -> Limits:
143
+ return self._client.limits
144
+
145
+ @property
146
+ def running(self) -> bool:
147
+ return self._client.running
148
+
149
+ @property
150
+ def busy(self) -> bool:
151
+ return self._client.busy
152
+
153
+ @property
154
+ def session(self) -> SessionInfo:
155
+ return self._client.session
156
+
157
+ @property
158
+ def pi_version(self) -> str | None:
159
+ return self._client.pi_version
160
+
161
+ @property
162
+ def compatibility(self) -> str:
163
+ return self._client.compatibility
164
+
165
+ @property
166
+ def stderr_tail(self) -> str:
167
+ return self._client.stderr_tail
168
+
169
+ def _check_caller(self) -> None:
170
+ if IN_SYNC_UI.get() or threading.current_thread() is self._thread:
171
+ raise RuntimeError("Blocking PiClient calls are not allowed from UI callbacks")
172
+
173
+ def _loop_main(self) -> None:
174
+ loop: asyncio.AbstractEventLoop | None = None
175
+ try:
176
+ loop = asyncio.new_event_loop()
177
+ asyncio.set_event_loop(loop)
178
+ except BaseException as exc:
179
+ self._loop_error = exc
180
+ if loop is not None:
181
+ loop.close()
182
+ # The starting thread must wake even when no loop could be created.
183
+ self._ready.set()
184
+ self._thread_done.set()
185
+ return
186
+ self._loop = loop
187
+ self._ready.set()
188
+ try:
189
+ loop.run_forever()
190
+ finally:
191
+
192
+ async def finish() -> None:
193
+ remaining = [
194
+ task for task in asyncio.all_tasks() if task is not asyncio.current_task()
195
+ ]
196
+ for task in remaining:
197
+ task.cancel()
198
+ if remaining:
199
+ await asyncio.gather(*remaining, return_exceptions=True)
200
+ await loop.shutdown_asyncgens()
201
+
202
+ try:
203
+ loop.run_until_complete(finish())
204
+ finally:
205
+ # A user callback may be blocked in a worker thread. Python cannot
206
+ # forcibly stop it; closing the loop does not wait for such callbacks.
207
+ loop.close()
208
+ self._thread_done.set()
209
+
210
+ def _call(
211
+ self,
212
+ factory: Callable[[], Coroutine[Any, Any, T]],
213
+ *,
214
+ closing: bool = False,
215
+ cancel_on_interrupt: bool = True,
216
+ ) -> T:
217
+ self._check_caller()
218
+ call = _Call(factory)
219
+ with self._lock:
220
+ loop = self._loop
221
+ if (
222
+ loop is None
223
+ or self._thread_done.is_set()
224
+ or self._closed
225
+ or (self._closing and not closing)
226
+ ):
227
+ raise PiProcessError("Pi is not running; use the client context or start() first")
228
+ loop.call_soon_threadsafe(call.start)
229
+ try:
230
+ return call.result.result()
231
+ except KeyboardInterrupt:
232
+ if cancel_on_interrupt:
233
+ loop.call_soon_threadsafe(call.cancel)
234
+ # Future.cancel() marks a bridge cancelled before async finally blocks
235
+ # finish. Only the task's done callback proves cleanup has completed.
236
+ while not call.finished.is_set():
237
+ try:
238
+ call.finished.wait()
239
+ except KeyboardInterrupt:
240
+ continue
241
+ raise
242
+
243
+ def start(self) -> None:
244
+ """Start the persistent loop and wait for Pi's version and readiness checks."""
245
+ self._check_caller()
246
+ with self._lock:
247
+ if self._thread is not None or self._closed or self._closing:
248
+ raise PiProcessError("Clients are single-use; create a new client to restart Pi")
249
+ thread = threading.Thread(target=self._loop_main, name="pi-client-loop", daemon=True)
250
+ try:
251
+ thread.start()
252
+ except BaseException:
253
+ self._closed = True
254
+ self._closed_event.set()
255
+ raise
256
+ self._thread = thread
257
+ try:
258
+ self._ready.wait()
259
+ if self._loop_error is not None:
260
+ raise PiProcessError("Could not initialize the Pi event loop") from self._loop_error
261
+ self._call(self._client.start)
262
+ except BaseException:
263
+ self.close()
264
+ raise
265
+
266
+ def close(self) -> None:
267
+ """Reap Pi, finish outstanding calls, and stop and join the background loop."""
268
+ self._check_caller()
269
+ with self._lock:
270
+ if self._closed:
271
+ return
272
+ other_closer = self._closing
273
+ self._closing = True
274
+ thread = self._thread
275
+ if other_closer:
276
+ self._closed_event.wait()
277
+ return
278
+ try:
279
+ if thread is not None:
280
+ self._ready.wait()
281
+ try:
282
+ # Interrupting shutdown must not cancel process reaping.
283
+ if self._loop is not None and not self._thread_done.is_set():
284
+ self._call(self._client.aclose, closing=True, cancel_on_interrupt=False)
285
+ finally:
286
+ if self._loop is not None and not self._loop.is_closed():
287
+ self._loop.call_soon_threadsafe(self._loop.stop)
288
+ interrupted = False
289
+ while not self._thread_done.is_set():
290
+ try:
291
+ self._thread_done.wait()
292
+ except KeyboardInterrupt:
293
+ interrupted = True
294
+ thread.join()
295
+ if interrupted:
296
+ raise KeyboardInterrupt
297
+ finally:
298
+ with self._lock:
299
+ self._closed = True
300
+ self._closed_event.set()
301
+
302
+ def _close_context(self, close: Callable[[], Coroutine[Any, Any, None]]) -> None:
303
+ """Close a nested context, or join client shutdown already doing its cleanup."""
304
+ try:
305
+ self._call(close, cancel_on_interrupt=False)
306
+ except PiProcessError:
307
+ with self._lock:
308
+ shutting_down = self._closing or self._closed
309
+ if not shutting_down:
310
+ raise
311
+ self.close()
312
+
313
+ def __enter__(self) -> Self:
314
+ self.start()
315
+ return self
316
+
317
+ def __exit__(
318
+ self,
319
+ exc_type: type[BaseException] | None,
320
+ exc: BaseException | None,
321
+ traceback: TracebackType | None,
322
+ ) -> None:
323
+ self.close()
324
+
325
+ def request(
326
+ self, command_type: str, *, timeout: Timeout = DEFAULT_TIMEOUT, **fields: Any
327
+ ) -> dict[str, Any]:
328
+ """Return a checked raw response; omission and timeout rules match the async client."""
329
+ return self._call(lambda: self._client.request(command_type, timeout=timeout, **fields))
330
+
331
+ def prompt(
332
+ self,
333
+ message: str,
334
+ *,
335
+ images: list[ImageContent] | None = None,
336
+ streaming_behavior: str | None = None,
337
+ timeout: Timeout = DEFAULT_TIMEOUT,
338
+ ) -> AcceptanceReceipt:
339
+ """Wait for acceptance only; handled commands may never start an agent run."""
340
+ return self._call(
341
+ lambda: self._client.prompt(
342
+ message, images=images, streaming_behavior=streaming_behavior, timeout=timeout
343
+ )
344
+ )
345
+
346
+ def steer(
347
+ self,
348
+ message: str,
349
+ *,
350
+ images: list[ImageContent] | None = None,
351
+ timeout: Timeout = DEFAULT_TIMEOUT,
352
+ ) -> None:
353
+ """Queue input for Pi's next steering opportunity; acknowledgement is not consumption."""
354
+ self._call(lambda: self._client.steer(message, images=images, timeout=timeout))
355
+
356
+ def follow_up(
357
+ self,
358
+ message: str,
359
+ *,
360
+ images: list[ImageContent] | None = None,
361
+ timeout: Timeout = DEFAULT_TIMEOUT,
362
+ ) -> None:
363
+ """Queue input after the current response; acknowledgement is not consumption."""
364
+ self._call(lambda: self._client.follow_up(message, images=images, timeout=timeout))
365
+
366
+ def abort(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
367
+ """Abort current work using Pi semantics, without implicitly clearing queued input."""
368
+ self._call(lambda: self._client.abort(timeout=timeout))
369
+
370
+ def clear_queue(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> QueueState:
371
+ """Clear queued input and return the removed steering and follow-up text."""
372
+ return self._call(lambda: self._client.clear_queue(timeout=timeout))
373
+
374
+ def new_session(
375
+ self, *, parent_session: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
376
+ ) -> SessionChangeResult:
377
+ """Start a new session; inspect cancelled for an extension veto."""
378
+ return self._call(
379
+ lambda: self._client.new_session(parent_session=parent_session, timeout=timeout)
380
+ )
381
+
382
+ def get_state(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionState:
383
+ """Read authoritative Pi state and refresh the cached session identity."""
384
+ return self._call(lambda: self._client.get_state(timeout=timeout))
385
+
386
+ def set_model(
387
+ self, provider: str, model_id: str, *, timeout: Timeout = DEFAULT_TIMEOUT
388
+ ) -> Model:
389
+ """Select a provider/model and return its full metadata."""
390
+ return self._call(lambda: self._client.set_model(provider, model_id, timeout=timeout))
391
+
392
+ def cycle_model(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ModelCycleResult | None:
393
+ """Cycle models and return model, thinking level, and scope, or None."""
394
+ return self._call(lambda: self._client.cycle_model(timeout=timeout))
395
+
396
+ def get_available_models(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[Model]:
397
+ """List the models Pi makes available with their provider metadata."""
398
+ return self._call(lambda: self._client.get_available_models(timeout=timeout))
399
+
400
+ def set_thinking_level(
401
+ self, level: ThinkingLevel, *, timeout: Timeout = DEFAULT_TIMEOUT
402
+ ) -> None:
403
+ """Request a thinking level for the current model."""
404
+ self._call(lambda: self._client.set_thinking_level(level, timeout=timeout))
405
+
406
+ def cycle_thinking_level(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ThinkingLevel | None:
407
+ """Cycle the current thinking level, or return None when unavailable."""
408
+ return self._call(lambda: self._client.cycle_thinking_level(timeout=timeout))
409
+
410
+ def get_available_thinking_levels(
411
+ self, *, timeout: Timeout = DEFAULT_TIMEOUT
412
+ ) -> list[ThinkingLevel]:
413
+ """List thinking levels available for the current model."""
414
+ return self._call(lambda: self._client.get_available_thinking_levels(timeout=timeout))
415
+
416
+ def set_steering_mode(self, mode: QueueMode, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
417
+ """Choose all queued steering messages or one at a time."""
418
+ self._call(lambda: self._client.set_steering_mode(mode, timeout=timeout))
419
+
420
+ def set_follow_up_mode(self, mode: QueueMode, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
421
+ """Choose all queued follow-up messages or one at a time."""
422
+ self._call(lambda: self._client.set_follow_up_mode(mode, timeout=timeout))
423
+
424
+ def compact(
425
+ self, *, custom_instructions: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
426
+ ) -> CompactionResult:
427
+ """Compact the conversation and return Pi's summary and token information."""
428
+ return self._call(
429
+ lambda: self._client.compact(custom_instructions=custom_instructions, timeout=timeout)
430
+ )
431
+
432
+ def set_auto_compaction(self, enabled: bool, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
433
+ """Enable or disable Pi's automatic context compaction."""
434
+ self._call(lambda: self._client.set_auto_compaction(enabled, timeout=timeout))
435
+
436
+ def set_auto_retry(self, enabled: bool, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
437
+ """Enable or disable Pi's automatic retry policy."""
438
+ self._call(lambda: self._client.set_auto_retry(enabled, timeout=timeout))
439
+
440
+ def abort_retry(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
441
+ """Abort Pi's active retry sequence."""
442
+ self._call(lambda: self._client.abort_retry(timeout=timeout))
443
+
444
+ def bash(
445
+ self,
446
+ command: str,
447
+ *,
448
+ exclude_from_context: bool | None = None,
449
+ timeout: Timeout = DEFAULT_TIMEOUT,
450
+ ) -> BashResult:
451
+ """Run Pi's bash command; events() exposes output deltas while it executes."""
452
+ return self._call(
453
+ lambda: self._client.bash(
454
+ command, exclude_from_context=exclude_from_context, timeout=timeout
455
+ )
456
+ )
457
+
458
+ def abort_bash(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
459
+ """Abort Pi's active bash execution."""
460
+ self._call(lambda: self._client.abort_bash(timeout=timeout))
461
+
462
+ def get_session_stats(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionStats:
463
+ """Return Pi's current session message counts, token totals, and cost."""
464
+ return self._call(lambda: self._client.get_session_stats(timeout=timeout))
465
+
466
+ def export_html(
467
+ self, *, output_path: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
468
+ ) -> str:
469
+ """Export the current session and return the path written by Pi."""
470
+ return self._call(
471
+ lambda: self._client.export_html(output_path=output_path, timeout=timeout)
472
+ )
473
+
474
+ def switch_session(
475
+ self, session_path: str, *, timeout: Timeout = DEFAULT_TIMEOUT
476
+ ) -> SessionChangeResult:
477
+ """Switch to a session path; inspect cancelled for an extension veto."""
478
+ return self._call(lambda: self._client.switch_session(session_path, timeout=timeout))
479
+
480
+ def fork(self, entry_id: str, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ForkResult:
481
+ """Fork at an eligible entry; a veto can omit the returned editable text."""
482
+ return self._call(lambda: self._client.fork(entry_id, timeout=timeout))
483
+
484
+ def clone(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionChangeResult:
485
+ """Clone the current session; inspect cancelled for an extension veto."""
486
+ return self._call(lambda: self._client.clone(timeout=timeout))
487
+
488
+ def get_fork_messages(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[ForkMessage]:
489
+ """List eligible message entry IDs and text for choosing a fork point."""
490
+ return self._call(lambda: self._client.get_fork_messages(timeout=timeout))
491
+
492
+ def get_entries(
493
+ self, *, since: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
494
+ ) -> EntriesResult:
495
+ """Return saved entries after an optional entry ID and the current leaf ID."""
496
+ return self._call(lambda: self._client.get_entries(since=since, timeout=timeout))
497
+
498
+ def get_tree(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> TreeResult:
499
+ """Return the recursive session tree and nullable selected leaf ID."""
500
+ return self._call(lambda: self._client.get_tree(timeout=timeout))
501
+
502
+ def get_last_assistant_text(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> str | None:
503
+ """Query session history for usable assistant text, or None when absent."""
504
+ return self._call(lambda: self._client.get_last_assistant_text(timeout=timeout))
505
+
506
+ def set_session_name(self, name: str, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
507
+ """Set the session name and refresh cached identity from Pi."""
508
+ self._call(lambda: self._client.set_session_name(name, timeout=timeout))
509
+
510
+ def get_messages(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[AgentMessage]:
511
+ """Return Pi's current conversation messages."""
512
+ return self._call(lambda: self._client.get_messages(timeout=timeout))
513
+
514
+ def get_commands(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[SlashCommand]:
515
+ """List extension, prompt-template, and skill commands with source information."""
516
+ return self._call(lambda: self._client.get_commands(timeout=timeout))
517
+
518
+ def events(self) -> SyncEventSubscription:
519
+ """Enter before submitting work, then iterate future events on the calling thread."""
520
+ return SyncEventSubscription(self)
521
+
522
+ def stream(
523
+ self,
524
+ message: str,
525
+ *,
526
+ images: list[ImageContent] | None = None,
527
+ timeout: float | None = None,
528
+ command_timeout: Timeout = DEFAULT_TIMEOUT,
529
+ ) -> SyncRunStream:
530
+ """Enter a context and iterate events; leaving early aborts owned work."""
531
+ return SyncRunStream(self, message, images, timeout, command_timeout)
532
+
533
+ def run(
534
+ self,
535
+ message: str,
536
+ *,
537
+ images: list[ImageContent] | None = None,
538
+ timeout: float | None = None,
539
+ command_timeout: Timeout = DEFAULT_TIMEOUT,
540
+ ) -> RunResult:
541
+ """Wait through retries and queued continuations until agent_settled."""
542
+ return self._call(
543
+ lambda: self._client.run(
544
+ message, images=images, timeout=timeout, command_timeout=command_timeout
545
+ )
546
+ )
547
+
548
+
549
+ class SyncEventSubscription:
550
+ """A blocking context and iterator over a bounded async subscription."""
551
+
552
+ def __init__(self, client: PiClient) -> None:
553
+ self._client = client
554
+ self._subscription: EventSubscription | None = None
555
+ self._entered = False
556
+ self._closed = False
557
+
558
+ def __enter__(self) -> Self:
559
+ async def enter() -> None:
560
+ if self._entered or self._closed:
561
+ raise RuntimeError("Event subscriptions are single-use")
562
+ self._entered = True
563
+ self._subscription = self._client._client.events()
564
+ await self._subscription.__aenter__()
565
+
566
+ self._client._call(enter)
567
+ return self
568
+
569
+ def __exit__(
570
+ self,
571
+ exc_type: type[BaseException] | None,
572
+ exc: BaseException | None,
573
+ traceback: TracebackType | None,
574
+ ) -> None:
575
+ self.close()
576
+
577
+ def __iter__(self) -> Self:
578
+ return self
579
+
580
+ def __next__(self) -> Event:
581
+ async def next_event() -> Event:
582
+ if self._subscription is None:
583
+ raise RuntimeError("Enter the event subscription context before iterating")
584
+ return await self._subscription.__anext__()
585
+
586
+ try:
587
+ return self._client._call(next_event)
588
+ except StopAsyncIteration:
589
+ raise StopIteration from None
590
+
591
+ def close(self) -> None:
592
+ self._closed = True
593
+ if self._subscription is not None:
594
+ self._client._close_context(self._subscription.aclose)
595
+
596
+
597
+ class SyncRunStream:
598
+ """An owned run; iterate once, then result(), or use result() alone to drain."""
599
+
600
+ def __init__(
601
+ self,
602
+ client: PiClient,
603
+ message: str,
604
+ images: list[ImageContent] | None,
605
+ timeout: float | None,
606
+ command_timeout: Timeout,
607
+ ) -> None:
608
+ self._client = client
609
+ self._message = message
610
+ self._images = images
611
+ self._timeout = timeout
612
+ self._command_timeout = command_timeout
613
+ self._stream: RunStream | None = None
614
+ self._entered = False
615
+ self._closed = False
616
+
617
+ def __enter__(self) -> Self:
618
+ async def enter() -> None:
619
+ if self._entered or self._closed:
620
+ raise RuntimeError("Run streams are single-use")
621
+ self._entered = True
622
+ self._stream = self._client._client.stream(
623
+ self._message,
624
+ images=self._images,
625
+ timeout=self._timeout,
626
+ command_timeout=self._command_timeout,
627
+ )
628
+ await self._stream.__aenter__()
629
+
630
+ self._client._call(enter)
631
+ return self
632
+
633
+ def __exit__(
634
+ self,
635
+ exc_type: type[BaseException] | None,
636
+ exc: BaseException | None,
637
+ traceback: TracebackType | None,
638
+ ) -> None:
639
+ self.close()
640
+
641
+ def __iter__(self) -> Self:
642
+ # Iterator wrappers may call iter() repeatedly before requesting an item.
643
+ # The async next/result methods enforce actual competing consumption.
644
+ return self
645
+
646
+ def _call_owned(self, operation: Callable[[], Coroutine[Any, Any, T]]) -> T:
647
+ async def invoke() -> T:
648
+ try:
649
+ return await operation()
650
+ except asyncio.CancelledError:
651
+ if self._stream is not None:
652
+ await self._stream.aclose()
653
+ raise
654
+
655
+ try:
656
+ return self._client._call(invoke)
657
+ except KeyboardInterrupt:
658
+ # Cancellation before invoke() starts cannot run its exception handler.
659
+ self.close()
660
+ raise
661
+
662
+ def __next__(self) -> Event:
663
+ async def next_event() -> Event:
664
+ if self._stream is None:
665
+ raise RuntimeError("Enter the stream context before iterating")
666
+ return await self._stream.__anext__()
667
+
668
+ try:
669
+ return self._call_owned(next_event)
670
+ except StopAsyncIteration:
671
+ raise StopIteration from None
672
+
673
+ def result(self) -> RunResult:
674
+ async def result() -> RunResult:
675
+ if self._stream is None:
676
+ raise RuntimeError("Enter the stream context before requesting its result")
677
+ return await self._stream.result()
678
+
679
+ return self._call_owned(result)
680
+
681
+ def close(self) -> None:
682
+ self._closed = True
683
+ if self._stream is not None:
684
+ self._client._close_context(self._stream.aclose)