diff --git a/README.md b/README.md index 2c16b8d..e0a72bd 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ Pytest plugin for automatically mocking OpenAI requests. Powered by [RESPX](http ## Supported Endpoints -[![support coverage](https://img.shields.io/badge/Endpoints_Covered-49.2%25-orange)](https://mharrisb1.github.io/openai-responses-python/coverage) - - [Chat](https://platform.openai.com/docs/api-reference/chat) - [Embeddings](https://platform.openai.com/docs/api-reference/embeddings) - [Files](https://platform.openai.com/docs/api-reference/files) diff --git a/docs/coverage.md b/docs/coverage.md index a6229a5..36c9ea2 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -37,7 +37,7 @@ The end-goal of this library is to eventually support all OpenAI API routes. See | List files | :material-check:{ .green } | - | Stateful | | Retrieve file | :material-check:{ .green } | - | Stateful | | Delete file | :material-check:{ .green } | - | Stateful | -| Retrieve file content | :material-close:{ .red } | - | Stateful | +| Retrieve file content | :material-check:{ .green } | - | Stateful | | **Images** | | Create image | :material-close:{ .red } | - | - | | Create image edit | :material-close:{ .red } | - | - | diff --git a/examples/test_files.py b/examples/test_files.py index 559a012..95ff328 100644 --- a/examples/test_files.py +++ b/examples/test_files.py @@ -64,3 +64,15 @@ def test_delete_file(openai_mock: OpenAIMock): assert client.files.delete(file.id).deleted assert openai_mock.files.create.route.call_count == 1 assert openai_mock.files.delete.route.call_count == 1 + + +@openai_responses.mock() +def test_retrieve_file_content(openai_mock: OpenAIMock): + client = openai.Client(api_key="sk-fake123") + + file = client.files.create( + file=open("examples/example.json", "rb"), + purpose="fine-tune", + ) + res = client.files.content(file.id) + assert res.content == b'{\n "foo": "bar",\n "fizz": "buzz"\n}' diff --git a/pyproject.toml b/pyproject.toml index f2b9724..4d02135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ python = ">=3.9,<4.0" openai = "^1.25" respx = "^0.20.2" faker-openai-api-provider = "^0.1.0" +requests-toolbelt = "^1.0.0" [tool.poetry.group.dev.dependencies] black = "^24.2.0" @@ -37,6 +38,9 @@ tox = "^4.14.2" requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[tool.mypy] +ignore_missing_imports = true + [tool.pyright] typeCheckingMode = "strict" pythonVersion = "3.9" diff --git a/src/openai_responses/_routes/__init__.py b/src/openai_responses/_routes/__init__.py index 5ed3234..84cb7c1 100644 --- a/src/openai_responses/_routes/__init__.py +++ b/src/openai_responses/_routes/__init__.py @@ -4,7 +4,13 @@ from .chat import ChatCompletionsCreateRoute from .embeddings import EmbeddingsCreateRoute -from .files import FileCreateRoute, FileListRoute, FileRetrieveRoute, FileDeleteRoute +from .files import ( + FileCreateRoute, + FileListRoute, + FileRetrieveRoute, + FileDeleteRoute, + FileRetrieveContentRoute, +) from .models import ModelListRoute, ModelRetrieveRoute from .assistants import ( AssistantCreateRoute, @@ -66,6 +72,7 @@ def __init__(self, router: respx.MockRouter, state: StateStore) -> None: self.list = FileListRoute(router, state) self.retrieve = FileRetrieveRoute(router, state) self.delete = FileDeleteRoute(router, state) + self.content = FileRetrieveContentRoute(router, state) class ModelRoutes: diff --git a/src/openai_responses/_routes/files.py b/src/openai_responses/_routes/files.py index 84b093a..ea2ec42 100644 --- a/src/openai_responses/_routes/files.py +++ b/src/openai_responses/_routes/files.py @@ -1,9 +1,10 @@ -import re -from typing import Any +import sys +from typing import Any, Optional from typing_extensions import override import httpx import respx +from requests_toolbelt.multipart import decoder from openai.pagination import SyncPage from openai.types.file_object import FileObject @@ -22,7 +23,6 @@ from .._utils.serde import model_dict from .._utils.time import utcnow_unix_timestamp_s -REGEXP_FILE = r'Content-Disposition: form-data;[^;]+; name="purpose"\r\n\r\n(?P[^\r\n]+)|filename="(?P[^"]+)"' __all__ = ["FileCreateRoute", "FileListRoute", "FileRetrieveRoute", "FileDeleteRoute"] @@ -38,8 +38,43 @@ def __init__(self, router: respx.MockRouter, state: StateStore) -> None: @override def _handler(self, request: httpx.Request, route: respx.Route) -> httpx.Response: self._route = route - model = self._build({}, request) + + filename: Optional[str] = None + purpose: Optional[str] = None + file_content: Optional[bytes] = None + + multipart_data = decoder.MultipartDecoder( + request.content, request.headers.get("content-type") + ) + for part in multipart_data.parts: # type: ignore + content_disposition = part.headers.get(b"Content-Disposition", b"").decode() # type: ignore + if 'name="purpose"' in content_disposition: + purpose = part.text # type: ignore + elif 'name="file"' in content_disposition: + filename = ( # type: ignore + part.headers.get(b"Content-Disposition", b"") # type: ignore + .decode() + .split("filename=")[1] + .strip('"') + ) + file_content = part.content # type: ignore + + assert filename + assert purpose + assert file_content + + model = FileObject( + id=faker.file.id(), + bytes=sys.getsizeof(file_content), # type: ignore + created_at=utcnow_unix_timestamp_s(), + filename=filename, # type: ignore + object="file", + purpose=purpose, # type: ignore + status="uploaded", + status_details=None, + ) self._state.files.put(model) + self._state.files.content.put(model.id, file_content) # type: ignore return httpx.Response( status_code=self._status_code, json=model_dict(model), @@ -47,30 +82,7 @@ def _handler(self, request: httpx.Request, route: respx.Route) -> httpx.Response @staticmethod def _build(partial: PartialFileObject, request: httpx.Request) -> FileObject: - content = request.content.decode("utf-8") - - filename = "" - purpose = "assistants" - - # FIXME: hacky - prog = re.compile(REGEXP_FILE) - matches = prog.finditer(content) - for match in matches: - if match.group("filename"): - filename = match.group("filename") - if match.group("purpose_value"): - purpose = match.group("purpose_value") - - return FileObject( - id=partial.get("id", faker.file.id()), - bytes=partial.get("bytes", 0), - created_at=partial.get("created_at", utcnow_unix_timestamp_s()), - filename=partial.get("filename", filename), - object="file", - purpose=partial.get("purpose", purpose), # type: ignore - status=partial.get("status", "uploaded"), - status_details=partial.get("status_details"), - ) + raise NotImplementedError class FileListRoute(StatefulRoute[SyncPage[FileObject], PartialFileList]): @@ -159,3 +171,33 @@ def _build( request: httpx.Request, ) -> FileObject: raise NotImplementedError + + +class FileRetrieveContentRoute(StatefulRoute[FileObject, PartialFileObject]): + def __init__(self, router: respx.MockRouter, state: StateStore) -> None: + super().__init__( + route=router.get(url__regex=r"/files/(?P[a-zA-Z0-9\-]+)/content"), + status_code=200, + state=state, + ) + + @override + def _handler( + self, + request: httpx.Request, + route: respx.Route, + **kwargs: Any, + ) -> httpx.Response: + self._route = route + fil_id = kwargs["file_id"] + found = self._state.files.get(fil_id) + if not found: + return httpx.Response(404) + + content = self._state.files.content.get(found.id) + assert content + return httpx.Response(status_code=200, content=content) + + @staticmethod + def _build(partial: PartialFileObject, request: httpx.Request) -> FileObject: + raise NotImplementedError diff --git a/src/openai_responses/stores/__init__.py b/src/openai_responses/stores/__init__.py index 3cf1511..d48685b 100644 --- a/src/openai_responses/stores/__init__.py +++ b/src/openai_responses/stores/__init__.py @@ -1,3 +1,4 @@ +from .content_store import ContentStore from .state_store import StateStore -__all__ = ["StateStore"] +__all__ = ["StateStore", "ContentStore"] diff --git a/src/openai_responses/stores/content_store.py b/src/openai_responses/stores/content_store.py new file mode 100644 index 0000000..fdf7847 --- /dev/null +++ b/src/openai_responses/stores/content_store.py @@ -0,0 +1,15 @@ +from typing import Dict, Union + + +class ContentStore: + def __init__(self) -> None: + self._data: Dict[str, bytes] = {} + + def put(self, id: str, content: bytes) -> None: + self._data[id] = content + + def get(self, id: str) -> Union[bytes, None]: + return self._data.get(id) + + def delete(self, id: str) -> None: + del self._data[id] diff --git a/src/openai_responses/stores/state_store.py b/src/openai_responses/stores/state_store.py index 670b37d..a308382 100644 --- a/src/openai_responses/stores/state_store.py +++ b/src/openai_responses/stores/state_store.py @@ -7,6 +7,7 @@ from openai.types.beta.threads.run import Run from openai.types.beta.threads.runs.run_step import RunStep +from .content_store import ContentStore from .._constants import SYSTEM_MODELS from .._utils.serde import model_parse @@ -77,8 +78,11 @@ def delete(self, id: str) -> bool: return False -# TODO: add content storage class FileStore(BaseStore[FileObject]): + def __init__(self) -> None: + super().__init__() + self.content = ContentStore() + def list( self, purpose: Optional[