From 3e240a9ef199d0ed0df2cfa4f922d554afcac699 Mon Sep 17 00:00:00 2001 From: Serge Simono Date: Mon, 17 Aug 2026 05:42:21 +0200 Subject: [PATCH] fix(backend): break the app<->routes import cycle that aborts the test suite Collecting backend/tests/ fails outright on a clean checkout: backend/tests/test_profile_duplicate_names.py:19: in from database import Base, VoiceProfile as DBVoiceProfile E ImportError: attempted relative import beyond top-level package pytest stops at the collection error, so the whole backend suite runs zero tests rather than the ~142 it otherwise would. There are two causes stacked on top of each other. First, a real cycle in production code. routes/profiles.py, routes/history.py and routes/stories.py each import safe_content_disposition from ..app, while app.py builds the FastAPI instance at module scope (app = create_app() on import), which registers those same routers. Importing any of those three route modules first therefore re-enters a partially initialised app and dies with "cannot import name 'router' from partially initialized module". It only works today because app.py always happens to be imported first. safe_content_disposition is a pure helper over urllib.parse.quote with no application state, so it moves to backend/utils/http.py. app.py re-exports it so any external caller importing it from the old location keeps working. Second, the test reached for modules through a sys.path hack (sys.path.insert(parent) + "from database import ...") rather than the "from backend.X import ..." style the rest of the suite uses. That flat import makes database/models.py's "from ..utils.capture_chords import ..." point outside the package. It also aimed at the wrong module: it wants the service layer, which raises ValueError, not the route handler, which converts that into an HTTPException. Result: the full suite goes from 0 collected to 148 passed. The one remaining failure, test_progress.py::test_hf_progress_tracker, is pre-existing and unrelated (tqdm patching) - it reproduces identically on an unpatched tree. Co-Authored-By: Claude Opus 5 (1M context) --- backend/app.py | 14 ++------------ backend/routes/history.py | 2 +- backend/routes/profiles.py | 2 +- backend/routes/stories.py | 2 +- backend/tests/test_profile_duplicate_names.py | 10 +++------- backend/utils/http.py | 19 +++++++++++++++++++ 6 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 backend/utils/http.py diff --git a/backend/app.py b/backend/app.py index ea8411cb..c7a4029b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -105,28 +105,18 @@ if not os.environ.get("MIOPEN_LOG_LEVEL"): import torch from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from urllib.parse import quote from . import __version__, config, database from .services import tts, transcribe, llm from .database import get_db from .utils.platform_detect import get_backend_type from .utils.progress import get_progress_manager +# Re-exported for backwards compatibility with callers importing it from here. +from .utils.http import safe_content_disposition as safe_content_disposition from .services.task_queue import create_background_task, init_queue from .routes import register_routers -def safe_content_disposition(disposition_type: str, filename: str) -> str: - """Build a Content-Disposition header safe for non-ASCII filenames. - - Uses RFC 5987 ``filename*`` parameter so browsers can decode UTF-8 - filenames while the ``filename`` fallback stays ASCII-only. - """ - ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download" - utf8_name = quote(filename, safe="") - return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}" - - def create_app() -> FastAPI: """Create and configure the FastAPI application.""" from .mcp_server.server import build_mcp_server, compose_lifespan diff --git a/backend/routes/history.py b/backend/routes/history.py index 694d35be..4b7e2222 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from .. import config, models from ..services import export_import, history -from ..app import safe_content_disposition +from ..utils.http import safe_content_disposition from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db router = APIRouter() diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 68e5f2af..b5658dea 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -12,7 +12,7 @@ from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session from .. import config, models -from ..app import safe_content_disposition +from ..utils.http import safe_content_disposition from ..database import VoiceProfile as DBVoiceProfile, get_db from ..services import channels, export_import, personality, profiles from ..services.profiles import _profile_to_response diff --git a/backend/routes/stories.py b/backend/routes/stories.py index 73757d34..0c6237a8 100644 --- a/backend/routes/stories.py +++ b/backend/routes/stories.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from .. import database, models from ..services import stories -from ..app import safe_content_disposition +from ..utils.http import safe_content_disposition from ..database import get_db router = APIRouter() diff --git a/backend/tests/test_profile_duplicate_names.py b/backend/tests/test_profile_duplicate_names.py index 55ee8587..1a9b5672 100644 --- a/backend/tests/test_profile_duplicate_names.py +++ b/backend/tests/test_profile_duplicate_names.py @@ -12,13 +12,9 @@ from pathlib import Path from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -# Add parent directory to path to import backend modules -import sys -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from database import Base, VoiceProfile as DBVoiceProfile -from models import VoiceProfileCreate -from profiles import create_profile, update_profile +from backend.database import Base, VoiceProfile as DBVoiceProfile +from backend.models import VoiceProfileCreate +from backend.services.profiles import create_profile, update_profile @pytest.fixture diff --git a/backend/utils/http.py b/backend/utils/http.py new file mode 100644 index 00000000..d29a72bb --- /dev/null +++ b/backend/utils/http.py @@ -0,0 +1,19 @@ +"""HTTP header helpers shared by the download routes. + +Lives here rather than in ``app`` so route modules can use it without +importing the application module — ``app`` builds the FastAPI instance at +import time, which registers those same routers and closes an import cycle. +""" + +from urllib.parse import quote + + +def safe_content_disposition(disposition_type: str, filename: str) -> str: + """Build a Content-Disposition header safe for non-ASCII filenames. + + Uses RFC 5987 ``filename*`` parameter so browsers can decode UTF-8 + filenames while the ``filename`` fallback stays ASCII-only. + """ + ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download" + utf8_name = quote(filename, safe="") + return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}"