Skip to content

Commit e60ad46

Browse files
authored
Add execute and await completion function (#643)
* Add execute and await completion * Fix lint
1 parent 6c83675 commit e60ad46

File tree

7 files changed

+880
-1322
lines changed

7 files changed

+880
-1322
lines changed

pyproject.toml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ authors = [
99
]
1010
dependencies = [
1111
"httpx>=0.23.0, <1",
12-
"pydantic>=1.9.0, <3",
12+
"pydantic>=1.9.0, <2.11",
1313
"typing-extensions>=4.10, <5",
1414
"anyio>=3.5.0, <5",
1515
"distro>=1.7.0, <2",
1616
"sniffio",
17+
"uuid-utils>=0.11.0",
1718
]
1819

19-
requires-python = ">= 3.8"
20+
requires-python = ">= 3.9"
2021
classifiers = [
2122
"Typing :: Typed",
2223
"Intended Audience :: Developers",
23-
"Programming Language :: Python :: 3.8",
2424
"Programming Language :: Python :: 3.9",
2525
"Programming Language :: Python :: 3.10",
2626
"Programming Language :: Python :: 3.11",
@@ -59,6 +59,7 @@ dev-dependencies = [
5959
"importlib-metadata>=6.7.0",
6060
"rich>=13.7.1",
6161
"pytest-xdist>=3.6.1",
62+
"uuid-utils>=0.11.0"
6263
]
6364

6465
[tool.rye.scripts]
@@ -86,6 +87,7 @@ typecheck = { chain = [
8687
"typecheck:pyright",
8788
"typecheck:mypy"
8889
]}
90+
8991
"typecheck:pyright" = "pyright"
9092
"typecheck:verify-types" = "pyright --verifytypes runloop_api_client --ignoreexternal"
9193
"typecheck:mypy" = "mypy ."
@@ -130,8 +132,8 @@ path = "README.md"
130132

131133
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
132134
# replace relative links with absolute links
133-
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
134-
replacement = '[\1](https://github.com/runloopai/api-client-python/tree/main/\g<2>)'
135+
pattern = '\\[(.+?)\\]\\(((?!https?://)\\S+?)\\)'
136+
replacement = '[\\1](https://github.com/runloopai/api-client-python/tree/main/\\g<2>)'
135137

136138
[tool.pytest.ini_options]
137139
testpaths = ["tests"]
@@ -153,7 +155,7 @@ markers = [
153155
# there are a couple of flags that are still disabled by
154156
# default in strict mode as they are experimental and niche.
155157
typeCheckingMode = "strict"
156-
pythonVersion = "3.8"
158+
pythonVersion = "3.9"
157159

158160
exclude = [
159161
"_dev",
@@ -223,7 +225,7 @@ ignore_missing_imports = true
223225
[tool.ruff]
224226
line-length = 120
225227
output-format = "grouped"
226-
target-version = "py38"
228+
target-version = "py39"
227229

228230
[tool.ruff.format]
229231
docstring-code-format = true

requirements-dev.lock

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ pygments==2.18.0
9797
pyright==1.1.399
9898
pytest==8.3.3
9999
# via pytest-asyncio
100+
# via pytest-timeout
100101
# via pytest-xdist
101-
pytest-timeout==2.3.1
102-
# via runloop-api-client (dev)
103102
pytest-asyncio==0.24.0
103+
pytest-timeout==2.3.1
104104
pytest-xdist==3.7.0
105105
python-dateutil==2.8.2
106106
# via time-machine
@@ -128,6 +128,8 @@ typing-extensions==4.12.2
128128
# via pydantic-core
129129
# via pyright
130130
# via runloop-api-client
131+
uuid-utils==0.11.0
132+
# via runloop-api-client
131133
virtualenv==20.24.5
132134
# via nox
133135
yarl==1.20.0

requirements.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,7 @@ typing-extensions==4.12.2
6868
# via pydantic
6969
# via pydantic-core
7070
# via runloop-api-client
71+
uuid-utils==0.11.0
72+
# via runloop-api-client
7173
yarl==1.20.0
7274
# via aiohttp

src/runloop_api_client/_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def __str__(self) -> str:
179179
@classmethod
180180
@override
181181
def construct( # pyright: ignore[reportIncompatibleMethodOverride]
182-
__cls: Type[ModelT],
182+
__cls: Type[ModelT], # type: ignore
183183
_fields_set: set[str] | None = None,
184184
**values: object,
185185
) -> ModelT:

src/runloop_api_client/resources/devboxes/devboxes.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing_extensions import Literal
77

88
import httpx
9+
from uuid_utils import uuid7
910

1011
from .lsp import (
1112
LspResource,
@@ -787,6 +788,59 @@ def execute(
787788
cast_to=DevboxAsyncExecutionDetailView,
788789
)
789790

791+
def execute_and_await_completion(
792+
self,
793+
devbox_id: str,
794+
*,
795+
command: str,
796+
shell_name: Optional[str] | NotGiven = NOT_GIVEN,
797+
polling_config: PollingConfig | None = None,
798+
# The following are forwarded to the initial execute request
799+
extra_headers: Headers | None = None,
800+
extra_query: Query | None = None,
801+
extra_body: Body | None = None,
802+
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
803+
idempotency_key: str | None = None,
804+
) -> DevboxAsyncExecutionDetailView:
805+
"""
806+
Execute a command and wait for it to complete with optimal latency for long running commands.
807+
808+
This method launches an execution with a generated command_id and first attempts to
809+
return the result within the initial request's timeout. If the execution is not yet
810+
complete, it switches to using wait_for_command to minimize latency while waiting.
811+
"""
812+
command_id = str(uuid7())
813+
execution = self.execute(
814+
devbox_id,
815+
command=command,
816+
command_id=command_id,
817+
shell_name=shell_name,
818+
extra_headers=extra_headers,
819+
extra_query=extra_query,
820+
extra_body=extra_body,
821+
timeout=timeout,
822+
idempotency_key=idempotency_key,
823+
)
824+
if execution.status == "completed":
825+
return execution
826+
827+
def handle_timeout_error(error: Exception) -> DevboxAsyncExecutionDetailView:
828+
if isinstance(error, APITimeoutError) or (
829+
isinstance(error, APIStatusError) and error.response.status_code == 408
830+
):
831+
return execution
832+
raise error
833+
834+
def is_done(result: DevboxAsyncExecutionDetailView) -> bool:
835+
return result.status == "completed"
836+
837+
return poll_until(
838+
lambda: self.wait_for_command(execution.execution_id, devbox_id=devbox_id, statuses=["completed"]),
839+
is_done,
840+
polling_config,
841+
handle_timeout_error,
842+
)
843+
790844
def execute_async(
791845
self,
792846
id: str,
@@ -2175,6 +2229,60 @@ async def execute(
21752229
cast_to=DevboxAsyncExecutionDetailView,
21762230
)
21772231

2232+
async def execute_and_await_completion(
2233+
self,
2234+
devbox_id: str,
2235+
*,
2236+
command: str,
2237+
shell_name: Optional[str] | NotGiven = NOT_GIVEN,
2238+
polling_config: PollingConfig | None = None,
2239+
# The following are forwarded to the initial execute request
2240+
extra_headers: Headers | None = None,
2241+
extra_query: Query | None = None,
2242+
extra_body: Body | None = None,
2243+
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
2244+
idempotency_key: str | None = None,
2245+
) -> DevboxAsyncExecutionDetailView:
2246+
"""
2247+
Execute a command and wait for it to complete with optimal latency for long running commands.
2248+
2249+
This method launches an execution with a generated command_id and first attempts to
2250+
return the result within the initial request's timeout. If the execution is not yet
2251+
complete, it switches to using wait_for_command to minimize latency while waiting.
2252+
"""
2253+
2254+
command_id = str(uuid7())
2255+
execution = await self.execute(
2256+
devbox_id,
2257+
command=command,
2258+
command_id=command_id,
2259+
shell_name=shell_name,
2260+
extra_headers=extra_headers,
2261+
extra_query=extra_query,
2262+
extra_body=extra_body,
2263+
timeout=timeout,
2264+
idempotency_key=idempotency_key,
2265+
)
2266+
if execution.status == "completed":
2267+
return execution
2268+
2269+
def handle_timeout_error(error: Exception) -> DevboxAsyncExecutionDetailView:
2270+
if isinstance(error, APITimeoutError) or (
2271+
isinstance(error, APIStatusError) and error.response.status_code == 408
2272+
):
2273+
return execution
2274+
raise error
2275+
2276+
def is_done(result: DevboxAsyncExecutionDetailView) -> bool:
2277+
return result.status == "completed"
2278+
2279+
return await async_poll_until(
2280+
lambda: self.wait_for_command(execution.execution_id, devbox_id=devbox_id, statuses=["completed"]),
2281+
is_done,
2282+
polling_config,
2283+
handle_timeout_error,
2284+
)
2285+
21782286
async def execute_async(
21792287
self,
21802288
id: str,

tests/smoketests/test_executions.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,37 @@ def test_tail_stdout_logs() -> None:
6161
if received:
6262
break
6363
assert isinstance(received, str)
64+
65+
66+
@pytest.mark.timeout(30)
67+
def test_execute_and_await_completion() -> None:
68+
assert _devbox_id
69+
completed = client.devboxes.execute_and_await_completion(
70+
_devbox_id,
71+
command="echo hello && sleep 1",
72+
polling_config=PollingConfig(max_attempts=120, interval_seconds=2.0, timeout_seconds=10 * 60),
73+
)
74+
assert completed.status == "completed"
75+
76+
77+
@pytest.mark.timeout(90)
78+
def test_execute_and_await_completion_long_running() -> None:
79+
assert _devbox_id
80+
completed = client.devboxes.execute_and_await_completion(
81+
_devbox_id,
82+
command="echo hello && sleep 70",
83+
polling_config=PollingConfig(max_attempts=120, interval_seconds=2.0),
84+
)
85+
assert completed.status == "completed"
86+
87+
88+
# TODO: Uncomment this test when we fix timeouts for polling
89+
# @pytest.mark.timeout(30)
90+
# def test_execute_and_await_completion_timeout() -> None:
91+
# assert _devbox_id
92+
# with pytest.raises(PollingTimeout):
93+
# client.devboxes.execute_and_await_completion(
94+
# devbox_id=_devbox_id,
95+
# command="echo hello && sleep 10",
96+
# polling_config=PollingConfig(max_attempts=1, interval_seconds=2.0, timeout_seconds=3),
97+
# )

0 commit comments

Comments
 (0)