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 <module>
        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) <[email protected]>
This commit is contained in:
Serge Simono
2026-10-04 00:01:11 +00:00
committed by capy-ai-staging[bot]
co-authored by Claude Opus 5
parent db2e7797b9
commit 3e240a9ef1
6 changed files with 27 additions and 22 deletions
+2 -12
View File
@@ -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
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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()
@@ -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
+19
View File
@@ -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}"