piocloop 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.
- piocloop/__init__.py +1 -0
- piocloop/__main__.py +3 -0
- piocloop/cli.py +230 -0
- piocloop/loop.py +487 -0
- piocloop/pi_client.py +441 -0
- piocloop/pi_events.py +326 -0
- piocloop/plan_parser.py +140 -0
- piocloop/tui.py +289 -0
- piocloop-0.1.0.dist-info/METADATA +191 -0
- piocloop-0.1.0.dist-info/RECORD +13 -0
- piocloop-0.1.0.dist-info/WHEEL +4 -0
- piocloop-0.1.0.dist-info/entry_points.txt +2 -0
- piocloop-0.1.0.dist-info/licenses/LICENSE +21 -0
piocloop/pi_client.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Client for the PI coding agent's RPC mode (`pi --mode rpc`).
|
|
2
|
+
|
|
3
|
+
All knowledge of the PI wire protocol lives in this module; the rest of piocloop
|
|
4
|
+
sees only `PiClient` methods and dict events.
|
|
5
|
+
|
|
6
|
+
Three design rules here are load-bearing (see DESIGN.md §4). They exist because
|
|
7
|
+
pyocloop hung in production for want of each one:
|
|
8
|
+
|
|
9
|
+
1. Every pipe is drained continuously. An unread stderr pipe fills and blocks
|
|
10
|
+
the child process — that was pyocloop's H2 deadlock.
|
|
11
|
+
2. Every wait has a deadline. No `await` in this module can block forever.
|
|
12
|
+
3. Blocking extension UI dialogs are answered automatically. `pi` stops and
|
|
13
|
+
waits for a reply to `select`/`confirm`/`input`/`editor`, and only dialogs
|
|
14
|
+
carrying a `timeout` field ever self-resolve, so an unattended harness that
|
|
15
|
+
ignores them hangs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import collections
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import signal
|
|
27
|
+
from typing import Any, Awaitable, Callable, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
# Dialog methods block the agent until we answer. Everything else on the
|
|
30
|
+
# extension UI channel is fire-and-forget and must NOT be answered.
|
|
31
|
+
_DIALOG_METHODS = frozenset({"select", "confirm", "input", "editor"})
|
|
32
|
+
|
|
33
|
+
# Guard against a pathological frame eating all memory. Real `message_update`
|
|
34
|
+
# events embed the full partial message and can be large, so this is generous.
|
|
35
|
+
_MAX_FRAME_BYTES = 64 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
38
|
+
|
|
39
|
+
# Synthetic event types, emitted by this module rather than by `pi`.
|
|
40
|
+
EVENT_PROCESS_EXITED = "_process_exited"
|
|
41
|
+
EVENT_STDERR = "_stderr"
|
|
42
|
+
EVENT_PROTOCOL_ERROR = "_protocol_error"
|
|
43
|
+
EVENT_DIALOG_ANSWERED = "_dialog_answered"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def strip_ansi(text: str) -> str:
|
|
47
|
+
"""Remove SGR escapes — extension status text arrives pre-coloured."""
|
|
48
|
+
return _ANSI_RE.sub("", text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PiError(RuntimeError):
|
|
52
|
+
"""A command was rejected by `pi`, or the process is unusable."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PiTimeout(PiError):
|
|
56
|
+
"""A command was not answered within its deadline."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PiExited(PiError):
|
|
60
|
+
"""The `pi` process exited while a command was in flight."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_argv(
|
|
64
|
+
*,
|
|
65
|
+
pi_bin: str = "pi",
|
|
66
|
+
model: Optional[str] = None,
|
|
67
|
+
thinking: Optional[str] = None,
|
|
68
|
+
session_dir: Optional[str] = None,
|
|
69
|
+
no_session: bool = False,
|
|
70
|
+
tools: Optional[str] = None,
|
|
71
|
+
exclude_tools: Optional[str] = None,
|
|
72
|
+
append_system_prompt: Sequence[str] = (),
|
|
73
|
+
skills: Sequence[str] = (),
|
|
74
|
+
approve: Optional[bool] = None,
|
|
75
|
+
extra_args: Sequence[str] = (),
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
"""Build the `pi --mode rpc` command line."""
|
|
78
|
+
argv = [pi_bin, "--mode", "rpc"]
|
|
79
|
+
if model:
|
|
80
|
+
argv += ["--model", model]
|
|
81
|
+
if thinking:
|
|
82
|
+
argv += ["--thinking", thinking]
|
|
83
|
+
if session_dir:
|
|
84
|
+
argv += ["--session-dir", session_dir]
|
|
85
|
+
if no_session:
|
|
86
|
+
argv += ["--no-session"]
|
|
87
|
+
if tools:
|
|
88
|
+
argv += ["--tools", tools]
|
|
89
|
+
if exclude_tools:
|
|
90
|
+
argv += ["--exclude-tools", exclude_tools]
|
|
91
|
+
for text in append_system_prompt:
|
|
92
|
+
argv += ["--append-system-prompt", text]
|
|
93
|
+
for skill in skills:
|
|
94
|
+
argv += ["--skill", skill]
|
|
95
|
+
if approve is True:
|
|
96
|
+
argv += ["--approve"]
|
|
97
|
+
elif approve is False:
|
|
98
|
+
argv += ["--no-approve"]
|
|
99
|
+
argv += list(extra_args)
|
|
100
|
+
return argv
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PiClient:
|
|
104
|
+
"""Owns a `pi --mode rpc` subprocess and speaks its JSONL protocol.
|
|
105
|
+
|
|
106
|
+
Events (including synthetic ones) are pushed to `events`; the caller is
|
|
107
|
+
expected to consume that queue continuously.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
argv: Sequence[str],
|
|
113
|
+
*,
|
|
114
|
+
cwd: Optional[str] = None,
|
|
115
|
+
dialog_policy: str = "cancel",
|
|
116
|
+
stderr_tail: int = 200,
|
|
117
|
+
default_timeout: float = 60.0,
|
|
118
|
+
) -> None:
|
|
119
|
+
self._argv = list(argv)
|
|
120
|
+
self._cwd = cwd
|
|
121
|
+
self._dialog_policy = dialog_policy
|
|
122
|
+
self._default_timeout = default_timeout
|
|
123
|
+
|
|
124
|
+
self._proc: Optional[asyncio.subprocess.Process] = None
|
|
125
|
+
self._tasks: list[asyncio.Task] = []
|
|
126
|
+
self._pending: dict[str, asyncio.Future] = {}
|
|
127
|
+
self._next_id = 0
|
|
128
|
+
self._write_lock = asyncio.Lock()
|
|
129
|
+
self._closing = False
|
|
130
|
+
|
|
131
|
+
self.events: asyncio.Queue[dict] = asyncio.Queue()
|
|
132
|
+
self.exited: asyncio.Event = asyncio.Event()
|
|
133
|
+
self.returncode: Optional[int] = None
|
|
134
|
+
self.stderr_tail: collections.deque[str] = collections.deque(maxlen=stderr_tail)
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Lifecycle
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
async def start(self) -> None:
|
|
141
|
+
if self._proc is not None:
|
|
142
|
+
raise PiError("PiClient already started")
|
|
143
|
+
try:
|
|
144
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
145
|
+
*self._argv,
|
|
146
|
+
cwd=self._cwd,
|
|
147
|
+
stdin=asyncio.subprocess.PIPE,
|
|
148
|
+
stdout=asyncio.subprocess.PIPE,
|
|
149
|
+
stderr=asyncio.subprocess.PIPE,
|
|
150
|
+
start_new_session=True, # own process group, so close() kills children
|
|
151
|
+
)
|
|
152
|
+
except FileNotFoundError as exc:
|
|
153
|
+
raise PiError(f"Could not execute {self._argv[0]!r}: {exc}") from exc
|
|
154
|
+
|
|
155
|
+
# Both pipes get a dedicated reader for the whole process lifetime.
|
|
156
|
+
# Leaving either unread eventually blocks the child (pyocloop bug H2).
|
|
157
|
+
self._tasks = [
|
|
158
|
+
asyncio.create_task(self._read_stdout(), name="pi-stdout"),
|
|
159
|
+
asyncio.create_task(self._read_stderr(), name="pi-stderr"),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
async def close(self, timeout: float = 5.0) -> None:
|
|
163
|
+
"""Shut the process down. Idempotent, and safe to call after a crash."""
|
|
164
|
+
self._closing = True
|
|
165
|
+
proc, self._proc = self._proc, None
|
|
166
|
+
if proc is not None:
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
if proc.stdin and not proc.stdin.is_closing():
|
|
169
|
+
proc.stdin.close()
|
|
170
|
+
try:
|
|
171
|
+
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
|
172
|
+
except (asyncio.TimeoutError, Exception): # noqa: B014 - best effort
|
|
173
|
+
self._kill_group(proc, signal.SIGTERM)
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
|
176
|
+
except (asyncio.TimeoutError, Exception): # noqa: B014
|
|
177
|
+
self._kill_group(proc, signal.SIGKILL)
|
|
178
|
+
with contextlib.suppress(Exception):
|
|
179
|
+
await proc.wait()
|
|
180
|
+
self.returncode = proc.returncode
|
|
181
|
+
|
|
182
|
+
for task in self._tasks:
|
|
183
|
+
task.cancel()
|
|
184
|
+
for task in self._tasks:
|
|
185
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
186
|
+
await task
|
|
187
|
+
self._tasks = []
|
|
188
|
+
self._fail_pending(PiExited("pi process closed"))
|
|
189
|
+
self.exited.set()
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _kill_group(proc: asyncio.subprocess.Process, sig: int) -> None:
|
|
193
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
194
|
+
os.killpg(os.getpgid(proc.pid), sig)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Readers
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
async def _read_stdout(self) -> None:
|
|
201
|
+
"""Parse strict JSONL: records are delimited by LF and nothing else.
|
|
202
|
+
|
|
203
|
+
Deliberately not using readline()/readuntil(): we split the raw byte
|
|
204
|
+
stream on b"\\n" ourselves so that neither a stream-buffer limit nor a
|
|
205
|
+
Unicode line separator (U+2028/U+2029, legal inside JSON strings) can
|
|
206
|
+
desynchronise framing.
|
|
207
|
+
"""
|
|
208
|
+
assert self._proc and self._proc.stdout
|
|
209
|
+
buf = bytearray()
|
|
210
|
+
try:
|
|
211
|
+
while True:
|
|
212
|
+
chunk = await self._proc.stdout.read(65536)
|
|
213
|
+
if not chunk:
|
|
214
|
+
break
|
|
215
|
+
buf.extend(chunk)
|
|
216
|
+
if len(buf) > _MAX_FRAME_BYTES:
|
|
217
|
+
raise PiError("RPC frame exceeded maximum size")
|
|
218
|
+
while True:
|
|
219
|
+
nl = buf.find(b"\n")
|
|
220
|
+
if nl == -1:
|
|
221
|
+
break
|
|
222
|
+
raw = bytes(buf[:nl])
|
|
223
|
+
del buf[: nl + 1]
|
|
224
|
+
self._handle_frame(raw)
|
|
225
|
+
except asyncio.CancelledError:
|
|
226
|
+
raise
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
self._emit({"type": EVENT_PROTOCOL_ERROR, "error": str(exc)})
|
|
229
|
+
finally:
|
|
230
|
+
await self._on_eof()
|
|
231
|
+
|
|
232
|
+
def _handle_frame(self, raw: bytes) -> None:
|
|
233
|
+
line = raw.decode("utf-8", errors="replace").rstrip("\r")
|
|
234
|
+
if not line.strip():
|
|
235
|
+
return
|
|
236
|
+
try:
|
|
237
|
+
msg = json.loads(line)
|
|
238
|
+
except json.JSONDecodeError as exc:
|
|
239
|
+
# Never fatal: a malformed frame must not take the loop down.
|
|
240
|
+
self._emit({"type": EVENT_PROTOCOL_ERROR, "error": f"bad JSON: {exc}", "raw": line[:500]})
|
|
241
|
+
return
|
|
242
|
+
if not isinstance(msg, dict):
|
|
243
|
+
self._emit({"type": EVENT_PROTOCOL_ERROR, "error": "frame was not an object", "raw": line[:500]})
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
mtype = msg.get("type")
|
|
247
|
+
if mtype == "response":
|
|
248
|
+
self._resolve(msg)
|
|
249
|
+
elif mtype == "extension_ui_request":
|
|
250
|
+
self._handle_ui_request(msg)
|
|
251
|
+
else:
|
|
252
|
+
self._emit(msg)
|
|
253
|
+
|
|
254
|
+
async def _read_stderr(self) -> None:
|
|
255
|
+
assert self._proc and self._proc.stderr
|
|
256
|
+
try:
|
|
257
|
+
while True:
|
|
258
|
+
chunk = await self._proc.stderr.read(65536)
|
|
259
|
+
if not chunk:
|
|
260
|
+
break
|
|
261
|
+
for line in chunk.decode("utf-8", errors="replace").splitlines():
|
|
262
|
+
line = line.rstrip()
|
|
263
|
+
if line:
|
|
264
|
+
self.stderr_tail.append(line)
|
|
265
|
+
self._emit({"type": EVENT_STDERR, "text": strip_ansi(line)})
|
|
266
|
+
except asyncio.CancelledError:
|
|
267
|
+
raise
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
async def _on_eof(self) -> None:
|
|
272
|
+
"""stdout closed — the process is gone or going."""
|
|
273
|
+
if self.exited.is_set():
|
|
274
|
+
return
|
|
275
|
+
proc = self._proc
|
|
276
|
+
if proc is not None:
|
|
277
|
+
with contextlib.suppress(Exception):
|
|
278
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
279
|
+
self.returncode = proc.returncode
|
|
280
|
+
self.exited.set()
|
|
281
|
+
self._fail_pending(PiExited(f"pi exited (code {self.returncode})"))
|
|
282
|
+
if not self._closing:
|
|
283
|
+
self._emit({
|
|
284
|
+
"type": EVENT_PROCESS_EXITED,
|
|
285
|
+
"returncode": self.returncode,
|
|
286
|
+
"stderr_tail": list(self.stderr_tail)[-20:],
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
def _emit(self, event: dict) -> None:
|
|
290
|
+
self.events.put_nowait(event)
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Extension UI auto-responder (DESIGN §4.3)
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _handle_ui_request(self, msg: dict) -> None:
|
|
297
|
+
method = msg.get("method", "")
|
|
298
|
+
if method not in _DIALOG_METHODS:
|
|
299
|
+
# Fire-and-forget (setStatus/notify/setWidget/setTitle/...). Surface
|
|
300
|
+
# it, but sending a response would be a protocol violation.
|
|
301
|
+
self._emit({
|
|
302
|
+
"type": "extension_ui_info",
|
|
303
|
+
"method": method,
|
|
304
|
+
"text": strip_ansi(str(
|
|
305
|
+
msg.get("statusText") or msg.get("message") or msg.get("title") or ""
|
|
306
|
+
)),
|
|
307
|
+
"raw": msg,
|
|
308
|
+
})
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
reply = self._dialog_reply(msg)
|
|
312
|
+
# Answer immediately: `pi` is blocked until we do.
|
|
313
|
+
asyncio.create_task(self._send_raw(reply))
|
|
314
|
+
self._emit({
|
|
315
|
+
"type": EVENT_DIALOG_ANSWERED,
|
|
316
|
+
"method": method,
|
|
317
|
+
"title": strip_ansi(str(msg.get("title") or "")),
|
|
318
|
+
"policy": self._dialog_policy,
|
|
319
|
+
"reply": reply,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
def _dialog_reply(self, msg: dict) -> dict:
|
|
323
|
+
rid = msg.get("id")
|
|
324
|
+
method = msg.get("method")
|
|
325
|
+
policy = self._dialog_policy
|
|
326
|
+
|
|
327
|
+
if policy == "cancel":
|
|
328
|
+
return {"type": "extension_ui_response", "id": rid, "cancelled": True}
|
|
329
|
+
|
|
330
|
+
if method == "confirm":
|
|
331
|
+
return {"type": "extension_ui_response", "id": rid, "confirmed": policy == "allow"}
|
|
332
|
+
|
|
333
|
+
if method == "select":
|
|
334
|
+
options = msg.get("options") or []
|
|
335
|
+
if policy == "allow" and options:
|
|
336
|
+
return {"type": "extension_ui_response", "id": rid, "value": options[0]}
|
|
337
|
+
return {"type": "extension_ui_response", "id": rid, "cancelled": True}
|
|
338
|
+
|
|
339
|
+
# input / editor have no meaningful non-interactive answer.
|
|
340
|
+
return {"type": "extension_ui_response", "id": rid, "cancelled": True}
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Command plumbing
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
async def _send_raw(self, payload: dict) -> None:
|
|
347
|
+
proc = self._proc
|
|
348
|
+
if proc is None or proc.stdin is None or self.exited.is_set():
|
|
349
|
+
raise PiExited("pi process is not running")
|
|
350
|
+
data = (json.dumps(payload) + "\n").encode("utf-8")
|
|
351
|
+
async with self._write_lock:
|
|
352
|
+
try:
|
|
353
|
+
proc.stdin.write(data)
|
|
354
|
+
await proc.stdin.drain()
|
|
355
|
+
except (BrokenPipeError, ConnectionResetError) as exc:
|
|
356
|
+
raise PiExited(f"pi stdin closed: {exc}") from exc
|
|
357
|
+
|
|
358
|
+
async def request(
|
|
359
|
+
self,
|
|
360
|
+
command: dict,
|
|
361
|
+
*,
|
|
362
|
+
timeout: Optional[float] = None,
|
|
363
|
+
) -> dict:
|
|
364
|
+
"""Send a command and await its correlated response.
|
|
365
|
+
|
|
366
|
+
Always bounded: raises PiTimeout rather than waiting indefinitely.
|
|
367
|
+
"""
|
|
368
|
+
self._next_id += 1
|
|
369
|
+
rid = f"piloop-{self._next_id}"
|
|
370
|
+
payload = {**command, "id": rid}
|
|
371
|
+
|
|
372
|
+
loop = asyncio.get_running_loop()
|
|
373
|
+
future: asyncio.Future = loop.create_future()
|
|
374
|
+
self._pending[rid] = future
|
|
375
|
+
try:
|
|
376
|
+
await self._send_raw(payload)
|
|
377
|
+
data = await asyncio.wait_for(
|
|
378
|
+
future, timeout=timeout if timeout is not None else self._default_timeout
|
|
379
|
+
)
|
|
380
|
+
except asyncio.TimeoutError as exc:
|
|
381
|
+
raise PiTimeout(f"no response to {command.get('type')!r} within deadline") from exc
|
|
382
|
+
finally:
|
|
383
|
+
self._pending.pop(rid, None)
|
|
384
|
+
|
|
385
|
+
if not data.get("success", False):
|
|
386
|
+
raise PiError(f"{command.get('type')} failed: {data.get('error', 'unknown error')}")
|
|
387
|
+
return data.get("data") or {}
|
|
388
|
+
|
|
389
|
+
def _resolve(self, msg: dict) -> None:
|
|
390
|
+
rid = msg.get("id")
|
|
391
|
+
future = self._pending.pop(rid, None) if rid else None
|
|
392
|
+
if future is None:
|
|
393
|
+
# Uncorrelated response (e.g. a parse error for an unparseable
|
|
394
|
+
# command). Surface it rather than dropping it silently.
|
|
395
|
+
self._emit({"type": "uncorrelated_response", "raw": msg})
|
|
396
|
+
return
|
|
397
|
+
if not future.done():
|
|
398
|
+
future.set_result(msg)
|
|
399
|
+
|
|
400
|
+
def _fail_pending(self, exc: Exception) -> None:
|
|
401
|
+
for future in self._pending.values():
|
|
402
|
+
if not future.done():
|
|
403
|
+
future.set_exception(exc)
|
|
404
|
+
self._pending.clear()
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# Commands
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async def new_session(self, timeout: float = 30.0) -> dict:
|
|
411
|
+
return await self.request({"type": "new_session"}, timeout=timeout)
|
|
412
|
+
|
|
413
|
+
async def prompt(self, message: str, *, timeout: float = 60.0) -> dict:
|
|
414
|
+
"""Send a prompt. The response only acknowledges acceptance.
|
|
415
|
+
|
|
416
|
+
Completion is signalled later by the `agent_settled` event.
|
|
417
|
+
"""
|
|
418
|
+
return await self.request({"type": "prompt", "message": message}, timeout=timeout)
|
|
419
|
+
|
|
420
|
+
async def abort(self, timeout: float = 30.0) -> dict:
|
|
421
|
+
return await self.request({"type": "abort"}, timeout=timeout)
|
|
422
|
+
|
|
423
|
+
async def get_state(self, timeout: float = 30.0) -> dict:
|
|
424
|
+
return await self.request({"type": "get_state"}, timeout=timeout)
|
|
425
|
+
|
|
426
|
+
async def set_model(self, provider: str, model_id: str, timeout: float = 30.0) -> dict:
|
|
427
|
+
return await self.request(
|
|
428
|
+
{"type": "set_model", "provider": provider, "modelId": model_id},
|
|
429
|
+
timeout=timeout,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------
|
|
433
|
+
# Context manager
|
|
434
|
+
# ------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
async def __aenter__(self) -> "PiClient":
|
|
437
|
+
await self.start()
|
|
438
|
+
return self
|
|
439
|
+
|
|
440
|
+
async def __aexit__(self, *_: object) -> None:
|
|
441
|
+
await self.close()
|