Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(routes): add retrieve file content route #46

Merged
merged 2 commits into from
May 28, 2024
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
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 } | - | - |
Expand Down
12 changes: 12 additions & 0 deletions examples/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion src/openai_responses/_routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
98 changes: 70 additions & 28 deletions src/openai_responses/_routes/files.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<purpose_value>[^\r\n]+)|filename="(?P<filename>[^"]+)"'

__all__ = ["FileCreateRoute", "FileListRoute", "FileRetrieveRoute", "FileDeleteRoute"]

Expand All @@ -38,39 +38,51 @@ 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),
)

@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]):
Expand Down Expand Up @@ -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<file_id>[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
3 changes: 2 additions & 1 deletion src/openai_responses/stores/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .content_store import ContentStore
from .state_store import StateStore

__all__ = ["StateStore"]
__all__ = ["StateStore", "ContentStore"]
15 changes: 15 additions & 0 deletions src/openai_responses/stores/content_store.py
Original file line number Diff line number Diff line change
@@ -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]
6 changes: 5 additions & 1 deletion src/openai_responses/stores/state_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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[
Expand Down
Loading