From 3f47b2256189b1aca4c4137134df2643dd3d7ca5 Mon Sep 17 00:00:00 2001 From: Adam Lesinski Date: Wed, 19 Aug 2026 18:36:14 -0700 Subject: [PATCH 1/3] feat(devboxes): use optimistic create endpoint (#829) --- .../resources/devboxes/devboxes.py | 110 +++++++++++------- tests/api_resources/test_devboxes.py | 90 ++++++++++++-- 2 files changed, 146 insertions(+), 54 deletions(-) diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index 0a0dea11a..de118e0f8 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -500,30 +500,41 @@ def create_and_await_running( PollingTimeout: If polling times out before devbox is running RunloopError: If devbox enters a non-running terminal state """ - # Pass all create_args to the underlying create method - devbox = self.create( - blueprint_id=blueprint_id, - blueprint_name=blueprint_name, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - gateways=gateways, - launch_parameters=launch_parameters, - mcp=mcp, - metadata=metadata, - mounts=mounts, - name=name, - secrets=secrets, - snapshot_id=snapshot_id, - tunnel=tunnel, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + devbox = self._post( + "/v1/devboxes/create_and_await_running", + body=maybe_transform( + { + "blueprint_id": blueprint_id, + "blueprint_name": blueprint_name, + "code_mounts": code_mounts, + "entrypoint": entrypoint, + "environment_variables": environment_variables, + "file_mounts": file_mounts, + "gateways": gateways, + "launch_parameters": launch_parameters, + "mcp": mcp, + "metadata": metadata, + "mounts": mounts, + "name": name, + "secrets": secrets, + "snapshot_id": snapshot_id, + "tunnel": tunnel, + }, + devbox_create_params.DevboxCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=DevboxView, ) + if devbox.status == "running": + return devbox + return self.await_running( devbox.id, polling_config=polling_config, @@ -2071,30 +2082,41 @@ async def create_and_await_running( RunloopError: If devbox enters a non-running terminal state """ - # Pass all create_args, relevant request args to the underlying create method - devbox = await self.create( - blueprint_id=blueprint_id, - blueprint_name=blueprint_name, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - gateways=gateways, - launch_parameters=launch_parameters, - mcp=mcp, - metadata=metadata, - mounts=mounts, - name=name, - secrets=secrets, - snapshot_id=snapshot_id, - tunnel=tunnel, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + devbox = await self._post( + "/v1/devboxes/create_and_await_running", + body=await async_maybe_transform( + { + "blueprint_id": blueprint_id, + "blueprint_name": blueprint_name, + "code_mounts": code_mounts, + "entrypoint": entrypoint, + "environment_variables": environment_variables, + "file_mounts": file_mounts, + "gateways": gateways, + "launch_parameters": launch_parameters, + "mcp": mcp, + "metadata": metadata, + "mounts": mounts, + "name": name, + "secrets": secrets, + "snapshot_id": snapshot_id, + "tunnel": tunnel, + }, + devbox_create_params.DevboxCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=DevboxView, ) + if devbox.status == "running": + return devbox + return await self.await_running( devbox.id, polling_config=polling_config, diff --git a/tests/api_resources/test_devboxes.py b/tests/api_resources/test_devboxes.py index 0d9495f80..490448dc4 100644 --- a/tests/api_resources/test_devboxes.py +++ b/tests/api_resources/test_devboxes.py @@ -4,7 +4,7 @@ import os from typing import Any, cast -from unittest.mock import Mock, patch +from unittest.mock import Mock, AsyncMock, patch import httpx import pytest @@ -1452,9 +1452,9 @@ def test_method_create_and_await_running_success(self, client: Runloop) -> None: state_transitions=[], ) - with patch.object(client.devboxes, "create") as mock_create: + with patch.object(client.devboxes, "_post") as mock_post: with patch.object(client.devboxes, "await_running") as mock_await: - mock_create.return_value = mock_devbox_creating + mock_post.return_value = mock_devbox_creating mock_await.return_value = mock_devbox_running result = client.devboxes.create_and_await_running( @@ -1463,9 +1463,34 @@ def test_method_create_and_await_running_success(self, client: Runloop) -> None: assert result.id == "test_id" assert result.status == "running" - mock_create.assert_called_once() + assert mock_post.call_args.args[0] == "/v1/devboxes/create_and_await_running" + assert mock_post.call_args.kwargs["body"]["name"] == "test" mock_await.assert_called_once_with("test_id", polling_config=None) + @parametrize + def test_method_create_and_await_running_optimistic_success(self, client: Runloop) -> None: + """Test create_and_await_running returns a running response without polling.""" + + mock_devbox_running = DevboxView( + id="test_id", + status="running", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(client.devboxes, "_post") as mock_post: + with patch.object(client.devboxes, "await_running") as mock_await: + mock_post.return_value = mock_devbox_running + + result = client.devboxes.create_and_await_running(name="test") + + assert result.status == "running" + assert mock_post.call_args.args[0] == "/v1/devboxes/create_and_await_running" + mock_await.assert_not_called() + @parametrize def test_method_create_and_await_running_with_config(self, client: Runloop) -> None: """Test create_and_await_running with custom polling configuration""" @@ -1492,9 +1517,9 @@ def test_method_create_and_await_running_with_config(self, client: Runloop) -> N config = PollingConfig(interval_seconds=0.1, max_attempts=10) - with patch.object(client.devboxes, "create") as mock_create: + with patch.object(client.devboxes, "_post") as mock_post: with patch.object(client.devboxes, "await_running") as mock_await: - mock_create.return_value = mock_devbox_creating + mock_post.return_value = mock_devbox_creating mock_await.return_value = mock_devbox_running result = client.devboxes.create_and_await_running( @@ -1514,8 +1539,8 @@ def test_method_create_and_await_running_create_failure(self, client: Runloop) - mock_response.status_code = 400 mock_error = APIStatusError("Bad request", response=mock_response, body=None) - with patch.object(client.devboxes, "create") as mock_create: - mock_create.side_effect = mock_error + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.side_effect = mock_error with pytest.raises(APIStatusError, match="Bad request"): client.devboxes.create_and_await_running( @@ -1536,9 +1561,9 @@ def test_method_create_and_await_running_await_failure(self, client: Runloop) -> state_transitions=[], ) - with patch.object(client.devboxes, "create") as mock_create: + with patch.object(client.devboxes, "_post") as mock_post: with patch.object(client.devboxes, "await_running") as mock_await: - mock_create.return_value = mock_devbox_creating + mock_post.return_value = mock_devbox_creating mock_await.side_effect = RunloopError("Devbox entered non-running terminal state: failed") with pytest.raises(RunloopError, match="Devbox entered non-running terminal state: failed"): @@ -1739,6 +1764,51 @@ class TestAsyncDevboxes: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + @parametrize + async def test_method_create_and_await_running_success(self, async_client: AsyncRunloop) -> None: + mock_devbox_creating = DevboxView( + id="test_id", + status="provisioning", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + mock_devbox_running = mock_devbox_creating.model_copy(update={"status": "running"}) + + with patch.object(async_client.devboxes, "_post", new_callable=AsyncMock) as mock_post: + with patch.object(async_client.devboxes, "await_running", new_callable=AsyncMock) as mock_await: + mock_post.return_value = mock_devbox_creating + mock_await.return_value = mock_devbox_running + + result = await async_client.devboxes.create_and_await_running(name="test") + + assert result.status == "running" + assert mock_post.call_args.args[0] == "/v1/devboxes/create_and_await_running" + mock_await.assert_awaited_once_with("test_id", polling_config=None) + + @parametrize + async def test_method_create_and_await_running_optimistic_success(self, async_client: AsyncRunloop) -> None: + mock_devbox_running = DevboxView( + id="test_id", + status="running", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(async_client.devboxes, "_post", new_callable=AsyncMock) as mock_post: + with patch.object(async_client.devboxes, "await_running", new_callable=AsyncMock) as mock_await: + mock_post.return_value = mock_devbox_running + + result = await async_client.devboxes.create_and_await_running(name="test") + + assert result.status == "running" + mock_await.assert_not_awaited() + @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: devbox = await async_client.devboxes.create() From b7a3d87997538ff6d4658df77d68177f83811eb3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:24:23 +0000 Subject: [PATCH 2/3] chore(internal): allow the mock server port to be set with STAINLESS_MOCK_PORT --- scripts/mock | 13 ++++++++++--- scripts/test | 12 +++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/scripts/mock b/scripts/mock index feebe5ed7..984e1c007 100755 --- a/scripts/mock +++ b/scripts/mock @@ -4,6 +4,11 @@ set -e cd "$(dirname "$0")/.." +if [ "$1" == "--install" ]; then + npm exec --package=@stdy/cli@0.22.1 -- steady --version + exit 0 +fi + if [[ -n "$1" && "$1" != '--'* ]]; then URL="$1" shift @@ -20,16 +25,18 @@ fi echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec +MOCK_PORT="${STAINLESS_MOCK_PORT:-4010}" + if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p "$MOCK_PORT" --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" attempts=0 - while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + while ! curl --silent --fail "http://127.0.0.1:$MOCK_PORT/_x-steady/health" >/dev/null 2>&1; do if ! kill -0 $! 2>/dev/null; then echo cat .stdy.log @@ -48,5 +55,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p "$MOCK_PORT" --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index ddfc3036b..aeb899915 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,10 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color +MOCK_PORT="${STAINLESS_MOCK_PORT:-4010}" + function steady_is_running() { - curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 + curl --silent "http://127.0.0.1:$MOCK_PORT/_x-steady/health" >/dev/null 2>&1 } find_pids_on_port_via_proc() { @@ -60,7 +62,7 @@ function is_overriding_api_base_url() { if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process - trap 'kill_server_on_port 4010' EXIT + trap 'kill_server_on_port "$MOCK_PORT"' EXIT # Start the dev server ./scripts/mock --daemon @@ -76,7 +78,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p $MOCK_PORT --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 @@ -85,6 +87,10 @@ else echo fi +if [ -n "${STAINLESS_MOCK_PORT:-}" ] && ! is_overriding_api_base_url ; then + export TEST_API_BASE_URL="http://127.0.0.1:$MOCK_PORT" +fi + export DEFER_PYDANTIC_BUILD=false # Note that we need to specify the patch version here so that uv From bb1064606c3f8cdeec73cbbd5dcf98e86b87d3e2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:24:47 +0000 Subject: [PATCH 3/3] release: 1.30.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- src/runloop_api_client/_version.py | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b8af36c3a..35c30adcf 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.29.0" + ".": "1.30.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index aa709b138..d7ee627d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.30.0 (2026-08-20) + +Full Changelog: [v1.29.0...v1.30.0](https://github.com/runloopai/api-client-python/compare/v1.29.0...v1.30.0) + +### Features + +* **devboxes:** use optimistic create endpoint ([#829](https://github.com/runloopai/api-client-python/issues/829)) ([3f47b22](https://github.com/runloopai/api-client-python/commit/3f47b2256189b1aca4c4137134df2643dd3d7ca5)) + + +### Chores + +* **internal:** allow the mock server port to be set with STAINLESS_MOCK_PORT ([b7a3d87](https://github.com/runloopai/api-client-python/commit/b7a3d87997538ff6d4658df77d68177f83811eb3)) + ## 1.29.0 (2026-08-20) Full Changelog: [v1.28.0...v1.29.0](https://github.com/runloopai/api-client-python/compare/v1.28.0...v1.29.0) diff --git a/pyproject.toml b/pyproject.toml index 7974ff247..622d9ec0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "1.29.0" +version = "1.30.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index cb57bc803..14101dc3c 100644 --- a/src/runloop_api_client/_version.py +++ b/src/runloop_api_client/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "runloop_api_client" -__version__ = "1.29.0" # x-release-please-version +__version__ = "1.30.0" # x-release-please-version