From cd59e2eab8803c1d8cb33fb4e61f85024414cc52 Mon Sep 17 00:00:00 2001 From: higkoo Date: Mon, 3 Aug 2026 19:17:26 +0800 Subject: [PATCH 1/2] fix: compatibility with Starlette>=1.0 and container/CI environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 FunClip 在以下环境中启动后访问页面报 500 错误: | 依赖 | 版本 | |------|------| | gradio | 4.44.1 | | starlette | 1.3.1 | | fastapi | 0.141.1 | | jinja2 | 3.1.6 | ## 问题 ### 问题 1:`TypeError: unhashable type: 'dict'` Jinja2 的 LRUCache 在缓存模板时,cache key 因 Starlette/Jinja2 版本矩阵 不兼容而变为不可哈希的 dict,导致模板渲染阶段直接崩溃。 ### 问题 2:`AttributeError: 'dict' object has no attribute 'split'`(核心问题) Starlette >= 1.0 破坏性变更了 `Jinja2Templates.TemplateResponse` 的签名: - 旧签名:`TemplateResponse(name, context, ...)` - 新签名:`TemplateResponse(request, name, context, ...)` Gradio 4.44.x 仍按旧签名调用,导致 context dict 被当作模板名传入 `split("/")`,触发 AttributeError。 ### 问题 3:`ValueError: When localhost is not accessible` 容器/无头环境中 Gradio 的 localhost 自检(HTTP HEAD)失败,直接抛异常 阻止启动。 ## 修复 所有修复集中在 `funclip/launch.py`,不改动第三方库源码: 1. **禁用 Jinja2 模板缓存**:`templates.env.cache = None`, 规避不可哈希 key 问题。 2. **猴子补丁 TemplateResponse**:通过首参类型自动判定新旧签名, 对旧式 `(name, context)` 调用做参数适配后转发给新版 Starlette, 实现 backward-compatible。 3. **启动容错**:传入 `_frontend=False` 跳过 localhost 自检; 增加 `ValueError` 捕获,检测失败时自动降级为 `share=True`。 ## 测试验证 | 路径 | 状态码 | 说明 | |------|--------|------| | `/` | 200 | 81455 bytes HTML,含 `gradio_config` | | `/info` | 200 | API 元数据正常 | | `/config` | 200 | UI 配置正常 | 启动命令:`python funclip/launch.py -p 12235 --listen` --- funclip/launch.py | 65 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/funclip/launch.py b/funclip/launch.py index 36ce753..278ac3f 100644 --- a/funclip/launch.py +++ b/funclip/launch.py @@ -20,6 +20,57 @@ from utils.trans_utils import extract_timestamps from introduction import top_md_1, top_md_3, top_md_4 +# Workaround: Jinja2 LRU cache key becomes unhashable (dict) due to +# Starlette/FastAPI/Jinja2 version mismatch. Disabling the Jinja2 template +# cache avoids "TypeError: unhashable type: 'dict'" when rendering the +# Gradio frontend index.html template via starlette Jinja2Templates. +# +# Workaround 2: Starlette >= 1.0 changed the Jinja2Templates.TemplateResponse +# signature from (name, context) to (request, name, context). Gradio 4.44.1 +# still uses the old signature, causing AttributeError: 'dict' has no +# attribute 'split'. Wrap TemplateResponse to accept both call styles. +try: + import gradio.routes as _routes + from starlette.requests import Request as _Request + + if hasattr(_routes, "templates") and hasattr(_routes.templates, "env"): + _routes.templates.env.cache = None + _routes.templates.env.cache_size = 0 + + # Monkey-patch TemplateResponse to support both signatures + _original_tr = _routes.templates.TemplateResponse + + def _compatible_template_response( + *args, + **kwargs, + ): + # Try to detect call style + if len(args) >= 1 and isinstance(args[0], _Request): + # New Starlette 1.x signature: (request, name, context, ...) + return _original_tr(*args, **kwargs) + if len(args) >= 1 and isinstance(args[0], (str, bytes, os.PathLike)): + # Old Gradio signature: (name, context, ...) - extract request from context + name = args[0] + context = args[1] if len(args) > 1 else kwargs.get("context") or {} + request = context.get("request") if isinstance(context, dict) else kwargs.get("request") + status_code = kwargs.get("status_code") or (args[2] if len(args) > 2 else 200) + headers = kwargs.get("headers") + media_type = kwargs.get("media_type") + background = kwargs.get("background") + return _original_tr( + request, name, context, + status_code=status_code, + headers=headers, + media_type=media_type, + background=background, + ) + # Fallback: try original + return _original_tr(*args, **kwargs) + + _routes.templates.TemplateResponse = _compatible_template_response +except Exception: # pragma: no cover - defensive, never break launch on this + pass + def create_asr_model(model_name, lang, auto_model_cls=AutoModel): if model_name == "fun-asr-nano": @@ -374,7 +425,15 @@ def AI_clip_subti(LLM_res, dest_text, video_spk_input, start_ost, end_ost, video outputs=[video_output, audio_output, clip_message, srt_clipped]) # start gradio service in local or share + launch_kwargs = dict(share=args.share, server_port=args.port, server_name=server_name, show_error=True) if args.listen: - funclip_service.launch(share=args.share, server_port=args.port, server_name=server_name, inbrowser=False) - else: - funclip_service.launch(share=args.share, server_port=args.port, server_name=server_name) + launch_kwargs["inbrowser"] = False + try: + funclip_service.launch(**launch_kwargs, _frontend=False) + except ValueError as e: + if "localhost is not accessible" in str(e): + print("⚠️ Localhost is not accessible. Retrying with share=True...") + launch_kwargs["share"] = True + funclip_service.launch(**launch_kwargs) + else: + raise From 54133848b59d23351d0b2f7732387b0386db1d9d Mon Sep 17 00:00:00 2001 From: zhifu gao Date: Mon, 3 Aug 2026 12:59:00 +0000 Subject: [PATCH 2/2] fix: make Gradio runtime compatibility safe Signed-off-by: zhifu gao --- .github/workflows/tests.yml | 37 +++++++++++++ funclip/launch.py | 72 ++----------------------- funclip/launch_config.py | 16 ++++++ requirements.txt | 1 + tests/test_gradio_runtime_compat.py | 84 +++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 funclip/launch_config.py create mode 100644 tests/test_gradio_runtime_compat.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..480e545 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gradio-runtime: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install Gradio runtime test dependencies + run: | + python -m pip install --disable-pip-version-check \ + pytest==8.3.5 \ + "gradio==4.44.1" \ + "huggingface_hub<1.0" \ + "starlette<1.0" + python -m pip check + + - name: Test Gradio runtime compatibility + run: python -m pytest -q tests/test_gradio_runtime_compat.py diff --git a/funclip/launch.py b/funclip/launch.py index 278ac3f..4ffdf00 100644 --- a/funclip/launch.py +++ b/funclip/launch.py @@ -19,57 +19,7 @@ from llm.twelvelabs_api import call_twelvelabs_pegasus from utils.trans_utils import extract_timestamps from introduction import top_md_1, top_md_3, top_md_4 - -# Workaround: Jinja2 LRU cache key becomes unhashable (dict) due to -# Starlette/FastAPI/Jinja2 version mismatch. Disabling the Jinja2 template -# cache avoids "TypeError: unhashable type: 'dict'" when rendering the -# Gradio frontend index.html template via starlette Jinja2Templates. -# -# Workaround 2: Starlette >= 1.0 changed the Jinja2Templates.TemplateResponse -# signature from (name, context) to (request, name, context). Gradio 4.44.1 -# still uses the old signature, causing AttributeError: 'dict' has no -# attribute 'split'. Wrap TemplateResponse to accept both call styles. -try: - import gradio.routes as _routes - from starlette.requests import Request as _Request - - if hasattr(_routes, "templates") and hasattr(_routes.templates, "env"): - _routes.templates.env.cache = None - _routes.templates.env.cache_size = 0 - - # Monkey-patch TemplateResponse to support both signatures - _original_tr = _routes.templates.TemplateResponse - - def _compatible_template_response( - *args, - **kwargs, - ): - # Try to detect call style - if len(args) >= 1 and isinstance(args[0], _Request): - # New Starlette 1.x signature: (request, name, context, ...) - return _original_tr(*args, **kwargs) - if len(args) >= 1 and isinstance(args[0], (str, bytes, os.PathLike)): - # Old Gradio signature: (name, context, ...) - extract request from context - name = args[0] - context = args[1] if len(args) > 1 else kwargs.get("context") or {} - request = context.get("request") if isinstance(context, dict) else kwargs.get("request") - status_code = kwargs.get("status_code") or (args[2] if len(args) > 2 else 200) - headers = kwargs.get("headers") - media_type = kwargs.get("media_type") - background = kwargs.get("background") - return _original_tr( - request, name, context, - status_code=status_code, - headers=headers, - media_type=media_type, - background=background, - ) - # Fallback: try original - return _original_tr(*args, **kwargs) - - _routes.templates.TemplateResponse = _compatible_template_response -except Exception: # pragma: no cover - defensive, never break launch on this - pass +from launch_config import build_launch_kwargs def create_asr_model(model_name, lang, auto_model_cls=AutoModel): @@ -117,10 +67,6 @@ def create_asr_model(model_name, lang, auto_model_cls=AutoModel): audio_clipper = VideoClipper(funasr_model) audio_clipper.lang = args.lang - server_name='127.0.0.1' - if args.listen: - server_name = '0.0.0.0' - def save_text_to_file(content, extension, output_dir=None): if not content: return None @@ -424,16 +370,6 @@ def AI_clip_subti(LLM_res, dest_text, video_spk_input, start_ost, end_ost, video ], outputs=[video_output, audio_output, clip_message, srt_clipped]) - # start gradio service in local or share - launch_kwargs = dict(share=args.share, server_port=args.port, server_name=server_name, show_error=True) - if args.listen: - launch_kwargs["inbrowser"] = False - try: - funclip_service.launch(**launch_kwargs, _frontend=False) - except ValueError as e: - if "localhost is not accessible" in str(e): - print("⚠️ Localhost is not accessible. Retrying with share=True...") - launch_kwargs["share"] = True - funclip_service.launch(**launch_kwargs) - else: - raise + funclip_service.launch( + **build_launch_kwargs(share=args.share, port=args.port, listen=args.listen) + ) diff --git a/funclip/launch_config.py b/funclip/launch_config.py new file mode 100644 index 0000000..bc2ef4e --- /dev/null +++ b/funclip/launch_config.py @@ -0,0 +1,16 @@ +"""Launch policy for the FunClip Gradio service.""" + + +def build_launch_kwargs(*, share, port, listen): + kwargs = { + "share": share, + "server_port": port, + "server_name": "127.0.0.1", + } + if listen: + kwargs.update( + server_name="0.0.0.0", + inbrowser=False, + _frontend=False, + ) + return kwargs diff --git a/requirements.txt b/requirements.txt index 27b4d7f..d479796 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ huggingface_hub>=0.19.3,<1.0 moviepy==1.0.3 numpy==1.26.4 gradio>=4.31.3,<5.0 +starlette<1.0 modelscope torch>=1.13 torchaudio diff --git a/tests/test_gradio_runtime_compat.py b/tests/test_gradio_runtime_compat.py new file mode 100644 index 0000000..977122f --- /dev/null +++ b/tests/test_gradio_runtime_compat.py @@ -0,0 +1,84 @@ +"""Regression tests for the supported Gradio/Starlette launch contract.""" + +import ast +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LAUNCH_PATH = ROOT / "funclip" / "launch.py" + + +def test_gradio4_excludes_breaking_starlette_releases(): + requirements = { + line.strip() + for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + assert "gradio>=4.31.3,<5.0" in requirements + assert "starlette<1.0" in requirements + + +def test_supported_gradio_stack_renders_index(): + import gradio + from starlette.testclient import TestClient + + with gradio.Blocks() as demo: + gradio.Markdown("FunClip runtime smoke test") + + app = gradio.routes.App.create_app(demo) + response = TestClient(app).get("/") + + assert response.status_code == 200 + assert "gradio_config" in response.text + + +def test_local_launch_keeps_gradio_frontend_probe_enabled(): + from funclip.launch_config import build_launch_kwargs + + assert build_launch_kwargs(share=False, port=7860, listen=False) == { + "share": False, + "server_port": 7860, + "server_name": "127.0.0.1", + } + + +def test_explicit_listen_skips_only_the_local_frontend_probe(): + from funclip.launch_config import build_launch_kwargs + + assert build_launch_kwargs(share=False, port=12235, listen=True) == { + "share": False, + "server_port": 12235, + "server_name": "0.0.0.0", + "inbrowser": False, + "_frontend": False, + } + + +def test_explicit_share_choice_is_preserved(): + from funclip.launch_config import build_launch_kwargs + + kwargs = build_launch_kwargs(share=True, port=7860, listen=True) + + assert kwargs["share"] is True + + +def test_launcher_does_not_patch_dependencies_or_retry_with_public_share(): + tree = ast.parse(LAUNCH_PATH.read_text(encoding="utf-8")) + launch_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "launch" + ] + patched_attributes = [ + target.attr + for node in ast.walk(tree) + if isinstance(node, (ast.Assign, ast.AnnAssign)) + for target in (node.targets if isinstance(node, ast.Assign) else [node.target]) + if isinstance(target, ast.Attribute) + and target.attr in {"TemplateResponse", "cache", "cache_size"} + ] + + assert len(launch_calls) == 1 + assert patched_attributes == []