Skip to content

Commit 7e5b66e

Browse files
authored
file_mount validation limits for python client (#660)
* added file_mount validation limits * improved error message handling to use notification pattern instead of fast fail with exception * fixed broken import * made file_mount limits more restrictive * updated with actual tested limits
1 parent 6efc023 commit 7e5b66e

6 files changed

Lines changed: 148 additions & 1 deletion

File tree

src/runloop_api_client/_constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,12 @@
1212

1313
INITIAL_RETRY_DELAY = 1.0
1414
MAX_RETRY_DELAY = 60.0
15+
16+
# Maximum allowed size (in bytes) for individual entries in `file_mounts` when creating Blueprints
17+
# NOTE: Empirically, ~131,000 is the maximum command length after
18+
# base64 encoding; 98,250 is the pre-encoded limit that stays within that bound.
19+
# We measure size in bytes using UTF-8 encoding; base64 output is ASCII.
20+
FILE_MOUNT_MAX_SIZE_BYTES = 98_250
21+
22+
# Maximum allowed total size (in bytes) across all `file_mounts` when creating Blueprints
23+
FILE_MOUNT_TOTAL_MAX_SIZE_BYTES = 786_000 * 10 # ~10 mb

src/runloop_api_client/_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# isort: skip_file
12
from ._sync import asyncify as asyncify
23
from ._proxy import LazyProxy as LazyProxy
34
from ._utils import (
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from typing import List, Optional
4+
5+
6+
class ValidationNotification:
7+
"""Collects validation errors without raising exceptions.
8+
9+
This follows the notification pattern: validations append errors, and callers
10+
decide how to react (e.g., surface all messages at once or abort).
11+
"""
12+
13+
def __init__(self) -> None:
14+
self._errors: List[str] = []
15+
self._causes: List[Optional[Exception]] = []
16+
17+
def add_error(self, message: str, cause: Optional[Exception] = None) -> None:
18+
self._errors.append(message)
19+
self._causes.append(cause)
20+
21+
def has_errors(self) -> bool:
22+
return len(self._errors) > 0
23+
24+
@property
25+
def errors(self) -> List[str]:
26+
# Return a copy to avoid external mutation
27+
return list(self._errors)
28+
29+
def error_message(self) -> str:
30+
# Join with semicolons to present multiple issues succinctly
31+
return "; ".join(self._errors)

src/runloop_api_client/resources/blueprints.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
2+
# isort: skip_file
23

34
from __future__ import annotations
45

@@ -15,6 +16,7 @@
1516
)
1617
from .._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
1718
from .._utils import maybe_transform, async_maybe_transform
19+
from .._utils._validation import ValidationNotification
1820
from .._compat import cached_property
1921
from .._resource import SyncAPIResource, AsyncAPIResource
2022
from .._response import (
@@ -23,6 +25,7 @@
2325
async_to_raw_response_wrapper,
2426
async_to_streamed_response_wrapper,
2527
)
28+
from .._constants import FILE_MOUNT_MAX_SIZE_BYTES, FILE_MOUNT_TOTAL_MAX_SIZE_BYTES
2629
from ..pagination import SyncBlueprintsCursorIDPage, AsyncBlueprintsCursorIDPage
2730
from .._exceptions import RunloopError
2831
from ..lib.polling import PollingConfig, poll_until
@@ -50,6 +53,57 @@ class BlueprintRequestArgs(TypedDict, total=False):
5053
__all__ = ["BlueprintsResource", "AsyncBlueprintsResource", "BlueprintRequestArgs"]
5154

5255

56+
def _format_bytes(num_bytes: int) -> str:
57+
"""Format a byte count in a human-friendly way (KB/MB/GB).
58+
59+
Uses binary units (1024). Avoids decimals when exact.
60+
"""
61+
if num_bytes < 1024:
62+
return f"{num_bytes} bytes"
63+
for factor, unit in ((1 << 30, "GB"), (1 << 20, "MB"), (1 << 10, "KB")):
64+
if num_bytes >= factor:
65+
value = num_bytes / factor
66+
if float(value).is_integer():
67+
return f"{int(value)} {unit}"
68+
return f"{value:.1f} {unit}"
69+
return f"{num_bytes} bytes"
70+
71+
72+
def _validate_file_mounts(file_mounts: Optional[Dict[str, str]] | Omit) -> ValidationNotification:
73+
"""Validate file_mounts are within size constraints: returns validation failures.
74+
75+
Currently enforces a maximum per-file size to avoid server-side issues with
76+
large inline file contents. Also enforces a maximum total size across all
77+
file_mounts.
78+
"""
79+
80+
note = ValidationNotification()
81+
82+
if file_mounts is omit or file_mounts is None:
83+
return note
84+
85+
total_size_bytes = 0
86+
for mount_path, content in file_mounts.items():
87+
# Measure size in bytes using UTF-8 encoding since payloads are JSON strings
88+
size_bytes = len(content.encode("utf-8"))
89+
if size_bytes > FILE_MOUNT_MAX_SIZE_BYTES:
90+
over = size_bytes - FILE_MOUNT_MAX_SIZE_BYTES
91+
note.add_error(
92+
f"file_mount '{mount_path}' is {_format_bytes(over)} over the limit "
93+
f"({_format_bytes(size_bytes)} / {_format_bytes(FILE_MOUNT_MAX_SIZE_BYTES)}). Use object_mounts instead."
94+
)
95+
total_size_bytes += size_bytes
96+
97+
if total_size_bytes > FILE_MOUNT_TOTAL_MAX_SIZE_BYTES:
98+
total_over = total_size_bytes - FILE_MOUNT_TOTAL_MAX_SIZE_BYTES
99+
note.add_error(
100+
f"total file_mounts size is {_format_bytes(total_over)} over the limit "
101+
f"({_format_bytes(total_size_bytes)} / {_format_bytes(FILE_MOUNT_TOTAL_MAX_SIZE_BYTES)}). Use object_mounts instead."
102+
)
103+
104+
return note
105+
106+
53107
class BlueprintsResource(SyncAPIResource):
54108
@cached_property
55109
def with_raw_response(self) -> BlueprintsResourceWithRawResponse:
@@ -144,6 +198,10 @@ def create(
144198
145199
idempotency_key: Specify a custom idempotency key for this request
146200
"""
201+
note = _validate_file_mounts(file_mounts)
202+
if note.has_errors():
203+
raise ValueError(note.error_message())
204+
147205
return self._post(
148206
"/v1/blueprints",
149207
body=maybe_transform(
@@ -758,6 +816,10 @@ async def create(
758816
759817
idempotency_key: Specify a custom idempotency key for this request
760818
"""
819+
note = _validate_file_mounts(file_mounts)
820+
if note.has_errors():
821+
raise ValueError(note.error_message())
822+
761823
return await self._post(
762824
"/v1/blueprints",
763825
body=await async_maybe_transform(

tests/api_resources/test_blueprints.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ def test_streaming_response_create(self, client: Runloop) -> None:
108108

109109
assert cast(Any, response.is_closed) is True
110110

111+
@parametrize
112+
def test_create_rejects_large_file_mount(self, client: Runloop) -> None:
113+
# 98,250 bytes + 1 byte (pre-encoded limit to stay within ~131,000 b64'd)
114+
too_large_content = "a" * (98_250 + 1)
115+
with pytest.raises(ValueError, match=r"over the limit"):
116+
client.blueprints.create(
117+
name="name",
118+
file_mounts={"/tmp/large.txt": too_large_content},
119+
)
120+
121+
@parametrize
122+
def test_create_rejects_total_file_mount_size(self, client: Runloop) -> None:
123+
# Eighty files at per-file max (98,250) equals current total limit; add 1 byte to exceed
124+
per_file_max = 98_250
125+
file_mounts = {f"/tmp/{i}.txt": "a" * per_file_max for i in range(80)}
126+
file_mounts["/tmp/extra.txt"] = "x"
127+
with pytest.raises(ValueError, match=r"total file_mounts size .* over the limit"):
128+
client.blueprints.create(
129+
name="name",
130+
file_mounts=file_mounts,
131+
)
132+
111133
@parametrize
112134
def test_method_retrieve(self, client: Runloop) -> None:
113135
blueprint = client.blueprints.retrieve(
@@ -536,6 +558,28 @@ async def test_streaming_response_create(self, async_client: AsyncRunloop) -> No
536558

537559
assert cast(Any, response.is_closed) is True
538560

561+
@parametrize
562+
async def test_create_rejects_large_file_mount(self, async_client: AsyncRunloop) -> None:
563+
# 98,250 bytes + 1 byte (pre-encoded limit to stay within ~131,000 b64'd)
564+
too_large_content = "a" * (98_250 + 1)
565+
with pytest.raises(ValueError, match=r"over the limit"):
566+
await async_client.blueprints.create(
567+
name="name",
568+
file_mounts={"/tmp/large.txt": too_large_content},
569+
)
570+
571+
@parametrize
572+
async def test_create_rejects_total_file_mount_size(self, async_client: AsyncRunloop) -> None:
573+
# Eighty files at per-file max (98,250) equals current total limit; add 1 byte to exceed
574+
per_file_max = 98_250
575+
file_mounts = {f"/tmp/{i}.txt": "a" * per_file_max for i in range(80)}
576+
file_mounts["/tmp/extra.txt"] = "x"
577+
with pytest.raises(ValueError, match=r"total file_mounts size .* over the limit"):
578+
await async_client.blueprints.create(
579+
name="name",
580+
file_mounts=file_mounts,
581+
)
582+
539583
@parametrize
540584
async def test_method_retrieve(self, async_client: AsyncRunloop) -> None:
541585
blueprint = await async_client.blueprints.retrieve(

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)