diff --git a/src/runloop_api_client/_constants.py b/src/runloop_api_client/_constants.py index 4fe93f3ea..d6361c8ad 100644 --- a/src/runloop_api_client/_constants.py +++ b/src/runloop_api_client/_constants.py @@ -12,3 +12,12 @@ INITIAL_RETRY_DELAY = 1.0 MAX_RETRY_DELAY = 60.0 + +# Maximum allowed size (in bytes) for individual entries in `file_mounts` when creating Blueprints +# NOTE: Empirically, ~131,000 is the maximum command length after +# base64 encoding; 98,250 is the pre-encoded limit that stays within that bound. +# We measure size in bytes using UTF-8 encoding; base64 output is ASCII. +FILE_MOUNT_MAX_SIZE_BYTES = 98_250 + +# Maximum allowed total size (in bytes) across all `file_mounts` when creating Blueprints +FILE_MOUNT_TOTAL_MAX_SIZE_BYTES = 786_000 * 10 # ~10 mb diff --git a/src/runloop_api_client/_utils/__init__.py b/src/runloop_api_client/_utils/__init__.py index dc64e29a1..80df24dba 100644 --- a/src/runloop_api_client/_utils/__init__.py +++ b/src/runloop_api_client/_utils/__init__.py @@ -1,3 +1,4 @@ +# isort: skip_file from ._sync import asyncify as asyncify from ._proxy import LazyProxy as LazyProxy from ._utils import ( diff --git a/src/runloop_api_client/_utils/_validation.py b/src/runloop_api_client/_utils/_validation.py new file mode 100644 index 000000000..56ec6f76b --- /dev/null +++ b/src/runloop_api_client/_utils/_validation.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import List, Optional + + +class ValidationNotification: + """Collects validation errors without raising exceptions. + + This follows the notification pattern: validations append errors, and callers + decide how to react (e.g., surface all messages at once or abort). + """ + + def __init__(self) -> None: + self._errors: List[str] = [] + self._causes: List[Optional[Exception]] = [] + + def add_error(self, message: str, cause: Optional[Exception] = None) -> None: + self._errors.append(message) + self._causes.append(cause) + + def has_errors(self) -> bool: + return len(self._errors) > 0 + + @property + def errors(self) -> List[str]: + # Return a copy to avoid external mutation + return list(self._errors) + + def error_message(self) -> str: + # Join with semicolons to present multiple issues succinctly + return "; ".join(self._errors) diff --git a/src/runloop_api_client/resources/blueprints.py b/src/runloop_api_client/resources/blueprints.py index 34f98b498..4bb8cbace 100644 --- a/src/runloop_api_client/resources/blueprints.py +++ b/src/runloop_api_client/resources/blueprints.py @@ -1,4 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +# isort: skip_file from __future__ import annotations @@ -15,6 +16,7 @@ ) from .._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from .._utils import maybe_transform, async_maybe_transform +from .._utils._validation import ValidationNotification from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -23,6 +25,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) +from .._constants import FILE_MOUNT_MAX_SIZE_BYTES, FILE_MOUNT_TOTAL_MAX_SIZE_BYTES from ..pagination import SyncBlueprintsCursorIDPage, AsyncBlueprintsCursorIDPage from .._exceptions import RunloopError from ..lib.polling import PollingConfig, poll_until @@ -50,6 +53,57 @@ class BlueprintRequestArgs(TypedDict, total=False): __all__ = ["BlueprintsResource", "AsyncBlueprintsResource", "BlueprintRequestArgs"] +def _format_bytes(num_bytes: int) -> str: + """Format a byte count in a human-friendly way (KB/MB/GB). + + Uses binary units (1024). Avoids decimals when exact. + """ + if num_bytes < 1024: + return f"{num_bytes} bytes" + for factor, unit in ((1 << 30, "GB"), (1 << 20, "MB"), (1 << 10, "KB")): + if num_bytes >= factor: + value = num_bytes / factor + if float(value).is_integer(): + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + return f"{num_bytes} bytes" + + +def _validate_file_mounts(file_mounts: Optional[Dict[str, str]] | Omit) -> ValidationNotification: + """Validate file_mounts are within size constraints: returns validation failures. + + Currently enforces a maximum per-file size to avoid server-side issues with + large inline file contents. Also enforces a maximum total size across all + file_mounts. + """ + + note = ValidationNotification() + + if file_mounts is omit or file_mounts is None: + return note + + total_size_bytes = 0 + for mount_path, content in file_mounts.items(): + # Measure size in bytes using UTF-8 encoding since payloads are JSON strings + size_bytes = len(content.encode("utf-8")) + if size_bytes > FILE_MOUNT_MAX_SIZE_BYTES: + over = size_bytes - FILE_MOUNT_MAX_SIZE_BYTES + note.add_error( + f"file_mount '{mount_path}' is {_format_bytes(over)} over the limit " + f"({_format_bytes(size_bytes)} / {_format_bytes(FILE_MOUNT_MAX_SIZE_BYTES)}). Use object_mounts instead." + ) + total_size_bytes += size_bytes + + if total_size_bytes > FILE_MOUNT_TOTAL_MAX_SIZE_BYTES: + total_over = total_size_bytes - FILE_MOUNT_TOTAL_MAX_SIZE_BYTES + note.add_error( + f"total file_mounts size is {_format_bytes(total_over)} over the limit " + f"({_format_bytes(total_size_bytes)} / {_format_bytes(FILE_MOUNT_TOTAL_MAX_SIZE_BYTES)}). Use object_mounts instead." + ) + + return note + + class BlueprintsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> BlueprintsResourceWithRawResponse: @@ -144,6 +198,10 @@ def create( idempotency_key: Specify a custom idempotency key for this request """ + note = _validate_file_mounts(file_mounts) + if note.has_errors(): + raise ValueError(note.error_message()) + return self._post( "/v1/blueprints", body=maybe_transform( @@ -758,6 +816,10 @@ async def create( idempotency_key: Specify a custom idempotency key for this request """ + note = _validate_file_mounts(file_mounts) + if note.has_errors(): + raise ValueError(note.error_message()) + return await self._post( "/v1/blueprints", body=await async_maybe_transform( diff --git a/tests/api_resources/test_blueprints.py b/tests/api_resources/test_blueprints.py index 30ae21d3e..9826f0b67 100644 --- a/tests/api_resources/test_blueprints.py +++ b/tests/api_resources/test_blueprints.py @@ -108,6 +108,28 @@ def test_streaming_response_create(self, client: Runloop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_create_rejects_large_file_mount(self, client: Runloop) -> None: + # 98,250 bytes + 1 byte (pre-encoded limit to stay within ~131,000 b64'd) + too_large_content = "a" * (98_250 + 1) + with pytest.raises(ValueError, match=r"over the limit"): + client.blueprints.create( + name="name", + file_mounts={"/tmp/large.txt": too_large_content}, + ) + + @parametrize + def test_create_rejects_total_file_mount_size(self, client: Runloop) -> None: + # Eighty files at per-file max (98,250) equals current total limit; add 1 byte to exceed + per_file_max = 98_250 + file_mounts = {f"/tmp/{i}.txt": "a" * per_file_max for i in range(80)} + file_mounts["/tmp/extra.txt"] = "x" + with pytest.raises(ValueError, match=r"total file_mounts size .* over the limit"): + client.blueprints.create( + name="name", + file_mounts=file_mounts, + ) + @parametrize def test_method_retrieve(self, client: Runloop) -> None: blueprint = client.blueprints.retrieve( @@ -536,6 +558,28 @@ async def test_streaming_response_create(self, async_client: AsyncRunloop) -> No assert cast(Any, response.is_closed) is True + @parametrize + async def test_create_rejects_large_file_mount(self, async_client: AsyncRunloop) -> None: + # 98,250 bytes + 1 byte (pre-encoded limit to stay within ~131,000 b64'd) + too_large_content = "a" * (98_250 + 1) + with pytest.raises(ValueError, match=r"over the limit"): + await async_client.blueprints.create( + name="name", + file_mounts={"/tmp/large.txt": too_large_content}, + ) + + @parametrize + async def test_create_rejects_total_file_mount_size(self, async_client: AsyncRunloop) -> None: + # Eighty files at per-file max (98,250) equals current total limit; add 1 byte to exceed + per_file_max = 98_250 + file_mounts = {f"/tmp/{i}.txt": "a" * per_file_max for i in range(80)} + file_mounts["/tmp/extra.txt"] = "x" + with pytest.raises(ValueError, match=r"total file_mounts size .* over the limit"): + await async_client.blueprints.create( + name="name", + file_mounts=file_mounts, + ) + @parametrize async def test_method_retrieve(self, async_client: AsyncRunloop) -> None: blueprint = await async_client.blueprints.retrieve( diff --git a/uv.lock b/uv.lock index 2e395a445..4425156fb 100644 --- a/uv.lock +++ b/uv.lock @@ -1006,7 +1006,7 @@ wheels = [ [[package]] name = "runloop-api-client" -version = "0.65.0" +version = "0.66.1" source = { editable = "." } dependencies = [ { name = "anyio" },