Skip to content

Commit 1bb6f93

Browse files
Auto-remove from file list after deleting file
1 parent daa7497 commit 1bb6f93

8 files changed

Lines changed: 329 additions & 33 deletions

File tree

main.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ async def lifespan(app: FastAPI):
5151
async def general_exception_handler(request: Request, exc: Exception) -> Response:
5252
logger.error(f"Unhandled error: {exc}")
5353
return templates.TemplateResponse(
54-
"error.html",
55-
{"request": request, "error_message": str(exc)},
54+
request, "error.html",
55+
{"error_message": str(exc)},
5656
status_code=500
5757
)
5858

@@ -70,17 +70,17 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
7070
else:
7171
# Return the full error page for standard requests
7272
return templates.TemplateResponse(
73-
"error.html",
74-
{"request": request, "error_message": f"Invalid input: {error_details}"},
73+
request, "error.html",
74+
{"error_message": f"Invalid input: {error_details}"},
7575
status_code=422,
7676
)
7777

7878
@app.exception_handler(HTTPException)
7979
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
8080
logger.error(f"HTTP error: {exc.detail}")
8181
return templates.TemplateResponse(
82-
"error.html",
83-
{"request": request, "error_message": exc.detail},
82+
request, "error.html",
83+
{"error_message": exc.detail},
8484
status_code=exc.status_code
8585
)
8686

@@ -107,9 +107,8 @@ async def read_home(
107107
conversation_id = await create_conversation()
108108

109109
return templates.TemplateResponse(
110-
"index.html",
110+
request, "index.html",
111111
{
112-
"request": request,
113112
"messages": messages,
114113
"conversation_id": conversation_id
115114
}

routers/files.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ async def list_files(
3030
vector_store_id = await get_or_create_vector_store(client)
3131
files = await get_files_for_vector_store(vector_store_id, client)
3232
return templates.TemplateResponse(
33-
"components/file-list.html",
34-
{"request": request, "files": files}
33+
request, "components/file-list.html",
34+
{"files": files}
3535
)
3636
except Exception as e:
3737
logger.error(f"Error generating file list HTML: {e}")
@@ -53,8 +53,8 @@ async def upload_file(
5353
except Exception as e:
5454
logger.error(f"Error getting or creating vector store: {e}")
5555
return templates.TemplateResponse(
56-
"components/file-list.html",
57-
{"request": request, "error_message": "Error getting or creating vector store"}
56+
request, "components/file-list.html",
57+
{"error_message": "Error getting or creating vector store"}
5858
)
5959

6060
error_messages: list[str] = []
@@ -126,9 +126,8 @@ async def upload_file(
126126

127127
# Return the response, conditionally including error message
128128
return templates.TemplateResponse(
129-
"components/file-list.html",
129+
request, "components/file-list.html",
130130
{
131-
"request": request,
132131
"files": file_list,
133132
**({"error_message": error_message} if error_message else {})
134133
}
@@ -202,9 +201,11 @@ async def delete_file(
202201
try:
203202
if vector_store_id:
204203
files = await get_files_for_vector_store(vector_store_id, client)
204+
# Filter out the deleted file in case the API hasn't caught up yet
205+
files = [f for f in files if f["id"] != file_id]
205206
elif not error_message:
206207
error_message = "Could not retrieve vector store information."
207-
208+
208209
except Exception as fetch_error:
209210
logger.error(f"Error fetching file list after delete attempt: {fetch_error}")
210211
# If an error message wasn't already set, set one now. Otherwise, keep the original error.
@@ -213,9 +214,8 @@ async def delete_file(
213214

214215
# Return the response, conditionally including error message
215216
return templates.TemplateResponse(
216-
"components/file-list.html",
217+
request, "components/file-list.html",
217218
{
218-
"request": request,
219219
"files": files,
220220
**({"error_message": error_message} if error_message else {})
221221
}

routers/setup.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,16 @@ async def read_setup(
117117
existing_mcp_servers.append(entry)
118118

119119
return templates.TemplateResponse(
120-
"setup.html",
120+
request, "setup.html",
121121
{
122-
"request": request,
123122
"setup_message": setup_message,
124-
"status": status, # Pass status from query params
125-
"status_message": message_text, # Pass message from query params
123+
"status": status,
124+
"status_message": message_text,
126125
"current_tools": current_tools,
127126
"current_model": current_model,
128127
"current_instructions": current_instructions,
129128
"current_show_tool_call_detail": current_show_tool_call_detail,
130-
"available_models": available_models, # Pass available models to template
129+
"available_models": available_models,
131130
"existing_registry_entries": read_registry_entries(),
132131
"existing_mcp_servers": existing_mcp_servers,
133132
}
@@ -157,8 +156,8 @@ async def new_registry_row(request: Request) -> Response:
157156
index = 0
158157

159158
return templates.TemplateResponse(
160-
"components/registry-row.html",
161-
{"request": request, "index": index},
159+
request, "components/registry-row.html",
160+
{"index": index},
162161
)
163162

164163

@@ -192,8 +191,8 @@ async def new_mcp_row(request: Request) -> Response:
192191
index = 0
193192

194193
return templates.TemplateResponse(
195-
"components/mcp-row.html",
196-
{"request": request, "index": index},
194+
request, "components/mcp-row.html",
195+
{"index": index},
197196
)
198197

199198

tests/conftest.py

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import os
12
import sys
3+
import time
4+
import socket
5+
import threading
6+
from contextlib import contextmanager
27
from pathlib import Path
8+
from typing import Generator, Iterator
39

410
import pytest
511

6-
712
PROJECT_ROOT = Path(__file__).resolve().parent.parent
813

914
if str(PROJECT_ROOT) not in sys.path:
@@ -13,3 +18,195 @@
1318
@pytest.fixture
1419
def anyio_backend() -> str:
1520
return "asyncio"
21+
22+
23+
# ---------------------------------------------------------------------------
24+
# Playwright infrastructure — live server + environment helpers
25+
# ---------------------------------------------------------------------------
26+
27+
# A fake API key that satisfies AsyncOpenAI()'s constructor without hitting
28+
# the real OpenAI API. Tests that need the page to behave as if there is NO
29+
# key write OPENAI_API_KEY= (empty) to .env; load_dotenv(override=True) in
30+
# the route body then sets the in-process env to "". The Depends lambda
31+
# runs *before* load_dotenv, so it always sees the fake key and doesn't raise.
32+
_FAKE_API_KEY = "sk-fake-playwright-test-key"
33+
_BASE_ENV: dict[str, str] = {
34+
"OPENAI_API_KEY": _FAKE_API_KEY,
35+
"RESPONSES_MODEL": "gpt-4o",
36+
"ENABLED_TOOLS": "",
37+
}
38+
39+
40+
@pytest.fixture(autouse=True)
41+
def _isolate_asyncio_running_loop(request: pytest.FixtureRequest) -> Iterator[None]:
42+
"""
43+
Save and restore asyncio._running_loop around anyio tests.
44+
45+
Playwright's sync API calls asyncio._set_running_loop(self._loop) after
46+
each sync operation to mark its paused greenlet loop as "running" from the
47+
main thread's perspective. anyio's asyncio.Runner raises "Cannot run the
48+
event loop while another loop is running" if that marker is non-None when
49+
it starts.
50+
51+
We therefore clear the marker before each anyio test and restore it
52+
afterwards so that Playwright's session teardown (browser.close) can still
53+
reach its paused loop.
54+
"""
55+
if not request.node.get_closest_marker("anyio"):
56+
yield
57+
return
58+
59+
import asyncio.events as _aio_events
60+
61+
saved = _aio_events._get_running_loop()
62+
_aio_events._set_running_loop(None)
63+
try:
64+
yield
65+
finally:
66+
# Clear any loop reference anyio left behind, then restore Playwright's.
67+
_aio_events._set_running_loop(None)
68+
if saved is not None:
69+
_aio_events._set_running_loop(saved)
70+
71+
72+
@pytest.fixture(scope="session")
73+
def app_server() -> Generator[int, None, None]:
74+
"""Start the FastAPI app in a background thread on a free port."""
75+
import uvicorn
76+
from main import app # imported here to avoid polluting the global scope
77+
78+
# Seed the process environment so AsyncOpenAI() can always be instantiated
79+
# (it validates the key at construction time, before load_dotenv runs).
80+
os.environ.setdefault("OPENAI_API_KEY", _FAKE_API_KEY)
81+
os.environ.setdefault("RESPONSES_MODEL", "gpt-4o")
82+
83+
with socket.socket() as s:
84+
s.bind(("127.0.0.1", 0))
85+
port: int = s.getsockname()[1]
86+
87+
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
88+
server = uvicorn.Server(config)
89+
thread = threading.Thread(target=server.run, daemon=True)
90+
thread.start()
91+
92+
# Wait until the server is actually accepting connections (up to 5 s).
93+
for _ in range(50):
94+
time.sleep(0.1)
95+
if server.started:
96+
break
97+
98+
yield port
99+
100+
server.should_exit = True
101+
thread.join(timeout=5)
102+
103+
104+
@pytest.fixture(scope="session")
105+
def base_url(app_server: int) -> str: # type: ignore[override]
106+
"""Return the base URL of the running test server."""
107+
return f"http://127.0.0.1:{app_server}"
108+
109+
110+
@contextmanager
111+
def _dotenv(overrides: dict[str, str]) -> Iterator[None]:
112+
"""
113+
Write a temporary .env for the duration of a single test, then restore.
114+
115+
Also keeps OPENAI_API_KEY set to the fake value in os.environ so that the
116+
FastAPI Depends(lambda: AsyncOpenAI()) in route signatures never raises
117+
before load_dotenv(override=True) has a chance to run inside the route body.
118+
"""
119+
env_path = PROJECT_ROOT / ".env"
120+
121+
# Persist the original .env (may not exist).
122+
try:
123+
original_text = env_path.read_text()
124+
except FileNotFoundError:
125+
original_text = None
126+
127+
# Track the os.environ state for every key we will touch.
128+
all_keys = set(overrides) | {"OPENAI_API_KEY"}
129+
original_osenv = {k: os.environ.get(k) for k in all_keys}
130+
131+
# Always keep a fake key in the process env for the Depends constructor.
132+
os.environ["OPENAI_API_KEY"] = _FAKE_API_KEY
133+
134+
# Write the test-specific .env; the route body's load_dotenv will read it.
135+
env_path.write_text(
136+
"\n".join(f"{k}={v}" for k, v in overrides.items()) + "\n"
137+
)
138+
139+
try:
140+
yield
141+
finally:
142+
# Restore .env.
143+
if original_text is not None:
144+
env_path.write_text(original_text)
145+
else:
146+
env_path.unlink(missing_ok=True)
147+
148+
# Restore os.environ.
149+
for k, orig in original_osenv.items():
150+
if orig is not None:
151+
os.environ[k] = orig
152+
elif k in os.environ:
153+
del os.environ[k]
154+
155+
156+
# ---------------------------------------------------------------------------
157+
# Environment fixtures used by test_setup_page_rendering.py
158+
# ---------------------------------------------------------------------------
159+
160+
@pytest.fixture
161+
def env_no_api_key(app_server: int) -> Iterator[None]:
162+
"""Setup page should show the API-key form (OPENAI_API_KEY empty)."""
163+
with _dotenv({"RESPONSES_MODEL": "gpt-4o", "OPENAI_API_KEY": ""}):
164+
yield
165+
166+
167+
@pytest.fixture
168+
def env_api_key_no_tools(app_server: int) -> Iterator[None]:
169+
with _dotenv(_BASE_ENV):
170+
yield
171+
172+
173+
@pytest.fixture
174+
def env_file_search_only(app_server: int) -> Iterator[None]:
175+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "file_search"}):
176+
yield
177+
178+
179+
@pytest.fixture
180+
def env_function_only(app_server: int) -> Iterator[None]:
181+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "function"}):
182+
yield
183+
184+
185+
@pytest.fixture
186+
def env_mcp_only(app_server: int) -> Iterator[None]:
187+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "mcp"}):
188+
yield
189+
190+
191+
@pytest.fixture
192+
def env_file_search_and_function(app_server: int) -> Iterator[None]:
193+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "file_search,function"}):
194+
yield
195+
196+
197+
@pytest.fixture
198+
def env_file_search_and_mcp(app_server: int) -> Iterator[None]:
199+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "file_search,mcp"}):
200+
yield
201+
202+
203+
@pytest.fixture
204+
def env_function_and_mcp(app_server: int) -> Iterator[None]:
205+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "function,mcp"}):
206+
yield
207+
208+
209+
@pytest.fixture
210+
def env_all_tools(app_server: int) -> Iterator[None]:
211+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "file_search,function,mcp"}):
212+
yield

0 commit comments

Comments
 (0)