Skip to content

Commit 3388fd6

Browse files
authored
feat(sdk): add sync and async execution stdin helpers (#15)
1 parent b9cdcc7 commit 3388fd6

6 files changed

Lines changed: 201 additions & 0 deletions

File tree

‎README-SDK.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,23 @@ print("Errors:", result.stderr())
233233
execution.kill()
234234
```
235235

236+
To send input, start the execution with stdin attached:
237+
238+
```python
239+
execution = devbox.cmd.exec_async("cat", attach_stdin=True)
240+
execution.send_std_in("first\n")
241+
execution.send_std_in("second\n")
242+
execution.close_std_in()
243+
result = execution.result()
244+
assert result.stdout() == "first\nsecond\n"
245+
```
246+
247+
The async SDK exposes the same helpers with `await`. Send nonempty text, finish each
248+
send before the next, and wait for pending sends before closing stdin. Concurrent
249+
calls are not serialized. These helpers preserve the generated client's retry policy;
250+
a retry after an ambiguous connection failure can replay input (not exactly-once
251+
execution).
252+
236253
**Key methods:**
237254

238255
- `execution.get_state()` - Get current execution state (status, exit_code, etc.)

‎src/runloop_api_client/sdk/async_execution.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ._types import BaseRequestOptions, LongRequestOptions
1111
from .._client import AsyncRunloop
12+
from .._exceptions import RunloopError
1213
from .async_execution_result import AsyncExecutionResult
1314
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
1415

@@ -132,6 +133,38 @@ async def get_state(self, **options: Unpack[BaseRequestOptions]) -> DevboxAsyncE
132133
**options,
133134
)
134135

136+
async def send_std_in(self, text: str, **options: Unpack[LongRequestOptions]) -> None:
137+
"""Send nonempty text to an execution started with ``attach_stdin=True``.
138+
139+
Complete each send before the next send or ``close_std_in``. Concurrent
140+
calls are not ordered. Client retries can replay input after an ambiguous
141+
connection failure; delivery is not guaranteed to be exactly once.
142+
143+
:param text: Text to write to stdin
144+
:param options: Request configuration forwarded to the generated client
145+
:raises RunloopError: If the API reports unsuccessful delivery
146+
"""
147+
response = await self._client.devboxes.executions.send_std_in(
148+
self._execution_id, devbox_id=self._devbox_id, text=text, **options
149+
)
150+
if not response.success:
151+
raise RunloopError(f"Failed to send stdin to execution {self._execution_id}")
152+
153+
async def close_std_in(self, **options: Unpack[LongRequestOptions]) -> None:
154+
"""Send EOF after all input has been delivered so the execution can finish.
155+
156+
Requires ``attach_stdin=True``. Wait for pending sends before closing.
157+
This handle does not serialize calls or guarantee exactly-once delivery.
158+
159+
:param options: Request configuration forwarded to the generated client
160+
:raises RunloopError: If the API reports unsuccessful delivery
161+
"""
162+
response = await self._client.devboxes.executions.send_std_in(
163+
self._execution_id, devbox_id=self._devbox_id, signal="EOF", **options
164+
)
165+
if not response.success:
166+
raise RunloopError(f"Failed to send stdin to execution {self._execution_id}")
167+
135168
async def kill(self, **options: Unpack[LongRequestOptions]) -> None:
136169
"""Request termination of the running execution.
137170

‎src/runloop_api_client/sdk/execution.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ._types import BaseRequestOptions, LongRequestOptions
1111
from .._client import Runloop
12+
from .._exceptions import RunloopError
1213
from .execution_result import ExecutionResult
1314
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
1415

@@ -120,6 +121,38 @@ def get_state(self, **options: Unpack[BaseRequestOptions]) -> DevboxAsyncExecuti
120121
**options,
121122
)
122123

124+
def send_std_in(self, text: str, **options: Unpack[LongRequestOptions]) -> None:
125+
"""Send nonempty text to an execution started with ``attach_stdin=True``.
126+
127+
Complete each send before the next send or ``close_std_in``. Concurrent
128+
calls are not ordered. Client retries can replay input after an ambiguous
129+
connection failure; delivery is not guaranteed to be exactly once.
130+
131+
:param text: Text to write to stdin
132+
:param options: Request configuration forwarded to the generated client
133+
:raises RunloopError: If the API reports unsuccessful delivery
134+
"""
135+
response = self._client.devboxes.executions.send_std_in(
136+
self._execution_id, devbox_id=self._devbox_id, text=text, **options
137+
)
138+
if not response.success:
139+
raise RunloopError(f"Failed to send stdin to execution {self._execution_id}")
140+
141+
def close_std_in(self, **options: Unpack[LongRequestOptions]) -> None:
142+
"""Send EOF after all input has been delivered so the execution can finish.
143+
144+
Requires ``attach_stdin=True``. Wait for pending sends before closing.
145+
This handle does not serialize calls or guarantee exactly-once delivery.
146+
147+
:param options: Request configuration forwarded to the generated client
148+
:raises RunloopError: If the API reports unsuccessful delivery
149+
"""
150+
response = self._client.devboxes.executions.send_std_in(
151+
self._execution_id, devbox_id=self._devbox_id, signal="EOF", **options
152+
)
153+
if not response.success:
154+
raise RunloopError(f"Failed to send stdin to execution {self._execution_id}")
155+
123156
def kill(self, **options: Unpack[LongRequestOptions]) -> None:
124157
"""Request termination of the running execution.
125158
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Stdin wrappers use generated routing and preserve request configuration."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import httpx
8+
import pytest
9+
10+
from runloop_api_client import RunloopError
11+
from runloop_api_client.sdk import Execution, RunloopSDK, AsyncExecution, AsyncRunloopSDK
12+
from runloop_api_client.types import DevboxAsyncExecutionDetailView
13+
14+
15+
class StdinTransport:
16+
def __init__(self, success: bool) -> None:
17+
self.success = success
18+
self.bodies: list[object] = []
19+
20+
def __call__(self, request: httpx.Request) -> httpx.Response:
21+
assert request.method == "POST"
22+
assert request.url.path == "/v1/devboxes/dbx_test/executions/execution_test/send_std_in"
23+
assert request.headers["x-request-id"] == "synthetic-idempotency-key"
24+
assert request.headers["x-probe"] == "probe"
25+
assert request.extensions["timeout"]["read"] == 12
26+
self.bodies.append(json.loads(request.content))
27+
return httpx.Response(
28+
200, json={"devbox_id": "dbx_test", "execution_id": "execution_test", "success": self.success}
29+
)
30+
31+
32+
@pytest.mark.parametrize("success", [True, False])
33+
def test_stdin_generated_routing_and_unsuccessful_delivery(success: bool) -> None:
34+
transport = StdinTransport(success)
35+
with RunloopSDK(
36+
bearer_token="synthetic",
37+
base_url="https://api.invalid",
38+
max_retries=0,
39+
http_client=httpx.Client(transport=httpx.MockTransport(transport)),
40+
) as sdk:
41+
execution = Execution(
42+
sdk.api, "dbx_test", DevboxAsyncExecutionDetailView.model_construct(execution_id="execution_test")
43+
)
44+
for operation in [
45+
lambda: execution.send_std_in(
46+
"hello\n", extra_headers={"x-probe": "probe"}, idempotency_key="synthetic-idempotency-key", timeout=12
47+
),
48+
lambda: execution.close_std_in(
49+
extra_headers={"x-probe": "probe"}, idempotency_key="synthetic-idempotency-key", timeout=12
50+
),
51+
]:
52+
if success:
53+
assert operation() is None
54+
else:
55+
with pytest.raises(RunloopError, match="Failed to send stdin"):
56+
operation()
57+
assert transport.bodies == [{"text": "hello\n"}, {"signal": "EOF"}]
58+
59+
60+
@pytest.mark.parametrize("success", [True, False])
61+
@pytest.mark.asyncio
62+
async def test_async_stdin_generated_routing_and_unsuccessful_delivery(success: bool) -> None:
63+
transport = StdinTransport(success)
64+
async with AsyncRunloopSDK(
65+
bearer_token="synthetic",
66+
base_url="https://api.invalid",
67+
max_retries=0,
68+
http_client=httpx.AsyncClient(transport=httpx.MockTransport(transport)),
69+
) as sdk:
70+
execution = AsyncExecution(
71+
sdk.api, "dbx_test", DevboxAsyncExecutionDetailView.model_construct(execution_id="execution_test")
72+
)
73+
for operation in [
74+
lambda: execution.send_std_in(
75+
"hello\n", extra_headers={"x-probe": "probe"}, idempotency_key="synthetic-idempotency-key", timeout=12
76+
),
77+
lambda: execution.close_std_in(
78+
extra_headers={"x-probe": "probe"}, idempotency_key="synthetic-idempotency-key", timeout=12
79+
),
80+
]:
81+
if success:
82+
assert await operation() is None
83+
else:
84+
with pytest.raises(RunloopError, match="Failed to send stdin"):
85+
await operation()
86+
assert transport.bodies == [{"text": "hello\n"}, {"signal": "EOF"}]

‎tests/smoketests/sdk/test_async_devbox.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ async def test_exec_with_exit_code(self, shared_devbox: AsyncDevbox) -> None:
125125
assert result.success is False
126126
assert await result.stdout(num_lines=1) == ""
127127

128+
@pytest.mark.timeout(TWO_MINUTE_TIMEOUT)
129+
async def test_stdin_ordered_roundtrip(self, shared_devbox: AsyncDevbox) -> None:
130+
execution = await shared_devbox.cmd.exec_async("cat", attach_stdin=True)
131+
completed = False
132+
try:
133+
await execution.send_std_in("first\n")
134+
await execution.send_std_in("second\n")
135+
await execution.close_std_in()
136+
result = await execution.result()
137+
completed = True
138+
assert result.exit_code == 0
139+
assert await result.stdout() == "first\nsecond\n"
140+
finally:
141+
if not completed:
142+
await execution.kill()
143+
128144
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
129145
async def test_exec_async_command(self, shared_devbox: AsyncDevbox) -> None:
130146
"""Test executing a command asynchronously with exec_async."""

‎tests/smoketests/sdk/test_devbox.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ def test_exec_with_exit_code(self, shared_devbox: Devbox) -> None:
125125
assert result.success is False
126126
assert "" == result.stdout(num_lines=1)
127127

128+
@pytest.mark.timeout(TWO_MINUTE_TIMEOUT)
129+
def test_stdin_ordered_roundtrip(self, shared_devbox: Devbox) -> None:
130+
execution = shared_devbox.cmd.exec_async("cat", attach_stdin=True)
131+
completed = False
132+
try:
133+
execution.send_std_in("first\n")
134+
execution.send_std_in("second\n")
135+
execution.close_std_in()
136+
result = execution.result()
137+
completed = True
138+
assert result.exit_code == 0
139+
assert result.stdout() == "first\nsecond\n"
140+
finally:
141+
if not completed:
142+
execution.kill()
143+
128144
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
129145
def test_exec_async_command(self, shared_devbox: Devbox) -> None:
130146
"""Test executing a command asynchronously."""

0 commit comments

Comments
 (0)