diff --git a/src/openai/_files.py b/src/openai/_files.py index 1a2cc77478..1f271dbf9b 100644 --- a/src/openai/_files.py +++ b/src/openai/_files.py @@ -27,16 +27,48 @@ 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) + + +def _is_file_tuple(obj: object) -> bool: + if not isinstance(obj, tuple): + return False + + obj = cast(tuple[object, ...], obj) + + if len(obj) == 2: + filename, file = obj + return (filename is None or isinstance(filename, str)) and is_file_content(file) + + if len(obj) == 3: + filename, file, content_type = obj + return ( + (filename is None or isinstance(filename, str)) + and is_file_content(file) + and (content_type is None or isinstance(content_type, str)) + ) + + if len(obj) == 4: + filename, file, content_type, headers = obj + return ( + (filename is None or isinstance(filename, str)) + and is_file_content(file) + and (content_type is None or isinstance(content_type, str)) + and is_mapping(headers) + ) + + return False + + +def _is_file_types(obj: object) -> TypeGuard[FileTypes]: + return is_file_content(obj) or _is_file_tuple(obj) def assert_is_file_content(obj: object, *, key: str | None = None) -> None: - if not is_file_content(obj): + if not _is_file_types(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" + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a supported file tuple but received {type(obj)} instead. See https://github.com/openai/openai-python/tree/main#file-uploads" ) from None @@ -63,6 +95,9 @@ def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: def _transform_file(file: FileTypes) -> HttpxFileTypes: + if is_tuple_t(file): + return (file[0], read_file_content(file[1]), *file[2:]) + if is_file_content(file): if isinstance(file, os.PathLike): path = pathlib.Path(file) @@ -70,9 +105,6 @@ def _transform_file(file: FileTypes) -> HttpxFileTypes: return file - if is_tuple_t(file): - return (file[0], read_file_content(file[1]), *file[2:]) - raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") @@ -105,6 +137,9 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: + if is_tuple_t(file): + return (file[0], await async_read_file_content(file[1]), *file[2:]) + if is_file_content(file): if isinstance(file, os.PathLike): path = anyio.Path(file) @@ -112,9 +147,6 @@ async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: return file - if is_tuple_t(file): - return (file[0], await async_read_file_content(file[1]), *file[2:]) - raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 54490e133f..6362e85220 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,12 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_invalid_file_tuple_is_rejected() -> None: + query = {"file": ("upload.md", "not-a-file")} + with pytest.raises(RuntimeError, match="Expected entry at `file`"): + extract_files(query, paths=[["file"]]) + + def test_top_level_file_array() -> None: query = {"files": [b"file one", b"file two"], "title": "hello"} assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] diff --git a/tests/test_files.py b/tests/test_files.py index 56445fb550..f422724e06 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -22,6 +22,12 @@ def test_tuple_input() -> None: assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) +def test_pathlib_inside_file_tuple_content_is_read() -> None: + result = to_httpx_files({"file": ("upload.md", readme_path, "text/markdown")}) + print(result) + assert result == IsDict({"file": IsTuple("upload.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}) @@ -43,6 +49,13 @@ async def test_async_tuple_input() -> None: assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) +@pytest.mark.asyncio +async def test_async_pathlib_inside_file_tuple_content_is_read() -> None: + result = await async_to_httpx_files({"file": ("upload.md", readme_path, "text/markdown")}) + print(result) + assert result == IsDict({"file": IsTuple("upload.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(