Skip to content

Commit c2c859a

Browse files
authored
chore(files tests): update files integration tests and fix inline::localfs (#3195)
- update files=inline::localfs to raise ResourceNotFoundError instead of ValueError - only skip tests when no files provider is available - directly use openai_client and llama_stack_client where appropriate - check for correct behavior of non-existent file - xfail the isolation test, no implementation supports it test plan - ``` $ uv run ./scripts/integration-tests.sh --stack-config server:ci-tests --provider ollama --test-subdirs files ... tests/integration/files/test_files.py::test_openai_client_basic_operations PASSED [ 25%] tests/integration/files/test_files.py::test_files_authentication_isolation XFAIL [ 50%] tests/integration/files/test_files.py::test_files_authentication_shared_attributes PASSED [ 75%] tests/integration/files/test_files.py::test_files_authentication_anonymous_access PASSED [100%] ==================================== 3 passed, 1 xfailed in 1.03s ===================================== ``` previously - ``` $ uv run llama stack build --image-type venv --providers files=inline::localfs --run & ... $ ./scripts/integration-tests.sh --stack-config http://localhost:8321 --provider ollama --test-subdirs files ... tests/integration/files/test_files.py::test_openai_client_basic_operations[openai_client-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] PASSED [ 12%] tests/integration/files/test_files.py::test_files_authentication_isolation[openai_client-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] SKIPPED [ 25%] tests/integration/files/test_files.py::test_files_authentication_shared_attributes[openai_client-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] SKIPPED [ 37%] tests/integration/files/test_files.py::test_files_authentication_anonymous_access[openai_client-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] SKIPPED [ 50%] tests/integration/files/test_files.py::test_openai_client_basic_operations[client_with_models-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] PASSED [ 62%] tests/integration/files/test_files.py::test_files_authentication_isolation[client_with_models-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] SKIPPED [ 75%] tests/integration/files/test_files.py::test_files_authentication_shared_attributes[client_with_models-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] SKIPPED [ 87%] tests/integration/files/test_files.py::test_files_authentication_anonymous_access[client_with_models-ollama/llama3.2:3b-instruct-fp16-None-sentence-transformers/all-MiniLM-L6-v2-None-384] SKIPPED [100%] ========================================================= 2 passed, 6 skipped in 1.31s ========================================================== ```
1 parent 55e9959 commit c2c859a

File tree

4 files changed

+92
-87
lines changed

4 files changed

+92
-87
lines changed

llama_stack/providers/inline/files/localfs/files.py

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from fastapi import File, Form, Response, UploadFile
1313

14+
from llama_stack.apis.common.errors import ResourceNotFoundError
1415
from llama_stack.apis.common.responses import Order
1516
from llama_stack.apis.files import (
1617
Files,
@@ -20,12 +21,15 @@
2021
OpenAIFilePurpose,
2122
)
2223
from llama_stack.core.datatypes import AccessRule
24+
from llama_stack.log import get_logger
2325
from llama_stack.providers.utils.sqlstore.api import ColumnDefinition, ColumnType
2426
from llama_stack.providers.utils.sqlstore.authorized_sqlstore import AuthorizedSqlStore
2527
from llama_stack.providers.utils.sqlstore.sqlstore import sqlstore_impl
2628

2729
from .config import LocalfsFilesImplConfig
2830

31+
logger = get_logger(name=__name__, category="files")
32+
2933

3034
class LocalfsFilesImpl(Files):
3135
def __init__(self, config: LocalfsFilesImplConfig, policy: list[AccessRule]) -> None:
@@ -65,6 +69,18 @@ def _get_file_path(self, file_id: str) -> Path:
6569
"""Get the filesystem path for a file ID."""
6670
return Path(self.config.storage_dir) / file_id
6771

72+
async def _lookup_file_id(self, file_id: str) -> tuple[OpenAIFileObject, Path]:
73+
"""Look up a OpenAIFileObject and filesystem path from its ID."""
74+
if not self.sql_store:
75+
raise RuntimeError("Files provider not initialized")
76+
77+
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
78+
if not row:
79+
raise ResourceNotFoundError(file_id, "File", "client.files.list()")
80+
81+
file_path = Path(row.pop("file_path"))
82+
return OpenAIFileObject(**row), file_path
83+
6884
# OpenAI Files API Implementation
6985
async def openai_upload_file(
7086
self,
@@ -157,37 +173,19 @@ async def openai_list_files(
157173

158174
async def openai_retrieve_file(self, file_id: str) -> OpenAIFileObject:
159175
"""Returns information about a specific file."""
160-
if not self.sql_store:
161-
raise RuntimeError("Files provider not initialized")
176+
file_obj, _ = await self._lookup_file_id(file_id)
162177

163-
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
164-
if not row:
165-
raise ValueError(f"File with id {file_id} not found")
166-
167-
return OpenAIFileObject(
168-
id=row["id"],
169-
filename=row["filename"],
170-
purpose=OpenAIFilePurpose(row["purpose"]),
171-
bytes=row["bytes"],
172-
created_at=row["created_at"],
173-
expires_at=row["expires_at"],
174-
)
178+
return file_obj
175179

176180
async def openai_delete_file(self, file_id: str) -> OpenAIFileDeleteResponse:
177181
"""Delete a file."""
178-
if not self.sql_store:
179-
raise RuntimeError("Files provider not initialized")
180-
181-
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
182-
if not row:
183-
raise ValueError(f"File with id {file_id} not found")
184-
185182
# Delete physical file
186-
file_path = Path(row["file_path"])
183+
_, file_path = await self._lookup_file_id(file_id)
187184
if file_path.exists():
188185
file_path.unlink()
189186

190187
# Delete metadata from database
188+
assert self.sql_store is not None, "Files provider not initialized"
191189
await self.sql_store.delete("openai_files", where={"id": file_id})
192190

193191
return OpenAIFileDeleteResponse(
@@ -197,25 +195,17 @@ async def openai_delete_file(self, file_id: str) -> OpenAIFileDeleteResponse:
197195

198196
async def openai_retrieve_file_content(self, file_id: str) -> Response:
199197
"""Returns the contents of the specified file."""
200-
if not self.sql_store:
201-
raise RuntimeError("Files provider not initialized")
202-
203-
# Get file metadata
204-
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
205-
if not row:
206-
raise ValueError(f"File with id {file_id} not found")
207-
208198
# Read file content
209-
file_path = Path(row["file_path"])
210-
if not file_path.exists():
211-
raise ValueError(f"File content not found on disk: {file_path}")
199+
file_obj, file_path = await self._lookup_file_id(file_id)
212200

213-
with open(file_path, "rb") as f:
214-
content = f.read()
201+
if not file_path.exists():
202+
logger.warning(f"File '{file_id}'s underlying '{file_path}' is missing, deleting metadata.")
203+
await self.openai_delete_file(file_id)
204+
raise ResourceNotFoundError(file_id, "File", "client.files.list()")
215205

216206
# Return as binary response with appropriate content type
217207
return Response(
218-
content=content,
208+
content=file_path.read_bytes(),
219209
media_type="application/octet-stream",
220-
headers={"Content-Disposition": f'attachment; filename="{row["filename"]}"'},
210+
headers={"Content-Disposition": f'attachment; filename="{file_obj.filename}"'},
221211
)

tests/integration/files/test_files.py

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,27 @@
88
from unittest.mock import patch
99

1010
import pytest
11-
from openai import OpenAI
1211

1312
from llama_stack.core.datatypes import User
14-
from llama_stack.core.library_client import LlamaStackAsLibraryClient
1513

1614

17-
def test_openai_client_basic_operations(compat_client, client_with_models):
15+
# a fixture to skip all these tests if a files provider is not available
16+
@pytest.fixture(autouse=True)
17+
def skip_if_no_files_provider(llama_stack_client):
18+
if not [provider for provider in llama_stack_client.providers.list() if provider.api == "files"]:
19+
pytest.skip("No files providers found")
20+
21+
22+
def test_openai_client_basic_operations(openai_client):
1823
"""Test basic file operations through OpenAI client."""
19-
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
20-
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
21-
client = compat_client
24+
from openai import NotFoundError
25+
26+
client = openai_client
2227

2328
test_content = b"files test content"
2429

30+
uploaded_file = None
31+
2532
try:
2633
# Upload file using OpenAI client
2734
with BytesIO(test_content) as file_buffer:
@@ -31,6 +38,7 @@ def test_openai_client_basic_operations(compat_client, client_with_models):
3138
# Verify basic response structure
3239
assert uploaded_file.id.startswith("file-")
3340
assert hasattr(uploaded_file, "filename")
41+
assert uploaded_file.filename == "openai_test.txt"
3442

3543
# List files
3644
files_list = client.files.list()
@@ -43,37 +51,41 @@ def test_openai_client_basic_operations(compat_client, client_with_models):
4351

4452
# Retrieve file content - OpenAI client returns httpx Response object
4553
content_response = client.files.content(uploaded_file.id)
46-
# The response is an httpx Response object with .content attribute containing bytes
47-
if isinstance(content_response, str):
48-
# Llama Stack Client returns a str
49-
# TODO: fix Llama Stack Client
50-
content = bytes(content_response, "utf-8")
51-
else:
52-
content = content_response.content
53-
assert content == test_content
54+
assert content_response.content == test_content
5455

5556
# Delete file
5657
delete_response = client.files.delete(uploaded_file.id)
5758
assert delete_response.deleted is True
5859

59-
except Exception as e:
60-
# Cleanup in case of failure
61-
try:
60+
# Retrieve file should fail
61+
with pytest.raises(NotFoundError, match="not found"):
62+
client.files.retrieve(uploaded_file.id)
63+
64+
# File should not be found in listing
65+
files_list = client.files.list()
66+
file_ids = [f.id for f in files_list.data]
67+
assert uploaded_file.id not in file_ids
68+
69+
# Double delete should fail
70+
with pytest.raises(NotFoundError, match="not found"):
6271
client.files.delete(uploaded_file.id)
63-
except Exception:
64-
pass
65-
raise e
6672

73+
finally:
74+
# Cleanup in case of failure
75+
if uploaded_file is not None:
76+
try:
77+
client.files.delete(uploaded_file.id)
78+
except NotFoundError:
79+
pass # ignore 404
6780

81+
82+
@pytest.mark.xfail(message="User isolation broken for current providers, must be fixed.")
6883
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
69-
def test_files_authentication_isolation(mock_get_authenticated_user, compat_client, client_with_models):
84+
def test_files_authentication_isolation(mock_get_authenticated_user, llama_stack_client):
7085
"""Test that users can only access their own files."""
71-
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
72-
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
73-
if not isinstance(client_with_models, LlamaStackAsLibraryClient):
74-
pytest.skip("Authentication tests require LlamaStackAsLibraryClient (library mode)")
86+
from llama_stack_client import NotFoundError
7587

76-
client = compat_client
88+
client = llama_stack_client
7789

7890
# Create two test users
7991
user1 = User("user1", {"roles": ["user"], "teams": ["team-a"]})
@@ -117,7 +129,7 @@ def test_files_authentication_isolation(mock_get_authenticated_user, compat_clie
117129

118130
# User 1 cannot retrieve user2's file
119131
mock_get_authenticated_user.return_value = user1
120-
with pytest.raises(ValueError, match="not found"):
132+
with pytest.raises(NotFoundError, match="not found"):
121133
client.files.retrieve(user2_file.id)
122134

123135
# User 1 can access their file content
@@ -131,7 +143,7 @@ def test_files_authentication_isolation(mock_get_authenticated_user, compat_clie
131143

132144
# User 1 cannot access user2's file content
133145
mock_get_authenticated_user.return_value = user1
134-
with pytest.raises(ValueError, match="not found"):
146+
with pytest.raises(NotFoundError, match="not found"):
135147
client.files.content(user2_file.id)
136148

137149
# User 1 can delete their own file
@@ -141,7 +153,7 @@ def test_files_authentication_isolation(mock_get_authenticated_user, compat_clie
141153

142154
# User 1 cannot delete user2's file
143155
mock_get_authenticated_user.return_value = user1
144-
with pytest.raises(ValueError, match="not found"):
156+
with pytest.raises(NotFoundError, match="not found"):
145157
client.files.delete(user2_file.id)
146158

147159
# User 2 can still access their file after user1's file is deleted
@@ -169,14 +181,9 @@ def test_files_authentication_isolation(mock_get_authenticated_user, compat_clie
169181

170182

171183
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
172-
def test_files_authentication_shared_attributes(mock_get_authenticated_user, compat_client, client_with_models):
184+
def test_files_authentication_shared_attributes(mock_get_authenticated_user, llama_stack_client):
173185
"""Test access control with users having identical attributes."""
174-
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
175-
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
176-
if not isinstance(client_with_models, LlamaStackAsLibraryClient):
177-
pytest.skip("Authentication tests require LlamaStackAsLibraryClient (library mode)")
178-
179-
client = compat_client
186+
client = llama_stack_client
180187

181188
# Create users with identical attributes (required for default policy)
182189
user_a = User("user-a", {"roles": ["user"], "teams": ["shared-team"]})
@@ -231,14 +238,8 @@ def test_files_authentication_shared_attributes(mock_get_authenticated_user, com
231238

232239

233240
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
234-
def test_files_authentication_anonymous_access(mock_get_authenticated_user, compat_client, client_with_models):
235-
"""Test anonymous user behavior when no authentication is present."""
236-
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
237-
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
238-
if not isinstance(client_with_models, LlamaStackAsLibraryClient):
239-
pytest.skip("Authentication tests require LlamaStackAsLibraryClient (library mode)")
240-
241-
client = compat_client
241+
def test_files_authentication_anonymous_access(mock_get_authenticated_user, llama_stack_client):
242+
client = llama_stack_client
242243

243244
# Simulate anonymous user (no authentication)
244245
mock_get_authenticated_user.return_value = None

tests/integration/fixtures/common.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,21 @@ def instantiate_llama_stack_client(session):
263263

264264

265265
@pytest.fixture(scope="session")
266-
def openai_client(client_with_models):
267-
base_url = f"{client_with_models.base_url}/v1/openai/v1"
266+
def require_server(llama_stack_client):
267+
"""
268+
Skip test if no server is running.
269+
270+
We use the llama_stack_client to tell if a server was started or not.
271+
272+
We use this with openai_client because it relies on a running server.
273+
"""
274+
if isinstance(llama_stack_client, LlamaStackAsLibraryClient):
275+
pytest.skip("No server running")
276+
277+
278+
@pytest.fixture(scope="session")
279+
def openai_client(llama_stack_client, require_server):
280+
base_url = f"{llama_stack_client.base_url}/v1/openai/v1"
268281
return OpenAI(base_url=base_url, api_key="fake")
269282

270283

tests/unit/files/test_files.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99

10+
from llama_stack.apis.common.errors import ResourceNotFoundError
1011
from llama_stack.apis.common.responses import Order
1112
from llama_stack.apis.files import OpenAIFilePurpose
1213
from llama_stack.core.access_control.access_control import default_policy
@@ -190,7 +191,7 @@ async def test_retrieve_file_success(self, files_provider, sample_text_file):
190191

191192
async def test_retrieve_file_not_found(self, files_provider):
192193
"""Test retrieving a non-existent file."""
193-
with pytest.raises(ValueError, match="File with id file-nonexistent not found"):
194+
with pytest.raises(ResourceNotFoundError, match="not found"):
194195
await files_provider.openai_retrieve_file("file-nonexistent")
195196

196197
async def test_retrieve_file_content_success(self, files_provider, sample_text_file):
@@ -208,7 +209,7 @@ async def test_retrieve_file_content_success(self, files_provider, sample_text_f
208209

209210
async def test_retrieve_file_content_not_found(self, files_provider):
210211
"""Test retrieving content of a non-existent file."""
211-
with pytest.raises(ValueError, match="File with id file-nonexistent not found"):
212+
with pytest.raises(ResourceNotFoundError, match="not found"):
212213
await files_provider.openai_retrieve_file_content("file-nonexistent")
213214

214215
async def test_delete_file_success(self, files_provider, sample_text_file):
@@ -229,12 +230,12 @@ async def test_delete_file_success(self, files_provider, sample_text_file):
229230
assert delete_response.deleted is True
230231

231232
# Verify file no longer exists
232-
with pytest.raises(ValueError, match=f"File with id {uploaded_file.id} not found"):
233+
with pytest.raises(ResourceNotFoundError, match="not found"):
233234
await files_provider.openai_retrieve_file(uploaded_file.id)
234235

235236
async def test_delete_file_not_found(self, files_provider):
236237
"""Test deleting a non-existent file."""
237-
with pytest.raises(ValueError, match="File with id file-nonexistent not found"):
238+
with pytest.raises(ResourceNotFoundError, match="not found"):
238239
await files_provider.openai_delete_file("file-nonexistent")
239240

240241
async def test_file_persistence_across_operations(self, files_provider, sample_text_file):

0 commit comments

Comments
 (0)