Skip to content

Commit 5c900b0

Browse files
authored
paginated stdout/stderr in ExecutionResult (#670)
* added pagination logic to stdout/stderr * unit test adjustments * added smoke tests and todos for fixing and testing output line counting logic
1 parent 6d3e819 commit 5c900b0

13 files changed

Lines changed: 638 additions & 126 deletions

src/runloop_api_client/sdk/async_execution_result.py

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from __future__ import annotations
44

5-
from typing_extensions import Optional, override
5+
from typing import Callable, Optional, Awaitable
6+
from typing_extensions import override
67

78
from .._client import AsyncRunloop
9+
from .._streaming import AsyncStream
10+
from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk
811
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
912

1013

@@ -48,32 +51,85 @@ def failed(self) -> bool:
4851
exit_code = self.exit_code
4952
return exit_code is not None and exit_code != 0
5053

51-
# TODO: add pagination support once we have it in the API
54+
def _count_non_empty_lines(self, text: str) -> int:
55+
"""Count non-empty lines in text, excluding trailing empty strings."""
56+
if not text:
57+
return 0
58+
# Remove trailing newlines, split, and count non-empty lines
59+
return sum(1 for line in text.rstrip("\n").split("\n") if line)
60+
61+
def _get_last_n_lines(self, text: str, n: int) -> str:
62+
"""Extract the last N lines from text."""
63+
# TODO: Fix inconsistency - _count_non_empty_lines counts non-empty lines but
64+
# _get_last_n_lines returns N lines (may include empty ones). This means
65+
# num_lines=50 might return fewer than 50 non-empty lines. Should either:
66+
# 1. Make _get_last_n_lines return N non-empty lines, OR
67+
# 2. Make _count_non_empty_lines count all lines
68+
# This affects both Python and TypeScript SDKs - fix together.
69+
if n <= 0 or not text:
70+
return ""
71+
# Remove trailing newlines before splitting and slicing
72+
return "\n".join(text.rstrip("\n").split("\n")[-n:])
73+
74+
async def _get_output(
75+
self,
76+
current_output: str,
77+
is_truncated: bool,
78+
num_lines: Optional[int],
79+
stream_fn: Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]],
80+
) -> str:
81+
"""Common logic for getting output with optional line limiting and streaming."""
82+
# Check if we have enough lines already
83+
if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines):
84+
return self._get_last_n_lines(current_output, num_lines)
85+
86+
# Stream full output if truncated
87+
if is_truncated:
88+
stream = await stream_fn()
89+
output = "".join([chunk.output async for chunk in stream])
90+
return self._get_last_n_lines(output, num_lines) if num_lines is not None else output
91+
92+
# Return current output, optionally limited to last N lines
93+
return self._get_last_n_lines(current_output, num_lines) if num_lines is not None else current_output
94+
5295
async def stdout(self, num_lines: Optional[int] = None) -> str:
53-
text = self._result.stdout or ""
54-
return _tail_lines(text, num_lines)
96+
"""
97+
Return captured standard output, streaming full output if truncated.
98+
99+
Args:
100+
num_lines: Optional number of lines to return from the end (most recent)
101+
102+
Returns:
103+
stdout content, optionally limited to last N lines
104+
"""
105+
return await self._get_output(
106+
self._result.stdout or "",
107+
self._result.stdout_truncated is True,
108+
num_lines,
109+
lambda: self._client.devboxes.executions.stream_stdout_updates(
110+
self.execution_id, devbox_id=self._devbox_id
111+
),
112+
)
55113

56-
# TODO: add pagination support once we have it in the API
57114
async def stderr(self, num_lines: Optional[int] = None) -> str:
58-
text = self._result.stderr or ""
59-
return _tail_lines(text, num_lines)
115+
"""
116+
Return captured standard error, streaming full output if truncated.
117+
118+
Args:
119+
num_lines: Optional number of lines to return from the end (most recent)
120+
121+
Returns:
122+
stderr content, optionally limited to last N lines
123+
"""
124+
return await self._get_output(
125+
self._result.stderr or "",
126+
self._result.stderr_truncated is True,
127+
num_lines,
128+
lambda: self._client.devboxes.executions.stream_stderr_updates(
129+
self.execution_id, devbox_id=self._devbox_id
130+
),
131+
)
60132

61133
@property
62134
def raw(self) -> DevboxAsyncExecutionDetailView:
63135
return self._result
64-
65-
66-
def _tail_lines(text: str, num_lines: Optional[int]) -> str:
67-
if not text:
68-
return ""
69-
if num_lines is None or num_lines <= 0:
70-
return text
71-
72-
lines = text.splitlines()
73-
if not lines:
74-
return text
75-
76-
clipped = "\n".join(lines[-num_lines:])
77-
if text.endswith("\n"):
78-
clipped += "\n"
79-
return clipped

src/runloop_api_client/sdk/execution_result.py

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5-
from typing import Optional
5+
from typing import Callable, Optional
66
from typing_extensions import override
77

88
from .._client import Runloop
9+
from .._streaming import Stream
10+
from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk
911
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
1012

1113

@@ -56,35 +58,85 @@ def failed(self) -> bool:
5658
exit_code = self.exit_code
5759
return exit_code is not None and exit_code != 0
5860

59-
# TODO: add pagination support once we have it in the API
61+
def _count_non_empty_lines(self, text: str) -> int:
62+
"""Count non-empty lines in text, excluding trailing empty strings."""
63+
if not text:
64+
return 0
65+
# Remove trailing newlines, split, and count non-empty lines
66+
return sum(1 for line in text.rstrip("\n").split("\n") if line)
67+
68+
def _get_last_n_lines(self, text: str, n: int) -> str:
69+
"""Extract the last N lines from text."""
70+
# TODO: Fix inconsistency - _count_non_empty_lines counts non-empty lines but
71+
# _get_last_n_lines returns N lines (may include empty ones). This means
72+
# num_lines=50 might return fewer than 50 non-empty lines. Should either:
73+
# 1. Make _get_last_n_lines return N non-empty lines, OR
74+
# 2. Make _count_non_empty_lines count all lines
75+
# This affects both Python and TypeScript SDKs - fix together.
76+
if n <= 0 or not text:
77+
return ""
78+
# Remove trailing newlines before splitting and slicing
79+
return "\n".join(text.rstrip("\n").split("\n")[-n:])
80+
81+
def _get_output(
82+
self,
83+
current_output: str,
84+
is_truncated: bool,
85+
num_lines: Optional[int],
86+
stream_fn: Callable[[], Stream[ExecutionUpdateChunk]],
87+
) -> str:
88+
"""Common logic for getting output with optional line limiting and streaming."""
89+
# Check if we have enough lines already
90+
if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines):
91+
return self._get_last_n_lines(current_output, num_lines)
92+
93+
# Stream full output if truncated
94+
if is_truncated:
95+
output = "".join(chunk.output for chunk in stream_fn())
96+
return self._get_last_n_lines(output, num_lines) if num_lines is not None else output
97+
98+
# Return current output, optionally limited to last N lines
99+
return self._get_last_n_lines(current_output, num_lines) if num_lines is not None else current_output
100+
60101
def stdout(self, num_lines: Optional[int] = None) -> str:
61-
"""Return captured standard output."""
62-
text = self._result.stdout or ""
63-
return _tail_lines(text, num_lines)
102+
"""
103+
Return captured standard output, streaming full output if truncated.
104+
105+
Args:
106+
num_lines: Optional number of lines to return from the end (most recent)
107+
108+
Returns:
109+
stdout content, optionally limited to last N lines
110+
"""
111+
return self._get_output(
112+
self._result.stdout or "",
113+
self._result.stdout_truncated is True,
114+
num_lines,
115+
lambda: self._client.devboxes.executions.stream_stdout_updates(
116+
self.execution_id, devbox_id=self._devbox_id
117+
),
118+
)
64119

65-
# TODO: add pagination support once we have it in the API
66120
def stderr(self, num_lines: Optional[int] = None) -> str:
67-
"""Return captured standard error."""
68-
text = self._result.stderr or ""
69-
return _tail_lines(text, num_lines)
121+
"""
122+
Return captured standard error, streaming full output if truncated.
123+
124+
Args:
125+
num_lines: Optional number of lines to return from the end (most recent)
126+
127+
Returns:
128+
stderr content, optionally limited to last N lines
129+
"""
130+
return self._get_output(
131+
self._result.stderr or "",
132+
self._result.stderr_truncated is True,
133+
num_lines,
134+
lambda: self._client.devboxes.executions.stream_stderr_updates(
135+
self.execution_id, devbox_id=self._devbox_id
136+
),
137+
)
70138

71139
@property
72140
def raw(self) -> DevboxAsyncExecutionDetailView:
73141
"""Access the underlying API response."""
74142
return self._result
75-
76-
77-
def _tail_lines(text: str, num_lines: Optional[int]) -> str:
78-
if not text:
79-
return ""
80-
if num_lines is None or num_lines <= 0:
81-
return text
82-
83-
lines = text.splitlines()
84-
if not lines:
85-
return text
86-
87-
clipped = "\n".join(lines[-num_lines:])
88-
if text.endswith("\n"):
89-
clipped += "\n"
90-
return clipped

tests/sdk/async_devbox/test_core.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,9 @@ async def test_shutdown(self, mock_async_client: AsyncMock, devbox_view: MockDev
137137
async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None:
138138
"""Test suspend method."""
139139
mock_async_client.devboxes.suspend = AsyncMock(return_value=devbox_view)
140-
polling_config = PollingConfig(timeout_seconds=60.0)
141140

142141
devbox = AsyncDevbox(mock_async_client, "dev_123")
143142
result = await devbox.suspend(
144-
polling_config=polling_config,
145143
extra_headers={"X-Custom": "value"},
146144
extra_query={"param": "value"},
147145
extra_body={"key": "value"},
@@ -152,7 +150,6 @@ async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevb
152150
assert result == devbox_view
153151
mock_async_client.devboxes.suspend.assert_called_once_with(
154152
"dev_123",
155-
polling_config=polling_config,
156153
extra_headers={"X-Custom": "value"},
157154
extra_query={"param": "value"},
158155
extra_body={"key": "value"},
@@ -164,11 +161,9 @@ async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevb
164161
async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None:
165162
"""Test resume method."""
166163
mock_async_client.devboxes.resume = AsyncMock(return_value=devbox_view)
167-
polling_config = PollingConfig(timeout_seconds=60.0)
168164

169165
devbox = AsyncDevbox(mock_async_client, "dev_123")
170166
result = await devbox.resume(
171-
polling_config=polling_config,
172167
extra_headers={"X-Custom": "value"},
173168
extra_query={"param": "value"},
174169
extra_body={"key": "value"},
@@ -179,7 +174,6 @@ async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevbo
179174
assert result == devbox_view
180175
mock_async_client.devboxes.resume.assert_called_once_with(
181176
"dev_123",
182-
polling_config=polling_config,
183177
extra_headers={"X-Custom": "value"},
184178
extra_query={"param": "value"},
185179
extra_body={"key": "value"},

tests/sdk/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class MockExecutionView:
5555
exit_status: int = 0
5656
stdout: str = "output"
5757
stderr: str = ""
58+
stdout_truncated: bool = False
59+
stderr_truncated: bool = False
5860

5961

6062
@dataclass

tests/sdk/test_async_clients.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,20 +215,6 @@ async def test_create(self, mock_async_client: AsyncMock, object_view: MockObjec
215215
metadata={"key": "value"},
216216
)
217217

218-
@pytest.mark.asyncio
219-
async def test_create_auto_detect_content_type(
220-
self, mock_async_client: AsyncMock, object_view: MockObjectView
221-
) -> None:
222-
"""Test create auto-detects content type."""
223-
mock_async_client.objects.create = AsyncMock(return_value=object_view)
224-
225-
client = AsyncStorageObjectClient(mock_async_client)
226-
obj = await client.create(name="test.txt")
227-
228-
assert isinstance(obj, AsyncStorageObject)
229-
call_kwargs = mock_async_client.objects.create.call_args[1]
230-
assert "content_type" not in call_kwargs
231-
232218
def test_from_id(self, mock_async_client: AsyncMock) -> None:
233219
"""Test from_id method."""
234220
client = AsyncStorageObjectClient(mock_async_client)

tests/sdk/test_async_execution.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ async def test_result_needs_polling(self, mock_async_client: AsyncMock) -> None:
159159
exit_status=0,
160160
stdout="output",
161161
stderr="",
162+
stdout_truncated=False,
163+
stderr_truncated=False,
162164
)
163165

164166
mock_async_client.devboxes.wait_for_command = AsyncMock(return_value=completed_execution)
@@ -263,19 +265,3 @@ async def test_kill(self, mock_async_client: AsyncMock, execution_view: MockExec
263265
"exec_123",
264266
devbox_id="dev_123",
265267
)
266-
267-
@pytest.mark.asyncio
268-
async def test_kill_with_process_group(
269-
self, mock_async_client: AsyncMock, execution_view: MockExecutionView
270-
) -> None:
271-
"""Test kill with kill_process_group."""
272-
mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None)
273-
274-
execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type]
275-
await execution.kill(kill_process_group=True)
276-
277-
mock_async_client.devboxes.executions.kill.assert_awaited_once_with(
278-
"exec_123",
279-
devbox_id="dev_123",
280-
kill_process_group=True,
281-
)

0 commit comments

Comments
 (0)