Spaces:
Build error
Build error
fix: exporting files
Browse files- app.py +46 -0
- frontend/ui/widgets.py +40 -27
- tests/conftest.py +23 -0
- tests/file_response_patch_test.py +61 -0
- tests/widgets_test.py +8 -7
app.py
CHANGED
|
@@ -31,6 +31,52 @@ try:
|
|
| 31 |
except Exception:
|
| 32 |
pass # Non-critical; app will work without this patch
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
import logging
|
| 35 |
import os
|
| 36 |
from pathlib import Path
|
|
|
|
| 31 |
except Exception:
|
| 32 |
pass # Non-critical; app will work without this patch
|
| 33 |
|
| 34 |
+
# ─── Fix Gradio file download Content-Length bug ──────────────────────
|
| 35 |
+
# Gradio 6.x uses Starlette FileResponse which can cause "Too little data
|
| 36 |
+
# for declared Content-Length" with h11 on streaming file downloads.
|
| 37 |
+
# The root cause: FileResponse.set_stat_headers() sets Content-Length based
|
| 38 |
+
# on the file size. With h11, this causes a protocol error during chunked
|
| 39 |
+
# transfer. We patch FileResponse to skip setting Content-Length so h11
|
| 40 |
+
# falls back to chunked transfer encoding.
|
| 41 |
+
#
|
| 42 |
+
# IMPORTANT: Gradio imports FileResponse directly into its modules (routes.py,
|
| 43 |
+
# route_utils.py). Simply replacing starlette.responses.FileResponse is not
|
| 44 |
+
# enough — we must also patch Gradio's cached references.
|
| 45 |
+
try:
|
| 46 |
+
from starlette.responses import FileResponse as _FileResponseBase
|
| 47 |
+
import starlette.responses as _sr_mod
|
| 48 |
+
|
| 49 |
+
class _NoContentLengthFileResponse(_FileResponseBase):
|
| 50 |
+
"""FileResponse that never sets Content-Length to avoid h11 bugs."""
|
| 51 |
+
|
| 52 |
+
def set_stat_headers(self, stat_result):
|
| 53 |
+
"""Override to skip setting Content-Length (keeps last-modified and etag)."""
|
| 54 |
+
last_modified = _sr_mod.formatdate(stat_result.st_mtime, usegmt=True)
|
| 55 |
+
etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size)
|
| 56 |
+
import hashlib
|
| 57 |
+
etag = '"' + hashlib.md5(etag_base.encode(), usedforsecurity=False).hexdigest() + '"'
|
| 58 |
+
self.headers.setdefault("last-modified", last_modified)
|
| 59 |
+
self.headers.setdefault("etag", etag)
|
| 60 |
+
# Deliberately NOT setting content-length
|
| 61 |
+
|
| 62 |
+
# Patch Starlette's module-level reference
|
| 63 |
+
_sr_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment]
|
| 64 |
+
|
| 65 |
+
# Also patch Gradio's cached references (they imported FileResponse directly)
|
| 66 |
+
import gradio.route_utils as _ru_mod
|
| 67 |
+
if hasattr(_ru_mod, 'FileResponse'):
|
| 68 |
+
_ru_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment]
|
| 69 |
+
|
| 70 |
+
import gradio.routes as _rt_mod
|
| 71 |
+
if hasattr(_rt_mod, 'FileResponse'):
|
| 72 |
+
_rt_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment]
|
| 73 |
+
|
| 74 |
+
import gradio.static_server as _ss_mod
|
| 75 |
+
if hasattr(_ss_mod, 'FileResponse'):
|
| 76 |
+
_ss_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment]
|
| 77 |
+
except Exception:
|
| 78 |
+
pass # Non-critical; app will work without this patch
|
| 79 |
+
|
| 80 |
import logging
|
| 81 |
import os
|
| 82 |
from pathlib import Path
|
frontend/ui/widgets.py
CHANGED
|
@@ -80,10 +80,10 @@ def _enable_phase2() -> tuple:
|
|
| 80 |
|
| 81 |
Both Audio and Images toggles default to ON after Phase 1. Voice dropdown becomes interactive — it becomes visible when audio toggle is turned ON (via audio_toggle.change).
|
| 82 |
Explicitly sets value=True to prevent Gradio from resetting checkbox state on re-render.
|
| 83 |
-
|
| 84 |
|
| 85 |
Returns:
|
| 86 |
-
Tuple of (images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css) updates.
|
| 87 |
"""
|
| 88 |
import gradio as gr
|
| 89 |
return (
|
|
@@ -91,6 +91,7 @@ def _enable_phase2() -> tuple:
|
|
| 91 |
gr.Checkbox(interactive=True, value=True), # audio_toggle
|
| 92 |
gr.Button(interactive=True), # generate_cards_btn
|
| 93 |
gr.Dropdown(interactive=True), # voice_dropdown
|
|
|
|
| 94 |
"", # phase_css
|
| 95 |
)
|
| 96 |
|
|
@@ -104,11 +105,11 @@ def _reset_to_idle() -> tuple:
|
|
| 104 |
Re-applies disabled CSS to phase-2 controls.
|
| 105 |
Keeps voice dropdown visible but disabled (it becomes interactive when audio is toggled ON after Phase 1).
|
| 106 |
Explicitly sets value=False to prevent Gradio from resetting checkbox state on re-render.
|
| 107 |
-
|
| 108 |
|
| 109 |
Returns:
|
| 110 |
Tuple of (generate_text_btn, images_toggle, audio_toggle, generate_cards_btn,
|
| 111 |
-
voice_dropdown, phase_css,
|
| 112 |
"""
|
| 113 |
import gradio as gr
|
| 114 |
return (
|
|
@@ -118,7 +119,8 @@ def _reset_to_idle() -> tuple:
|
|
| 118 |
gr.Button(visible=True, interactive=False, variant="secondary"), # generate_cards_btn
|
| 119 |
gr.Dropdown(visible=True, interactive=False), # voice_dropdown
|
| 120 |
"""<style id="phase-css">#toggle-images, #toggle-audio { opacity: 0.45; pointer-events: none; cursor: not-allowed; } #language-dropdown, #voice-dropdown { opacity: 0.45; pointer-events: none; cursor: not-allowed; } #generate-cards-btn { opacity: 0.45; pointer-events: none; cursor: not-allowed; }</style>""", # phase_css
|
| 121 |
-
gr.
|
|
|
|
| 122 |
)
|
| 123 |
|
| 124 |
|
|
@@ -233,9 +235,17 @@ def build_ui() -> "gr.Blocks":
|
|
| 233 |
|
| 234 |
progress_html = gr.HTML(label="Progress")
|
| 235 |
|
| 236 |
-
#
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
)
|
| 240 |
|
| 241 |
gr.Column(scale=1)
|
|
@@ -272,16 +282,17 @@ def build_ui() -> "gr.Blocks":
|
|
| 272 |
yield result
|
| 273 |
|
| 274 |
def _on_media_generation_complete():
|
| 275 |
-
"""After Phase 2 completes: hide generate buttons,
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
"""
|
| 280 |
import gradio as gr
|
| 281 |
return (
|
| 282 |
gr.Button(visible=False), # generate_text_btn
|
| 283 |
gr.Button(visible=False), # generate_cards_btn
|
| 284 |
-
gr.
|
|
|
|
| 285 |
)
|
| 286 |
|
| 287 |
generate_text_btn.click(
|
|
@@ -291,7 +302,7 @@ def build_ui() -> "gr.Blocks":
|
|
| 291 |
).then(
|
| 292 |
fn=_enable_phase2,
|
| 293 |
inputs=[],
|
| 294 |
-
outputs=[images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css],
|
| 295 |
)
|
| 296 |
|
| 297 |
# When audio toggle changes: show/hide voice dropdown and manage disabled CSS
|
|
@@ -317,43 +328,45 @@ def build_ui() -> "gr.Blocks":
|
|
| 317 |
).then(
|
| 318 |
fn=_on_media_generation_complete,
|
| 319 |
inputs=[],
|
| 320 |
-
outputs=[generate_text_btn, generate_cards_btn,
|
| 321 |
)
|
| 322 |
|
| 323 |
# Reset toggles and both buttons when user changes any input parameter
|
| 324 |
-
scenario_input.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css,
|
| 325 |
-
cefr_dropdown.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, phase_css,
|
| 326 |
-
batch_slider.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css,
|
| 327 |
# Language change does NOT reset — user can switch languages freely after Phase 1
|
| 328 |
|
| 329 |
# ─── Export Event Wiring ──────────────────────────────────────
|
| 330 |
|
| 331 |
def _handle_export_csv_event(scenario: str, cefr_level: str, target_language: str):
|
| 332 |
-
"""Export current cards as CSV + media zip
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
|
|
|
| 336 |
"""
|
| 337 |
from frontend.ui.cards import generate_progress_html
|
| 338 |
|
| 339 |
if not _app_module._current_cards:
|
| 340 |
-
return generate_progress_html(0, "\u26a0\ufe0f No cards to export."), None
|
| 341 |
|
| 342 |
try:
|
| 343 |
zip_path = _app_module._handle_export_csv(scenario, cefr_level, target_language)
|
| 344 |
if zip_path is None:
|
| 345 |
-
return generate_progress_html(0, "\u26a0\ufe0f Export failed."), None
|
| 346 |
-
|
|
|
|
| 347 |
except Exception as e:
|
| 348 |
logger = logging.getLogger(__name__)
|
| 349 |
logger.error("CSV export failed: %s", e, exc_info=True)
|
| 350 |
-
return generate_progress_html(0, f"\u26a0\ufe0f Export failed: {e}"), None
|
| 351 |
|
| 352 |
-
#
|
| 353 |
-
|
| 354 |
fn=_handle_export_csv_event,
|
| 355 |
inputs=[scenario_input, cefr_dropdown, language_dropdown],
|
| 356 |
-
outputs=[progress_html,
|
| 357 |
)
|
| 358 |
|
| 359 |
return demo
|
|
|
|
| 80 |
|
| 81 |
Both Audio and Images toggles default to ON after Phase 1. Voice dropdown becomes interactive — it becomes visible when audio toggle is turned ON (via audio_toggle.change).
|
| 82 |
Explicitly sets value=True to prevent Gradio from resetting checkbox state on re-render.
|
| 83 |
+
Export button remains VISIBLE but DISABLED until Phase 2 completes (when _current_cards is populated).
|
| 84 |
|
| 85 |
Returns:
|
| 86 |
+
Tuple of (images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, export_btn, phase_css) updates.
|
| 87 |
"""
|
| 88 |
import gradio as gr
|
| 89 |
return (
|
|
|
|
| 91 |
gr.Checkbox(interactive=True, value=True), # audio_toggle
|
| 92 |
gr.Button(interactive=True), # generate_cards_btn
|
| 93 |
gr.Dropdown(interactive=True), # voice_dropdown
|
| 94 |
+
gr.Button(visible=True, interactive=False), # export_btn (disabled until Phase 2)
|
| 95 |
"", # phase_css
|
| 96 |
)
|
| 97 |
|
|
|
|
| 105 |
Re-applies disabled CSS to phase-2 controls.
|
| 106 |
Keeps voice dropdown visible but disabled (it becomes interactive when audio is toggled ON after Phase 1).
|
| 107 |
Explicitly sets value=False to prevent Gradio from resetting checkbox state on re-render.
|
| 108 |
+
Keeps export button visible but disabled until Phase 2 completes.
|
| 109 |
|
| 110 |
Returns:
|
| 111 |
Tuple of (generate_text_btn, images_toggle, audio_toggle, generate_cards_btn,
|
| 112 |
+
voice_dropdown, phase_css, export_btn, export_file) updates.
|
| 113 |
"""
|
| 114 |
import gradio as gr
|
| 115 |
return (
|
|
|
|
| 119 |
gr.Button(visible=True, interactive=False, variant="secondary"), # generate_cards_btn
|
| 120 |
gr.Dropdown(visible=True, interactive=False), # voice_dropdown
|
| 121 |
"""<style id="phase-css">#toggle-images, #toggle-audio { opacity: 0.45; pointer-events: none; cursor: not-allowed; } #language-dropdown, #voice-dropdown { opacity: 0.45; pointer-events: none; cursor: not-allowed; } #generate-cards-btn { opacity: 0.45; pointer-events: none; cursor: not-allowed; }</style>""", # phase_css
|
| 122 |
+
gr.Button(visible=True, interactive=False), # export_btn (always visible, disabled until Phase 2)
|
| 123 |
+
gr.File(value=None, visible=False), # export_file
|
| 124 |
)
|
| 125 |
|
| 126 |
|
|
|
|
| 235 |
|
| 236 |
progress_html = gr.HTML(label="Progress")
|
| 237 |
|
| 238 |
+
# Export area: regular button to trigger export + File component for download
|
| 239 |
+
with gr.Row():
|
| 240 |
+
export_btn = gr.Button(
|
| 241 |
+
"📥 Export CSV + Media",
|
| 242 |
+
variant="primary",
|
| 243 |
+
visible=True,
|
| 244 |
+
interactive=False,
|
| 245 |
+
elem_id="export-btn",
|
| 246 |
+
)
|
| 247 |
+
export_file = gr.File(
|
| 248 |
+
label="Download", file_types=[".zip"], visible=False
|
| 249 |
)
|
| 250 |
|
| 251 |
gr.Column(scale=1)
|
|
|
|
| 282 |
yield result
|
| 283 |
|
| 284 |
def _on_media_generation_complete():
|
| 285 |
+
"""After Phase 2 completes: hide generate buttons, enable export button.
|
| 286 |
|
| 287 |
+
Export button is always visible but becomes interactive only after
|
| 288 |
+
Phase 2 completes (when _current_cards is populated).
|
| 289 |
"""
|
| 290 |
import gradio as gr
|
| 291 |
return (
|
| 292 |
gr.Button(visible=False), # generate_text_btn
|
| 293 |
gr.Button(visible=False), # generate_cards_btn
|
| 294 |
+
gr.Button(visible=True, interactive=True), # export_btn (enable)
|
| 295 |
+
gr.File(value=None, visible=False), # export_file (cleared)
|
| 296 |
)
|
| 297 |
|
| 298 |
generate_text_btn.click(
|
|
|
|
| 302 |
).then(
|
| 303 |
fn=_enable_phase2,
|
| 304 |
inputs=[],
|
| 305 |
+
outputs=[images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, export_btn, phase_css],
|
| 306 |
)
|
| 307 |
|
| 308 |
# When audio toggle changes: show/hide voice dropdown and manage disabled CSS
|
|
|
|
| 328 |
).then(
|
| 329 |
fn=_on_media_generation_complete,
|
| 330 |
inputs=[],
|
| 331 |
+
outputs=[generate_text_btn, generate_cards_btn, export_btn, export_file],
|
| 332 |
)
|
| 333 |
|
| 334 |
# Reset toggles and both buttons when user changes any input parameter
|
| 335 |
+
scenario_input.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css, export_btn, export_file])
|
| 336 |
+
cefr_dropdown.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, phase_css, export_btn, export_file])
|
| 337 |
+
batch_slider.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css, export_btn, export_file])
|
| 338 |
# Language change does NOT reset — user can switch languages freely after Phase 1
|
| 339 |
|
| 340 |
# ─── Export Event Wiring ──────────────────────────────────────
|
| 341 |
|
| 342 |
def _handle_export_csv_event(scenario: str, cefr_level: str, target_language: str):
|
| 343 |
+
"""Export current cards as CSV + media zip.
|
| 344 |
|
| 345 |
+
Sets the generated zip file path as the value of export_file component,
|
| 346 |
+
which Gradio renders as a downloadable file link (bypassing DownloadButton's
|
| 347 |
+
FileResponse Content-Length bug with h11).
|
| 348 |
"""
|
| 349 |
from frontend.ui.cards import generate_progress_html
|
| 350 |
|
| 351 |
if not _app_module._current_cards:
|
| 352 |
+
return generate_progress_html(0, "\u26a0\ufe0f No cards to export."), None, gr.File(visible=False)
|
| 353 |
|
| 354 |
try:
|
| 355 |
zip_path = _app_module._handle_export_csv(scenario, cefr_level, target_language)
|
| 356 |
if zip_path is None:
|
| 357 |
+
return generate_progress_html(0, "\u26a0\ufe0f Export failed."), None, gr.File(visible=False)
|
| 358 |
+
# Show the file for download — gr.File component renders it as a clickable link
|
| 359 |
+
return generate_progress_html(100, "Export complete! Click the file below to download."), zip_path, gr.File(visible=True)
|
| 360 |
except Exception as e:
|
| 361 |
logger = logging.getLogger(__name__)
|
| 362 |
logger.error("CSV export failed: %s", e, exc_info=True)
|
| 363 |
+
return generate_progress_html(0, f"\u26a0\ufe0f Export failed: {e}"), None, gr.File(visible=False)
|
| 364 |
|
| 365 |
+
# Export button click — generates zip and shows it in gr.File for download
|
| 366 |
+
export_btn.click(
|
| 367 |
fn=_handle_export_csv_event,
|
| 368 |
inputs=[scenario_input, cefr_dropdown, language_dropdown],
|
| 369 |
+
outputs=[progress_html, export_file, export_file],
|
| 370 |
)
|
| 371 |
|
| 372 |
return demo
|
tests/conftest.py
CHANGED
|
@@ -2,6 +2,29 @@
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import pytest
|
| 6 |
|
| 7 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
# ─── Patch Starlette FileResponse to skip Content-Length ─────────────
|
| 6 |
+
# Prevents h11 "Too little data for declared Content-Length" errors during tests.
|
| 7 |
+
# Mirrors the patch in app.py that applies at server startup.
|
| 8 |
+
try:
|
| 9 |
+
from starlette.responses import FileResponse as _FileResponseBase
|
| 10 |
+
import starlette.responses as _sr_mod
|
| 11 |
+
|
| 12 |
+
class _NoContentLengthFileResponse(_FileResponseBase):
|
| 13 |
+
"""FileResponse that never sets Content-Length to avoid h11 bugs."""
|
| 14 |
+
|
| 15 |
+
def set_stat_headers(self, stat_result):
|
| 16 |
+
"""Override to skip setting Content-Length (keeps last-modified and etag)."""
|
| 17 |
+
last_modified = _sr_mod.formatdate(stat_result.st_mtime, usegmt=True)
|
| 18 |
+
etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size)
|
| 19 |
+
import hashlib
|
| 20 |
+
etag = '"' + hashlib.md5(etag_base.encode(), usedforsecurity=False).hexdigest() + '"'
|
| 21 |
+
self.headers.setdefault("last-modified", last_modified)
|
| 22 |
+
self.headers.setdefault("etag", etag)
|
| 23 |
+
|
| 24 |
+
_sr_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment]
|
| 25 |
+
except Exception:
|
| 26 |
+
pass # Non-critical for tests
|
| 27 |
+
|
| 28 |
import pytest
|
| 29 |
|
| 30 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
tests/file_response_patch_test.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test that the FileResponse Content-Length patch is applied correctly."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_fileresponse_no_content_length_with_stat_result():
|
| 8 |
+
"""FileResponse should not set Content-Length even when stat_result is provided."""
|
| 9 |
+
from starlette.responses import FileResponse
|
| 10 |
+
|
| 11 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
|
| 12 |
+
f.write(b"x" * 1024)
|
| 13 |
+
tmp = f.name
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
stat_result = os.stat(tmp)
|
| 17 |
+
fr = FileResponse(tmp, filename="test.zip", stat_result=stat_result)
|
| 18 |
+
headers_lower = {k.lower(): v for k, v in fr.headers.items()}
|
| 19 |
+
assert "content-length" not in headers_lower, (
|
| 20 |
+
f"FileResponse should not set Content-Length. Headers: {fr.headers}"
|
| 21 |
+
)
|
| 22 |
+
# But last-modified and etag should still be set
|
| 23 |
+
assert "last-modified" in headers_lower
|
| 24 |
+
assert "etag" in headers_lower
|
| 25 |
+
finally:
|
| 26 |
+
os.unlink(tmp)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_fileresponse_no_content_length_without_stat_result():
|
| 30 |
+
"""FileResponse should not set Content-Length when stat_result is None."""
|
| 31 |
+
from starlette.responses import FileResponse
|
| 32 |
+
|
| 33 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
|
| 34 |
+
f.write(b"x" * 512)
|
| 35 |
+
tmp = f.name
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
fr = FileResponse(tmp, filename="test2.zip", stat_result=None)
|
| 39 |
+
headers_lower = {k.lower(): v for k, v in fr.headers.items()}
|
| 40 |
+
assert "content-length" not in headers_lower, (
|
| 41 |
+
f"FileResponse should not set Content-Length. Headers: {fr.headers}"
|
| 42 |
+
)
|
| 43 |
+
finally:
|
| 44 |
+
os.unlink(tmp)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_gradio_modules_use_patched_fileresponse():
|
| 48 |
+
"""Gradio's route_utils and routes modules should reference the patched FileResponse."""
|
| 49 |
+
import gradio.route_utils as ru
|
| 50 |
+
import gradio.routes as rt
|
| 51 |
+
|
| 52 |
+
from starlette.responses import FileResponse
|
| 53 |
+
|
| 54 |
+
if hasattr(ru, "FileResponse"):
|
| 55 |
+
assert ru.FileResponse is FileResponse, (
|
| 56 |
+
f"route_utils.FileResponse is not the patched class: {ru.FileResponse}"
|
| 57 |
+
)
|
| 58 |
+
if hasattr(rt, "FileResponse"):
|
| 59 |
+
assert rt.FileResponse is FileResponse, (
|
| 60 |
+
f"routes.FileResponse is not the patched class: {rt.FileResponse}"
|
| 61 |
+
)
|
tests/widgets_test.py
CHANGED
|
@@ -93,30 +93,31 @@ def test_voice_map_instruct_strings_format():
|
|
| 93 |
|
| 94 |
|
| 95 |
def test_enable_phase2_returns_tuple(_mock_gradio):
|
| 96 |
-
"""_enable_phase2() returns tuple of (Checkbox, Checkbox, Button, Dropdown, CSS).
|
| 97 |
|
| 98 |
-
Export
|
| 99 |
(when _current_cards is populated) via _on_media_generation_complete().
|
| 100 |
"""
|
| 101 |
from frontend.ui.widgets import _enable_phase2
|
| 102 |
|
| 103 |
result = _enable_phase2()
|
| 104 |
assert isinstance(result, tuple)
|
| 105 |
-
assert len(result) ==
|
| 106 |
|
| 107 |
|
| 108 |
def test_reset_to_idle_returns_tuple(_mock_gradio):
|
| 109 |
-
"""_reset_to_idle() returns tuple with interactive=False, disabled CSS string,
|
| 110 |
from frontend.ui.widgets import _reset_to_idle
|
| 111 |
|
| 112 |
result = _reset_to_idle()
|
| 113 |
assert isinstance(result, tuple)
|
| 114 |
-
assert len(result) ==
|
| 115 |
# Element at index 5 should be a CSS string (non-empty)
|
| 116 |
assert isinstance(result[5], str)
|
| 117 |
assert len(result[5]) > 0
|
| 118 |
-
#
|
| 119 |
-
assert isinstance(result[6], MagicMock)
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def test_reset_to_idle_disabled_css_content(_mock_gradio):
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
def test_enable_phase2_returns_tuple(_mock_gradio):
|
| 96 |
+
"""_enable_phase2() returns tuple of (Checkbox, Checkbox, Button, Dropdown, Button, CSS).
|
| 97 |
|
| 98 |
+
Export button is VISIBLE but DISABLED here — it is enabled after Phase 2 completes
|
| 99 |
(when _current_cards is populated) via _on_media_generation_complete().
|
| 100 |
"""
|
| 101 |
from frontend.ui.widgets import _enable_phase2
|
| 102 |
|
| 103 |
result = _enable_phase2()
|
| 104 |
assert isinstance(result, tuple)
|
| 105 |
+
assert len(result) == 6
|
| 106 |
|
| 107 |
|
| 108 |
def test_reset_to_idle_returns_tuple(_mock_gradio):
|
| 109 |
+
"""_reset_to_idle() returns tuple with interactive=False, disabled CSS string, export button, and file component."""
|
| 110 |
from frontend.ui.widgets import _reset_to_idle
|
| 111 |
|
| 112 |
result = _reset_to_idle()
|
| 113 |
assert isinstance(result, tuple)
|
| 114 |
+
assert len(result) == 8
|
| 115 |
# Element at index 5 should be a CSS string (non-empty)
|
| 116 |
assert isinstance(result[5], str)
|
| 117 |
assert len(result[5]) > 0
|
| 118 |
+
# Elements at indices 6, 7 should be the export button and file component mocks
|
| 119 |
+
assert isinstance(result[6], MagicMock) # export_btn
|
| 120 |
+
assert isinstance(result[7], MagicMock) # export_file
|
| 121 |
|
| 122 |
|
| 123 |
def test_reset_to_idle_disabled_css_content(_mock_gradio):
|