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
20 changes: 7 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,26 +639,20 @@ The per-model methods (`get_model`, `get_model_examples`, `get_model_pricing`) a

## File helpers

`file_to_data_uri` encodes a local file as a `data:` URI for passing as input:
**The SDK never reads the filesystem on its own.** A string you pass as a parameter is sent as that string, prompts and media parameters alike. When you want a file's contents on the wire, you say so:

```python
from pathlib import Path
from runware import file_to_data_uri
from runware import file_to_base64, file_to_data_uri

data_uri = file_to_data_uri(Path("photo.jpg"))
await client.media_storage({"operation": "upload", "media": data_uri})
await client.run({"model": "...", "seedImage": file_to_base64("photo.jpg")})
await client.media_storage({"operation": "upload", "media": file_to_data_uri("photo.jpg")})
```

Accepts both `Path` and `bytes` — `bytes` is useful when the file lives in memory (e.g. a freshly downloaded blob).
`file_to_base64` returns raw base64, with no `data:` prefix and no MIME type, which is what a media parameter takes most directly: the server reads the real format from the bytes. `file_to_data_uri` returns a `data:<mime>;base64,...` URI, taking the MIME from the file's extension.

`file_to_base64` does the same read but returns raw base64 with no `data:` prefix or MIME type (the server sniffs the real format from the bytes).
Both accept a `str`, a `Path`, `bytes`, or a file-like object, so a blob you already hold in memory needs no round trip through disk.

You usually don't need either helper for inputs: `run()` and `media_storage` auto-encode local file paths. Any string value (recursively, including nested dicts and lists) that points to an existing file on disk is read and replaced with its base64 before the request is sent. URLs, UUIDs, data URIs, existing base64, and prompts pass through untouched.

```python
await client.run({"model": "...", "seedImage": "./photo.jpg"})
await client.run({"model": "...", "referenceImages": ["./a.jpg", "./b.jpg"]})
```
> Before 1.7.1 the SDK replaced any string that happened to name a readable file with that file's contents, in every parameter. Passing a path straight to `seedImage` no longer works: wrap it in `file_to_base64`.

## Custom dependencies

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "runware-sdk"
version = "1.7.0"
version = "1.7.1"
description = "Async Python SDK for the Runware platform — image, video, audio, text, and 3D generation, plus upscaling, background removal, and more, over REST or WebSocket with typed parameters and runtime validation."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
8 changes: 2 additions & 6 deletions runware/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
operation_task_types as _bundled_operation_task_types,
)
from .types.transport import RequestOptions, WireFrame
from .utils.file import encode_local_files
from .validate import validate_tasks


Expand Down Expand Up @@ -264,7 +263,6 @@ async def run( # pyright: ignore[reportInconsistentOverload]
options: RunOptions | None = None,
) -> list[dict[str, Any]]: # pyright: ignore[reportExplicitAny]
"""Run an inference task."""
params = cast("dict[str, Any]", encode_local_files(params)) # pyright: ignore[reportExplicitAny]
params = await self._normalize_model_param(params)
task_type = await self._resolve_task_type(params)
task_uuid = str(params.get("taskUUID") or uuid.uuid4())
Expand Down Expand Up @@ -379,19 +377,17 @@ async def image_upload(
self, params: ImageUploadParams, options: RunOptions | None = None,
) -> list[ImageUploadResult]:
"""Deprecated: use :meth:`media_storage`, which handles any media type and supports deletion."""
encoded = cast("dict[str, Any]", encode_local_files(dict(params))) # pyright: ignore[reportExplicitAny]
return cast(
list[ImageUploadResult],
await self._utility("imageUpload", encoded, options),
await self._utility("imageUpload", dict(params), options),
)

async def media_storage(
self, params: MediaStorageParams, options: RunOptions | None = None,
) -> list[MediaStorageResult]:
encoded = cast("dict[str, Any]", encode_local_files(dict(params))) # pyright: ignore[reportExplicitAny]
return cast(
list[MediaStorageResult],
await self._utility("mediaStorage", encoded, options),
await self._utility("mediaStorage", dict(params), options),
)

async def account_management(
Expand Down
42 changes: 2 additions & 40 deletions runware/utils/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,12 @@
import base64
import io
import mimetypes
import os
from pathlib import Path
from typing import BinaryIO, cast
from typing import BinaryIO


# Strings longer than this can't plausibly be a filesystem path — a base64 blob
# or data URI is far longer. Skipping them avoids hitting the disk for them.
_MAX_PATH_LEN = 4096
_REMOTE_PREFIXES = ("http://", "https://", "data:")


def _read_bytes(source: str | Path | bytes | BinaryIO) -> bytes:
if isinstance(source, (str, Path)):
return Path(source).read_bytes()
Expand Down Expand Up @@ -44,40 +40,6 @@ def file_to_base64(source: str | Path | bytes | BinaryIO) -> str:
return base64.b64encode(_read_bytes(source)).decode("ascii")


def _looks_like_local_file(value: str) -> bool:
if value.startswith(_REMOTE_PREFIXES):
return False
if len(value) > _MAX_PATH_LEN:
return False
try:
return os.path.isfile(value)
except (OSError, ValueError):
return False


def encode_local_files(value: object) -> object:
"""
Recursively walk a params object (dicts, lists, strings) and replace any
string that points to an existing local file with its base64 contents.

URLs, data URIs, UUIDs, prompts, existing base64, numbers, and bools pass
through untouched — only strings that resolve to a real file on disk are
converted. A string that merely looks like a path but doesn't exist is left
as-is (no error raised).
"""
if isinstance(value, str):
if _looks_like_local_file(value):
return file_to_base64(value)
return value
if isinstance(value, dict):
items = cast("dict[object, object]", value)
return {k: encode_local_files(v) for k, v in items.items()}
if isinstance(value, list):
items_list = cast("list[object]", value)
return [encode_local_files(item) for item in items_list]
return value


def file_to_data_uri(source: str | Path | bytes | BinaryIO) -> str:
"""
Encode a file, bytes blob, or file-like into a `data:<mime>;base64,...` URI.
Expand Down
169 changes: 105 additions & 64 deletions tests/test_file_encoding.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
"""
Tests for runware/utils/file.py — auto-encoding of local file paths to base64
when passed as inputs to client.run().
Tests for the rule that the SDK never reads the filesystem on its own.

Until 1.7.0 every string in the params was checked against the filesystem and
replaced with the file's base64 if it happened to name one, which made any caller
that forwarded user text into ``run()`` read local files on request. A local
file now travels only through ``file_to_base64`` or ``file_to_data_uri``, which
the caller has to reach for.

The case that matters is the one that used to leak: a prompt that is exactly a
path to a real file has to arrive at the transport as that path.
"""

from __future__ import annotations
Expand All @@ -12,9 +20,8 @@

import pytest

from runware import Runware, file_to_base64
from runware import Runware, file_to_base64, file_to_data_uri
from runware.transport.rest import RestTransport
from runware.utils.file import encode_local_files


def _patch_rest(client: Runware, response: Any) -> AsyncMock:
Expand All @@ -25,81 +32,115 @@ def _patch_rest(client: Runware, response: Any) -> AsyncMock:
return mock


class TestEncodeLocalFiles:
def test_file_to_base64_has_no_prefix(self, tmp_path: Path) -> None:
f = tmp_path / "photo.jpg"
f.write_bytes(b"\xff\xd8\xff\xe0binary")
expected = base64.b64encode(b"\xff\xd8\xff\xe0binary").decode("ascii")
assert file_to_base64(f) == expected
assert not file_to_base64(f).startswith("data:")

def test_existing_file_converted(self, tmp_path: Path) -> None:
f = tmp_path / "a.png"
f.write_bytes(b"pixels")
out = encode_local_files(str(f))
assert out == base64.b64encode(b"pixels").decode("ascii")

def test_nonexistent_path_passes_through(self) -> None:
assert encode_local_files("./not-a-real-file.jpg") == "./not-a-real-file.jpg"

def test_url_uuid_data_uri_prompt_pass_through(self) -> None:
assert encode_local_files("https://x.com/a.jpg") == "https://x.com/a.jpg"
assert encode_local_files("http://x.com/a.jpg") == "http://x.com/a.jpg"
assert encode_local_files("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
assert encode_local_files("a paragraph that describes a cat") == "a paragraph that describes a cat"
assert encode_local_files("9f3c-uuid-like-1234") == "9f3c-uuid-like-1234"

def test_recurses_into_dicts_and_lists(self, tmp_path: Path) -> None:
a = tmp_path / "a.jpg"
b = tmp_path / "b.jpg"
a.write_bytes(b"AAA")
b.write_bytes(b"BBB")
from typing import cast

out = cast(
"dict[str, Any]",
encode_local_files(
{
"inputs": {"image": str(a)},
"referenceImages": [str(b), "https://x.com/c.jpg"],
"positivePrompt": "a cat",
"width": 1024,
"flag": True,
}
),
async def _sent_for(params: dict[str, Any]) -> dict[str, Any]:
client = Runware(api_key="sk-test", transport="rest")
mock = _patch_rest(
client,
{"data": [{"taskUUID": "u1", "imageURL": "https://x.jpg", "imageUUID": "i1"}]},
)
await client.run(params)
return mock.send_request.call_args_list[0].args[0]


class TestTheSdkDoesNotReadTheFilesystem:
@pytest.mark.asyncio
async def test_a_prompt_that_is_a_real_path_is_sent_as_that_path(
self, tmp_path: Path
) -> None:
secret = tmp_path / "secrets.env"
secret.write_bytes(b"API_KEY=leaked")

sent = await _sent_for(
{
"taskType": "imageInference",
"taskUUID": "u1",
"model": "runware:101@1",
"positivePrompt": str(secret),
"width": 1024,
"height": 1024,
"deliveryMethod": "sync",
}
)
assert out["inputs"]["image"] == base64.b64encode(b"AAA").decode("ascii")
assert out["referenceImages"][0] == base64.b64encode(b"BBB").decode("ascii")
assert out["referenceImages"][1] == "https://x.com/c.jpg"
assert out["positivePrompt"] == "a cat"
assert out["width"] == 1024
assert out["flag"] is True

assert sent["positivePrompt"] == str(secret)
assert base64.b64encode(b"API_KEY=leaked").decode("ascii") not in str(sent)

class TestRunAutoEncodes:
@pytest.mark.asyncio
async def test_run_encodes_local_seed_image(self, tmp_path: Path) -> None:
async def test_a_media_param_that_is_a_real_path_is_sent_as_that_path(
self, tmp_path: Path
) -> None:
f = tmp_path / "seed.jpg"
f.write_bytes(b"\x89PNG seed bytes")
client = Runware(api_key="sk-test", transport="rest")
mock = _patch_rest(
client,
{"data": [{"taskUUID": "u1", "imageURL": "https://x.jpg", "imageUUID": "i1"}]},
)
await client.run(

sent = await _sent_for(
{
"taskType": "imageInference",
"taskUUID": "u1",
"model": "runware:101@1",
"seedImage": str(f),
"referenceImages": ["https://x.com/keep.jpg"],
"positivePrompt": "x",
"width": 1024,
"height": 1024,
"deliveryMethod": "sync",
}
)
sent = mock.send_request.call_args_list[0].args[0]

assert sent["seedImage"] == str(f)

@pytest.mark.asyncio
async def test_a_nested_path_is_left_alone(self, tmp_path: Path) -> None:
f = tmp_path / "nested.txt"
f.write_bytes(b"nested bytes")

sent = await _sent_for(
{
"taskType": "imageInference",
"taskUUID": "u1",
"model": "runware:101@1",
"positivePrompt": "x",
"referenceImages": [str(f)],
"width": 1024,
"height": 1024,
"deliveryMethod": "sync",
}
)

assert sent["referenceImages"] == [str(f)]


class TestALocalFileTravelsWhenTheCallerAsks:
def test_file_to_base64_has_no_prefix(self, tmp_path: Path) -> None:
f = tmp_path / "photo.jpg"
f.write_bytes(b"\x89PNG photo bytes")

assert file_to_base64(str(f)) == base64.b64encode(b"\x89PNG photo bytes").decode("ascii")

def test_file_to_data_uri_carries_the_mime(self, tmp_path: Path) -> None:
f = tmp_path / "photo.jpg"
f.write_bytes(b"bytes")

encoded = base64.b64encode(b"bytes").decode("ascii")
assert file_to_data_uri(str(f)) == f"data:image/jpeg;base64,{encoded}"

def test_bytes_the_caller_already_holds(self) -> None:
assert file_to_base64(b"\x01\x02\x03") == base64.b64encode(b"\x01\x02\x03").decode("ascii")

@pytest.mark.asyncio
async def test_it_reaches_the_transport_when_encoded_first(self, tmp_path: Path) -> None:
f = tmp_path / "seed.jpg"
f.write_bytes(b"\x89PNG seed bytes")

sent = await _sent_for(
{
"taskType": "imageInference",
"taskUUID": "u1",
"model": "runware:101@1",
"seedImage": file_to_base64(str(f)),
"positivePrompt": "x",
"width": 1024,
"height": 1024,
"deliveryMethod": "sync",
}
)

assert sent["seedImage"] == base64.b64encode(b"\x89PNG seed bytes").decode("ascii")
assert sent["referenceImages"] == ["https://x.com/keep.jpg"]
assert sent["positivePrompt"] == "x"
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.

Loading