From 2376350c945f7650795190f8943d36756c6a6039 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Mon, 3 Aug 2026 19:49:12 -0700 Subject: [PATCH 1/2] feat(blueprints): add direct upload registration --- .../resources/blueprints.py | 128 ++++++++++++++++++ src/runloop_api_client/types/__init__.py | 2 + .../types/blueprint_register_params.py | 21 +++ .../types/blueprint_upload_view.py | 14 ++ tests/api_resources/test_blueprints.py | 65 +++++++++ 5 files changed, 230 insertions(+) create mode 100644 src/runloop_api_client/types/blueprint_register_params.py create mode 100644 src/runloop_api_client/types/blueprint_upload_view.py diff --git a/src/runloop_api_client/resources/blueprints.py b/src/runloop_api_client/resources/blueprints.py index 66185645e..3efd9d456 100644 --- a/src/runloop_api_client/resources/blueprints.py +++ b/src/runloop_api_client/resources/blueprints.py @@ -11,6 +11,7 @@ blueprint_list_params, blueprint_create_params, blueprint_preview_params, + blueprint_register_params, blueprint_list_public_params, ) from .._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given @@ -31,6 +32,7 @@ from ..lib.polling_async import async_poll_until from .._utils._validation import ValidationNotification from ..types.blueprint_view import BlueprintView +from ..types.blueprint_upload_view import BlueprintUploadView from ..types.blueprint_preview_view import BlueprintPreviewView from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView from ..types.shared_params.launch_parameters import LaunchParameters @@ -243,6 +245,63 @@ def create( cast_to=BlueprintView, ) + def register( + self, + *, + name: str, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> BlueprintUploadView: + """Create a private Blueprint awaiting its image to be pushed out-of-band via docker push. + + Bypasses the build pipeline entirely: no Dockerfile is composed and no image is + built. The Blueprint stays in the 'awaiting_upload' step until the push + completes. + + Args: + name: Name of the Blueprint. + + launch_parameters: Parameters to configure your Devbox at launch time. + + metadata: (Optional) User defined metadata for the Blueprint. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + return self._post( + "/v1/blueprints/register", + body=maybe_transform( + { + "name": name, + "launch_parameters": launch_parameters, + "metadata": metadata, + }, + blueprint_register_params.BlueprintRegisterParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=BlueprintUploadView, + ) + def retrieve( self, id: str, @@ -848,6 +907,63 @@ async def create( cast_to=BlueprintView, ) + async def register( + self, + *, + name: str, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> BlueprintUploadView: + """Create a private Blueprint awaiting its image to be pushed out-of-band via docker push. + + Bypasses the build pipeline entirely: no Dockerfile is composed and no image is + built. The Blueprint stays in the 'awaiting_upload' step until the push + completes. + + Args: + name: Name of the Blueprint. + + launch_parameters: Parameters to configure your Devbox at launch time. + + metadata: (Optional) User defined metadata for the Blueprint. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + return await self._post( + "/v1/blueprints/register", + body=await async_maybe_transform( + { + "name": name, + "launch_parameters": launch_parameters, + "metadata": metadata, + }, + blueprint_register_params.BlueprintRegisterParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=BlueprintUploadView, + ) + async def retrieve( self, id: str, @@ -1319,6 +1435,9 @@ def __init__(self, blueprints: BlueprintsResource) -> None: self.create = to_raw_response_wrapper( blueprints.create, ) + self.register = to_raw_response_wrapper( + blueprints.register, + ) self.retrieve = to_raw_response_wrapper( blueprints.retrieve, ) @@ -1348,6 +1467,9 @@ def __init__(self, blueprints: AsyncBlueprintsResource) -> None: self.create = async_to_raw_response_wrapper( blueprints.create, ) + self.register = async_to_raw_response_wrapper( + blueprints.register, + ) self.retrieve = async_to_raw_response_wrapper( blueprints.retrieve, ) @@ -1377,6 +1499,9 @@ def __init__(self, blueprints: BlueprintsResource) -> None: self.create = to_streamed_response_wrapper( blueprints.create, ) + self.register = to_streamed_response_wrapper( + blueprints.register, + ) self.retrieve = to_streamed_response_wrapper( blueprints.retrieve, ) @@ -1406,6 +1531,9 @@ def __init__(self, blueprints: AsyncBlueprintsResource) -> None: self.create = async_to_streamed_response_wrapper( blueprints.create, ) + self.register = async_to_streamed_response_wrapper( + blueprints.register, + ) self.retrieve = async_to_streamed_response_wrapper( blueprints.retrieve, ) diff --git a/src/runloop_api_client/types/__init__.py b/src/runloop_api_client/types/__init__.py index 0bda186a3..bb5981e5b 100644 --- a/src/runloop_api_client/types/__init__.py +++ b/src/runloop_api_client/types/__init__.py @@ -75,6 +75,7 @@ from .secret_update_params import SecretUpdateParams as SecretUpdateParams from .benchmark_list_params import BenchmarkListParams as BenchmarkListParams from .blueprint_list_params import BlueprintListParams as BlueprintListParams +from .blueprint_upload_view import BlueprintUploadView as BlueprintUploadView from .devbox_execute_params import DevboxExecuteParams as DevboxExecuteParams from .blueprint_preview_view import BlueprintPreviewView as BlueprintPreviewView from .devbox_shutdown_params import DevboxShutdownParams as DevboxShutdownParams @@ -103,6 +104,7 @@ from .axon_subscribe_sse_params import AxonSubscribeSseParams as AxonSubscribeSseParams from .benchmark_job_list_params import BenchmarkJobListParams as BenchmarkJobListParams from .benchmark_run_list_params import BenchmarkRunListParams as BenchmarkRunListParams +from .blueprint_register_params import BlueprintRegisterParams as BlueprintRegisterParams from .devbox_send_std_in_result import DevboxSendStdInResult as DevboxSendStdInResult from .devbox_snapshot_list_view import DevboxSnapshotListView as DevboxSnapshotListView from .devbox_upload_file_params import DevboxUploadFileParams as DevboxUploadFileParams diff --git a/src/runloop_api_client/types/blueprint_register_params.py b/src/runloop_api_client/types/blueprint_register_params.py new file mode 100644 index 000000000..9eaf82a8a --- /dev/null +++ b/src/runloop_api_client/types/blueprint_register_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Required, TypedDict + +from .shared_params.launch_parameters import LaunchParameters + +__all__ = ["BlueprintRegisterParams"] + + +class BlueprintRegisterParams(TypedDict, total=False): + name: Required[str] + """Name of the Blueprint.""" + + launch_parameters: Optional[LaunchParameters] + """Parameters to configure your Devbox at launch time.""" + + metadata: Optional[Dict[str, str]] + """(Optional) User defined metadata for the Blueprint.""" diff --git a/src/runloop_api_client/types/blueprint_upload_view.py b/src/runloop_api_client/types/blueprint_upload_view.py new file mode 100644 index 000000000..66ac8605d --- /dev/null +++ b/src/runloop_api_client/types/blueprint_upload_view.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel +from .blueprint_view import BlueprintView + +__all__ = ["BlueprintUploadView"] + + +class BlueprintUploadView(BaseModel): + blueprint: BlueprintView + """The created Blueprint, awaiting its image upload.""" + + push_reference: str + """The reference to push the image to, e.g. via docker push.""" diff --git a/tests/api_resources/test_blueprints.py b/tests/api_resources/test_blueprints.py index e3ff9b7eb..fcd666414 100644 --- a/tests/api_resources/test_blueprints.py +++ b/tests/api_resources/test_blueprints.py @@ -11,6 +11,7 @@ from runloop_api_client import Runloop, AsyncRunloop from runloop_api_client.types import ( BlueprintView, + BlueprintUploadView, BlueprintPreviewView, BlueprintBuildLogsListView, ) @@ -24,6 +25,38 @@ class TestBlueprints: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + @parametrize + def test_method_register(self, client: Runloop) -> None: + upload = client.blueprints.register(name="name") + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + @parametrize + def test_method_register_with_all_params(self, client: Runloop) -> None: + upload = client.blueprints.register( + name="name", + launch_parameters={"architecture": "x86_64"}, + metadata={"source": "upload"}, + ) + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + @parametrize + def test_raw_response_register(self, client: Runloop) -> None: + response = client.blueprints.with_raw_response.register(name="name") + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + upload = response.parse() + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + @parametrize + def test_streaming_response_register(self, client: Runloop) -> None: + with client.blueprints.with_streaming_response.register(name="name") as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + upload = response.parse() + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_create(self, client: Runloop) -> None: blueprint = client.blueprints.create( @@ -460,6 +493,38 @@ class TestAsyncBlueprints: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + @parametrize + async def test_method_register(self, async_client: AsyncRunloop) -> None: + upload = await async_client.blueprints.register(name="name") + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + @parametrize + async def test_method_register_with_all_params(self, async_client: AsyncRunloop) -> None: + upload = await async_client.blueprints.register( + name="name", + launch_parameters={"architecture": "x86_64"}, + metadata={"source": "upload"}, + ) + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + @parametrize + async def test_raw_response_register(self, async_client: AsyncRunloop) -> None: + response = await async_client.blueprints.with_raw_response.register(name="name") + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + upload = await response.parse() + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + @parametrize + async def test_streaming_response_register(self, async_client: AsyncRunloop) -> None: + async with async_client.blueprints.with_streaming_response.register(name="name") as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + upload = await response.parse() + assert_matches_type(BlueprintUploadView, upload, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: blueprint = await async_client.blueprints.create( From 04ee90d8450d82fe53d368a0b121164d0023ac8a Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Mon, 3 Aug 2026 20:03:13 -0700 Subject: [PATCH 2/2] fix(blueprints): handle awaiting upload status --- .../resources/blueprints.py | 12 ++-- .../types/blueprint_list_params.py | 2 +- .../types/blueprint_list_public_params.py | 2 +- .../types/blueprint_view.py | 2 +- tests/api_resources/test_blueprints.py | 66 +++++++++++++++---- 5 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/runloop_api_client/resources/blueprints.py b/src/runloop_api_client/resources/blueprints.py index 3efd9d456..fbb7602ef 100644 --- a/src/runloop_api_client/resources/blueprints.py +++ b/src/runloop_api_client/resources/blueprints.py @@ -371,7 +371,7 @@ def retrieve_blueprint() -> BlueprintView: ) def is_done_building(blueprint: BlueprintView) -> bool: - return blueprint.status not in ["queued", "building", "provisioning"] + return blueprint.status not in ["queued", "building", "provisioning", "awaiting_upload"] blueprint = poll_until(retrieve_blueprint, is_done_building, polling_config) @@ -481,7 +481,7 @@ def list( starting_after: Load the next page of data starting after the item with the given ID. - status: Filter by build status (queued, provisioning, building, failed, build_complete) + status: Filter by build status (queued, provisioning, building, awaiting_upload, failed, build_complete) extra_headers: Send extra headers @@ -584,7 +584,7 @@ def list_public( starting_after: Load the next page of data starting after the item with the given ID. - status: Filter by build status (queued, provisioning, building, failed, build_complete) + status: Filter by build status (queued, provisioning, building, awaiting_upload, failed, build_complete) extra_headers: Send extra headers @@ -1033,7 +1033,7 @@ async def retrieve_blueprint() -> BlueprintView: ) def is_done_building(blueprint: BlueprintView) -> bool: - return blueprint.status not in ["queued", "building", "provisioning"] + return blueprint.status not in ["queued", "building", "provisioning", "awaiting_upload"] blueprint = await async_poll_until(retrieve_blueprint, is_done_building, polling_config) @@ -1143,7 +1143,7 @@ def list( starting_after: Load the next page of data starting after the item with the given ID. - status: Filter by build status (queued, provisioning, building, failed, build_complete) + status: Filter by build status (queued, provisioning, building, awaiting_upload, failed, build_complete) extra_headers: Send extra headers @@ -1246,7 +1246,7 @@ def list_public( starting_after: Load the next page of data starting after the item with the given ID. - status: Filter by build status (queued, provisioning, building, failed, build_complete) + status: Filter by build status (queued, provisioning, building, awaiting_upload, failed, build_complete) extra_headers: Send extra headers diff --git a/src/runloop_api_client/types/blueprint_list_params.py b/src/runloop_api_client/types/blueprint_list_params.py index c61df2640..bc2648b3f 100644 --- a/src/runloop_api_client/types/blueprint_list_params.py +++ b/src/runloop_api_client/types/blueprint_list_params.py @@ -24,4 +24,4 @@ class BlueprintListParams(TypedDict, total=False): """Load the next page of data starting after the item with the given ID.""" status: str - """Filter by build status (queued, provisioning, building, failed, build_complete)""" + """Filter by build status (queued, provisioning, building, awaiting_upload, failed, build_complete)""" diff --git a/src/runloop_api_client/types/blueprint_list_public_params.py b/src/runloop_api_client/types/blueprint_list_public_params.py index a6b66dc9b..5421b19a9 100644 --- a/src/runloop_api_client/types/blueprint_list_public_params.py +++ b/src/runloop_api_client/types/blueprint_list_public_params.py @@ -24,4 +24,4 @@ class BlueprintListPublicParams(TypedDict, total=False): """Load the next page of data starting after the item with the given ID.""" status: str - """Filter by build status (queued, provisioning, building, failed, build_complete)""" + """Filter by build status (queued, provisioning, building, awaiting_upload, failed, build_complete)""" diff --git a/src/runloop_api_client/types/blueprint_view.py b/src/runloop_api_client/types/blueprint_view.py index 87c1eecab..b0bfefecd 100644 --- a/src/runloop_api_client/types/blueprint_view.py +++ b/src/runloop_api_client/types/blueprint_view.py @@ -63,7 +63,7 @@ class BlueprintView(BaseModel): state: Literal["created", "deleted"] """The state of the Blueprint.""" - status: Literal["queued", "provisioning", "building", "failed", "build_complete"] + status: Literal["queued", "provisioning", "building", "awaiting_upload", "failed", "build_complete"] """The status of the Blueprint build.""" base_blueprint_id: Optional[str] = None diff --git a/tests/api_resources/test_blueprints.py b/tests/api_resources/test_blueprints.py index fcd666414..9fdd1371f 100644 --- a/tests/api_resources/test_blueprints.py +++ b/tests/api_resources/test_blueprints.py @@ -5,7 +5,9 @@ import os from typing import Any, cast +import httpx import pytest +from respx import MockRouter from tests.utils import assert_matches_type from runloop_api_client import Runloop, AsyncRunloop @@ -22,16 +24,40 @@ base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +def mock_register(respx_mock: MockRouter) -> None: + respx_mock.post("/v1/blueprints/register").mock( + return_value=httpx.Response( + 200, + json={ + "blueprint": { + "id": "bpt_123", + "name": "name", + "status": "awaiting_upload", + "state": "created", + "create_time_ms": 0, + "parameters": {"name": "name"}, + }, + "push_reference": "converter.runloop.ai/blueprints:bpt_123", + }, + ) + ) + + class TestBlueprints: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) @parametrize - def test_method_register(self, client: Runloop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_method_register(self, client: Runloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) upload = client.blueprints.register(name="name") assert_matches_type(BlueprintUploadView, upload, path=["response"]) + assert upload.push_reference == "converter.runloop.ai/blueprints:bpt_123" @parametrize - def test_method_register_with_all_params(self, client: Runloop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_method_register_with_all_params(self, client: Runloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) upload = client.blueprints.register( name="name", launch_parameters={"architecture": "x86_64"}, @@ -40,7 +66,9 @@ def test_method_register_with_all_params(self, client: Runloop) -> None: assert_matches_type(BlueprintUploadView, upload, path=["response"]) @parametrize - def test_raw_response_register(self, client: Runloop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_raw_response_register(self, client: Runloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) response = client.blueprints.with_raw_response.register(name="name") assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -48,7 +76,9 @@ def test_raw_response_register(self, client: Runloop) -> None: assert_matches_type(BlueprintUploadView, upload, path=["response"]) @parametrize - def test_streaming_response_register(self, client: Runloop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_register(self, client: Runloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) with client.blueprints.with_streaming_response.register(name="name") as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -492,14 +522,22 @@ class TestAsyncBlueprints: parametrize = pytest.mark.parametrize( "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + register_parametrize = pytest.mark.parametrize( + "async_client", [False, True], indirect=True, ids=["loose", "strict"] + ) - @parametrize - async def test_method_register(self, async_client: AsyncRunloop) -> None: + @register_parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_register(self, async_client: AsyncRunloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) upload = await async_client.blueprints.register(name="name") assert_matches_type(BlueprintUploadView, upload, path=["response"]) + assert upload.push_reference == "converter.runloop.ai/blueprints:bpt_123" - @parametrize - async def test_method_register_with_all_params(self, async_client: AsyncRunloop) -> None: + @register_parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_register_with_all_params(self, async_client: AsyncRunloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) upload = await async_client.blueprints.register( name="name", launch_parameters={"architecture": "x86_64"}, @@ -507,16 +545,20 @@ async def test_method_register_with_all_params(self, async_client: AsyncRunloop) ) assert_matches_type(BlueprintUploadView, upload, path=["response"]) - @parametrize - async def test_raw_response_register(self, async_client: AsyncRunloop) -> None: + @register_parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_register(self, async_client: AsyncRunloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) response = await async_client.blueprints.with_raw_response.register(name="name") assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" upload = await response.parse() assert_matches_type(BlueprintUploadView, upload, path=["response"]) - @parametrize - async def test_streaming_response_register(self, async_client: AsyncRunloop) -> None: + @register_parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_register(self, async_client: AsyncRunloop, respx_mock: MockRouter) -> None: + mock_register(respx_mock) async with async_client.blueprints.with_streaming_response.register(name="name") as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python"