Takosaga commited on
Commit
dd2c103
·
1 Parent(s): 869f490

fix: exporting files

Browse files
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
- Download CSV button remains HIDDEN until Phase 2 completes (when _current_cards is populated).
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
- Hides download CSV button until Phase 2 completes.
108
 
109
  Returns:
110
  Tuple of (generate_text_btn, images_toggle, audio_toggle, generate_cards_btn,
111
- voice_dropdown, phase_css, download_csv_btn) updates.
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.DownloadButton(visible=False), # download_csv_btn
 
122
  )
123
 
124
 
@@ -233,9 +235,17 @@ def build_ui() -> "gr.Blocks":
233
 
234
  progress_html = gr.HTML(label="Progress")
235
 
236
- # Download CSV button shown after Phase 2 completes (when _current_cards is populated)
237
- download_csv_btn = gr.DownloadButton(
238
- "📥 Download CSV + Media", visible=False, elem_id="download-csv-btn"
 
 
 
 
 
 
 
 
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, show download CSV button.
276
 
277
- Download CSV button is shown here (not after Phase 1) because _current_cards
278
- is only populated at the end of generate_media_async().
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.DownloadButton(visible=True), # download_csv_btn
 
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, download_csv_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, download_csv_btn])
325
- cefr_dropdown.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, phase_css, download_csv_btn])
326
- batch_slider.change(_reset_to_idle, inputs=[], outputs=[generate_text_btn, images_toggle, audio_toggle, generate_cards_btn, voice_dropdown, phase_css, download_csv_btn])
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 and trigger browser download.
333
 
334
- Returns (progress_html, zip_path) directly so Gradio's DownloadButton
335
- receives the file path on first click and triggers the save dialog immediately.
 
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
- return generate_progress_html(100, "Export complete!"), zip_path
 
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
- # Download CSV button click — triggers browser download of zip file
353
- download_csv_btn.click(
354
  fn=_handle_export_csv_event,
355
  inputs=[scenario_input, cefr_dropdown, language_dropdown],
356
- outputs=[progress_html, download_csv_btn],
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 buttons are NOT enabled here — they are 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) == 5
106
 
107
 
108
  def test_reset_to_idle_returns_tuple(_mock_gradio):
109
- """_reset_to_idle() returns tuple with interactive=False, disabled CSS string, and download CSV button."""
110
  from frontend.ui.widgets import _reset_to_idle
111
 
112
  result = _reset_to_idle()
113
  assert isinstance(result, tuple)
114
- assert len(result) == 7
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
- # Last element should be the download CSV button mock
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):