Skip to content
Open
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
6 changes: 2 additions & 4 deletions src/openai/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,11 @@ def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]:


def is_file_content(obj: object) -> TypeGuard[FileContent]:
return (
isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike)
)
return isinstance(obj, bytes) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve tuple FileTypes in extracted uploads

This narrowed guard is also used by assert_is_file_content() inside extract_files() before generated resource methods call to_httpx_files(); those public methods accept FileTypes, whose documented type includes tuple forms. As a result, calls like client.files.create(file=("data.jsonl", b"..."), purpose="batch") or audio/image uploads with a custom filename/content type now raise RuntimeError during extraction instead of reaching the new tuple-normalization path, so the regression affects normal multipart endpoints rather than only direct to_httpx_files() callers.

Useful? React with 👍 / 👎.



def assert_is_file_content(obj: object, *, key: str | None = None) -> None:
if not is_file_content(obj):
if not is_file_content(obj) and not is_tuple_t(obj):
prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`"
raise RuntimeError(
f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/openai/openai-python/tree/main#file-uploads"
Expand Down
37 changes: 37 additions & 0 deletions tests/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ def test_tuple_input() -> None:
assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes())))


def test_file_tuple_with_pathlike_content() -> None:
result = to_httpx_files({"file": ("custom-name.md", readme_path)})
print(result)
assert result == IsDict({"file": IsTuple("custom-name.md", IsBytes())})


def test_file_tuple_with_pathlike_content_and_metadata() -> None:
result = to_httpx_files({"file": ("custom-name.md", readme_path, "text/markdown")})
print(result)
assert result == IsDict({"file": IsTuple("custom-name.md", IsBytes(), "text/markdown")})


@pytest.mark.asyncio
async def test_async_pathlib_includes_file_name() -> None:
result = await async_to_httpx_files({"file": readme_path})
Expand All @@ -43,6 +55,20 @@ async def test_async_tuple_input() -> None:
assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes())))


@pytest.mark.asyncio
async def test_async_file_tuple_with_pathlike_content() -> None:
result = await async_to_httpx_files({"file": ("custom-name.md", readme_path)})
print(result)
assert result == IsDict({"file": IsTuple("custom-name.md", IsBytes())})


@pytest.mark.asyncio
async def test_async_file_tuple_with_pathlike_content_and_metadata() -> None:
result = await async_to_httpx_files({"file": ("custom-name.md", readme_path, "text/markdown")})
print(result)
assert result == IsDict({"file": IsTuple("custom-name.md", IsBytes(), "text/markdown")})


def test_string_not_allowed() -> None:
with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"):
to_httpx_files(
Expand Down Expand Up @@ -117,6 +143,17 @@ def test_extract_files_does_not_mutate_original_top_level(self) -> None:
assert original == {"file": file_bytes, "other": "value"}
assert copied == {"other": "value"}

def test_extract_files_accepts_file_tuple(self) -> None:
file_tuple = ("custom-name.jsonl", b"contents", "application/jsonl")
original = {"file": file_tuple, "purpose": "batch"}

copied = deepcopy_with_paths(original, [["file"]])
extracted = extract_files(copied, paths=[["file"]])

assert extracted == [("file", file_tuple)]
assert original == {"file": file_tuple, "purpose": "batch"}
assert copied == {"purpose": "batch"}

def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None:
file1 = b"f1"
file2 = b"f2"
Expand Down