Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/runloop_api_client/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/runloop_api_client/_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# isort: skip_file
from ._sync import asyncify as asyncify
from ._proxy import LazyProxy as LazyProxy
from ._utils import (
Expand Down
31 changes: 31 additions & 0 deletions src/runloop_api_client/_utils/_validation.py
Original file line number Diff line number Diff line change
@@ -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)
62 changes: 62 additions & 0 deletions src/runloop_api_client/resources/blueprints.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
# isort: skip_file

from __future__ import annotations

Expand All @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions tests/api_resources/test_blueprints.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a good idea to test that we don't reject file mounts when under or precisely at the limit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are just client-side validations. There's no point being hyper-precise about these limits since they're really enforced on the server side anyway -- there's nothing to stop a caller ignoring our client & using our API endpoints directly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense!

Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.