Skip to content

Commit d906ec9

Browse files
committed
added pagination logic to stdout/stderr
1 parent ef298f5 commit d906ec9

2 files changed

Lines changed: 144 additions & 48 deletions

File tree

src/runloop_api_client/sdk/async_execution_result.py

Lines changed: 73 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,79 @@ 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+
if n <= 0 or not text:
64+
return ""
65+
# Remove trailing newlines before splitting and slicing
66+
return "\n".join(text.rstrip("\n").split("\n")[-n:])
67+
68+
async def _get_output(
69+
self,
70+
current_output: str,
71+
is_truncated: bool,
72+
num_lines: Optional[int],
73+
stream_fn: Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]],
74+
) -> str:
75+
"""Common logic for getting output with optional line limiting and streaming."""
76+
# Check if we have enough lines already
77+
if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines):
78+
return self._get_last_n_lines(current_output, num_lines)
79+
80+
# Stream full output if truncated
81+
if is_truncated:
82+
stream = await stream_fn()
83+
output = "".join([chunk.output async for chunk in stream])
84+
return self._get_last_n_lines(output, num_lines) if num_lines is not None else output
85+
86+
# Return current output, optionally limited to last N lines
87+
return self._get_last_n_lines(current_output, num_lines) if num_lines is not None else current_output
88+
5289
async def stdout(self, num_lines: Optional[int] = None) -> str:
53-
text = self._result.stdout or ""
54-
return _tail_lines(text, num_lines)
90+
"""
91+
Return captured standard output, streaming full output if truncated.
92+
93+
Args:
94+
num_lines: Optional number of lines to return from the end (most recent)
95+
96+
Returns:
97+
stdout content, optionally limited to last N lines
98+
"""
99+
return await self._get_output(
100+
self._result.stdout or "",
101+
self._result.stdout_truncated is True,
102+
num_lines,
103+
lambda: self._client.devboxes.executions.stream_stdout_updates(
104+
self.execution_id, devbox_id=self._devbox_id
105+
),
106+
)
55107

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

61127
@property
62128
def raw(self) -> DevboxAsyncExecutionDetailView:
63129
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: 71 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,79 @@ 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+
if n <= 0 or not text:
71+
return ""
72+
# Remove trailing newlines before splitting and slicing
73+
return "\n".join(text.rstrip("\n").split("\n")[-n:])
74+
75+
def _get_output(
76+
self,
77+
current_output: str,
78+
is_truncated: bool,
79+
num_lines: Optional[int],
80+
stream_fn: Callable[[], Stream[ExecutionUpdateChunk]],
81+
) -> str:
82+
"""Common logic for getting output with optional line limiting and streaming."""
83+
# Check if we have enough lines already
84+
if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines):
85+
return self._get_last_n_lines(current_output, num_lines)
86+
87+
# Stream full output if truncated
88+
if is_truncated:
89+
output = "".join(chunk.output for chunk in stream_fn())
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+
6095
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)
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 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+
)
64113

65-
# TODO: add pagination support once we have it in the API
66114
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)
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 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+
)
70132

71133
@property
72134
def raw(self) -> DevboxAsyncExecutionDetailView:
73135
"""Access the underlying API response."""
74136
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

0 commit comments

Comments
 (0)