mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 07:10:40 -07:00
chore(backend): repair test suite and bring ruff to green
The suite hadn't run green since the routes refactor: - test_profile_duplicate_names.py imported the pre-refactor module layout and broke collection; now imports backend.services.profiles - tests/conftest.py puts the repo root and backend dir on sys.path so files collect standalone instead of depending on run order - test_cors.py tested a hand-copied mirror of the origin list that had drifted from app.py (missing http://tauri.localhost); it now builds the app via the real create_app() factory - test_progress.py simulated a 1KB download, below the tracker's 1MB reporting threshold; simulation raised to 5MB - slow/timeout markers registered in pyproject Ruff: ~900 violations auto-fixed (typing modernization, import sorting, unused imports, whitespace). The remaining rules are baselined in pyproject.toml with per-rule counts to burn down, plus per-file carve-outs for deliberate env-before-import ordering. ruff check is now clean; suite is 134 passed, 2 skipped.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Shared test setup.
|
||||
|
||||
The suite mixes flat imports (``from utils.progress import ...``) with
|
||||
package imports (``from backend import config``). Both the repo root and
|
||||
the backend directory go on ``sys.path`` here so every test file collects
|
||||
on its own, regardless of which file loads first.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = BACKEND_DIR.parent
|
||||
|
||||
for _path in (str(BACKEND_DIR), str(REPO_ROOT)):
|
||||
if _path not in sys.path:
|
||||
sys.path.insert(0, _path)
|
||||
@@ -25,14 +25,12 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
DIST_DIR = BACKEND_DIR / "dist"
|
||||
@@ -46,7 +44,7 @@ RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||||
class MatrixRow:
|
||||
label: str # human-readable (appears in report)
|
||||
engine: str # /generate engine
|
||||
model_size: Optional[str] # /generate model_size (None = omit)
|
||||
model_size: str | None # /generate model_size (None = omit)
|
||||
profile_kind: str # "cloned" | "preset_kokoro" | "preset_qwen_cv"
|
||||
model_name: str # /models/status key for cache lookup
|
||||
|
||||
@@ -76,22 +74,22 @@ HEALTH_TIMEOUT = 120
|
||||
class ModelResult:
|
||||
label: str
|
||||
engine: str
|
||||
model_size: Optional[str]
|
||||
model_size: str | None
|
||||
status: str # "passed" | "failed" | "timeout"
|
||||
was_cached: Optional[bool] = None
|
||||
generation_id: Optional[str] = None
|
||||
was_cached: bool | None = None
|
||||
generation_id: str | None = None
|
||||
elapsed_seconds: float = 0.0
|
||||
audio_duration: Optional[float] = None
|
||||
audio_path: Optional[str] = None
|
||||
audio_bytes: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
http_status: Optional[int] = None
|
||||
server_log_tail: Optional[list[str]] = None
|
||||
audio_duration: float | None = None
|
||||
audio_path: str | None = None
|
||||
audio_bytes: int | None = None
|
||||
error: str | None = None
|
||||
http_status: int | None = None
|
||||
server_log_tail: list[str] | None = None
|
||||
|
||||
|
||||
# ── Binary resolution ────────────────────────────────────────────────
|
||||
|
||||
def find_binary() -> Optional[Path]:
|
||||
def find_binary() -> Path | None:
|
||||
"""Return the first existing binary in priority order, or None."""
|
||||
is_win = platform.system() == "Windows"
|
||||
exe = ".exe" if is_win else ""
|
||||
@@ -129,9 +127,9 @@ class ServerProcess:
|
||||
self.port = port
|
||||
self.data_dir = data_dir
|
||||
self.log_path = log_path
|
||||
self.proc: Optional[subprocess.Popen] = None
|
||||
self.proc: subprocess.Popen | None = None
|
||||
self._log_buffer: deque[str] = deque(maxlen=500)
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
args = [
|
||||
@@ -227,7 +225,7 @@ def wait_for_health(base_url: str, server: ServerProcess, timeout: int) -> None:
|
||||
raise TimeoutError(f"Server did not become healthy within {timeout}s")
|
||||
|
||||
|
||||
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> Optional[bool]:
|
||||
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> bool | None:
|
||||
try:
|
||||
r = client.get(f"{base_url}/models/status", timeout=30.0)
|
||||
r.raise_for_status()
|
||||
@@ -331,7 +329,7 @@ def run_one_generation(
|
||||
|
||||
def fetch_audio_info(
|
||||
client: httpx.Client, base_url: str, generation_id: str, data_dir: Path
|
||||
) -> tuple[Optional[str], Optional[int]]:
|
||||
) -> tuple[str | None, int | None]:
|
||||
"""Return (audio_path, audio_bytes) for a completed generation.
|
||||
|
||||
Server stores audio_path relative to data_dir; resolve it to get a size.
|
||||
@@ -504,8 +502,8 @@ def main() -> int:
|
||||
|
||||
# Reference audio (only required if any cloning row is in the matrix)
|
||||
needs_reference = any(r.profile_kind == "cloned" for r in rows)
|
||||
ref_wav: Optional[Path] = None
|
||||
ref_text: Optional[str] = None
|
||||
ref_wav: Path | None = None
|
||||
ref_text: str | None = None
|
||||
if needs_reference:
|
||||
try:
|
||||
ref_wav, ref_text = resolve_reference(args)
|
||||
@@ -518,14 +516,14 @@ def main() -> int:
|
||||
# Tempdir + log path
|
||||
data_dir = Path(tempfile.mkdtemp(prefix="voicebox-e2e-"))
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
log_path = args.output_dir / f"server-{ts}.log"
|
||||
|
||||
port = args.port or pick_free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
server = ServerProcess(binary=binary, port=port, data_dir=data_dir, log_path=log_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
started_at = datetime.now(UTC)
|
||||
results: list[ModelResult] = []
|
||||
|
||||
try:
|
||||
@@ -536,9 +534,9 @@ def main() -> int:
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# Profile setup (only create what's needed)
|
||||
cloned_profile_id: Optional[str] = None
|
||||
kokoro_profile_id: Optional[str] = None
|
||||
qwen_cv_profile_id: Optional[str] = None
|
||||
cloned_profile_id: str | None = None
|
||||
kokoro_profile_id: str | None = None
|
||||
qwen_cv_profile_id: str | None = None
|
||||
needed_kinds = {r.profile_kind for r in rows}
|
||||
if "cloned" in needed_kinds:
|
||||
assert ref_wav is not None and ref_text is not None
|
||||
@@ -608,7 +606,7 @@ def main() -> int:
|
||||
+ (f" ({result.error})" if result.error else ""), flush=True)
|
||||
results.append(result)
|
||||
finally:
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
finished_at = datetime.now(UTC)
|
||||
server.stop()
|
||||
if not args.keep_data_dir:
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
|
||||
@@ -14,12 +14,11 @@ import soundfile as sf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.audio import ( # noqa: E402
|
||||
from utils.audio import (
|
||||
preprocess_reference_audio,
|
||||
validate_and_load_reference_audio,
|
||||
)
|
||||
|
||||
|
||||
SR = 24000
|
||||
|
||||
|
||||
|
||||
+26
-58
@@ -4,64 +4,34 @@ Tests for CORS origin restrictions.
|
||||
Validates that the CORS middleware only allows known local origins
|
||||
and respects the VOICEBOX_CORS_ORIGINS environment variable.
|
||||
|
||||
Uses a minimal FastAPI app that mirrors the exact CORS configuration
|
||||
from backend/main.py, so tests run without heavy ML dependencies.
|
||||
|
||||
Usage:
|
||||
pip install httpx pytest fastapi starlette
|
||||
python -m pytest backend/tests/test_cors.py -v
|
||||
Builds the app via the real ``backend.app.create_app`` factory so the
|
||||
tests exercise the actual CORS configuration rather than a copy of it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _build_app(env_origins: str = "") -> FastAPI:
|
||||
"""
|
||||
Build a minimal FastAPI app with the same CORS logic as backend/main.py.
|
||||
|
||||
This mirrors the exact code in main.py so the test validates the real
|
||||
configuration without needing torch/numpy/transformers installed.
|
||||
"""
|
||||
app = FastAPI()
|
||||
|
||||
_default_origins = [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:17493",
|
||||
"http://127.0.0.1:17493",
|
||||
"tauri://localhost",
|
||||
"https://tauri.localhost",
|
||||
]
|
||||
_cors_origins = _default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
from backend.app import create_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
return TestClient(_build_app())
|
||||
def _build_client(monkeypatch, env_origins: str | None = None) -> TestClient:
|
||||
if env_origins is None:
|
||||
monkeypatch.delenv("VOICEBOX_CORS_ORIGINS", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("VOICEBOX_CORS_ORIGINS", env_origins)
|
||||
# Plain TestClient (no context manager) skips lifespan startup, so no
|
||||
# model scans or queue workers run — only middleware is exercised.
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_with_custom_origins():
|
||||
return TestClient(_build_app("https://custom.example.com,https://other.example.com"))
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
return _build_client(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_custom_origins(monkeypatch):
|
||||
return _build_client(monkeypatch, "https://custom.example.com,https://other.example.com")
|
||||
|
||||
|
||||
def _get_with_origin(client: TestClient, origin: str) -> dict:
|
||||
@@ -92,6 +62,7 @@ class TestCORSDefaultOrigins:
|
||||
"http://127.0.0.1:17493",
|
||||
"tauri://localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://tauri.localhost",
|
||||
])
|
||||
def test_allowed_origins(self, client, origin):
|
||||
headers = _get_with_origin(client, origin)
|
||||
@@ -143,20 +114,17 @@ class TestCORSCustomOrigins:
|
||||
class TestCORSEnvVarParsing:
|
||||
"""Edge cases for VOICEBOX_CORS_ORIGINS parsing."""
|
||||
|
||||
def test_empty_env_var(self):
|
||||
app = _build_app("")
|
||||
client = TestClient(app)
|
||||
def test_empty_env_var(self, monkeypatch):
|
||||
client = _build_client(monkeypatch, "")
|
||||
headers = _get_with_origin(client, "http://evil.com")
|
||||
assert "access-control-allow-origin" not in headers
|
||||
|
||||
def test_whitespace_trimmed(self):
|
||||
app = _build_app(" https://spaced.example.com ")
|
||||
client = TestClient(app)
|
||||
def test_whitespace_trimmed(self, monkeypatch):
|
||||
client = _build_client(monkeypatch, " https://spaced.example.com ")
|
||||
headers = _get_with_origin(client, "https://spaced.example.com")
|
||||
assert headers.get("access-control-allow-origin") == "https://spaced.example.com"
|
||||
|
||||
def test_trailing_comma_ignored(self):
|
||||
app = _build_app("https://one.example.com,")
|
||||
client = TestClient(app)
|
||||
def test_trailing_comma_ignored(self, monkeypatch):
|
||||
client = _build_client(monkeypatch, "https://one.example.com,")
|
||||
headers = _get_with_origin(client, "https://one.example.com")
|
||||
assert headers.get("access-control-allow-origin") == "https://one.example.com"
|
||||
|
||||
@@ -7,14 +7,14 @@ the model is already cached.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
"""Monitor SSE stream for a model during generation."""
|
||||
events: List[Dict] = []
|
||||
events: list[dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||
@@ -54,7 +54,7 @@ async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(f"[{timestamp}] ♥ heartbeat")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] SSE error: {e}")
|
||||
@@ -91,15 +91,14 @@ async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B
|
||||
print(f" Generation ID: {result.get('id')}")
|
||||
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||
return True, result
|
||||
elif response.status_code == 202:
|
||||
if response.status_code == 202:
|
||||
# Model is being downloaded
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] → Model download in progress")
|
||||
print(f" Detail: {result}")
|
||||
return False, result
|
||||
else:
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||
|
||||
@@ -21,7 +21,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.hf_offline_patch import force_offline_if_cached # noqa: E402
|
||||
from utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
|
||||
def _hf_const():
|
||||
@@ -53,7 +53,7 @@ def test_mutates_cached_transformers_constant():
|
||||
def test_sets_env_variable():
|
||||
original = os.environ.get("HF_HUB_OFFLINE")
|
||||
with force_offline_if_cached(True, "t"):
|
||||
assert "1" == os.environ.get("HF_HUB_OFFLINE")
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
assert original == os.environ.get("HF_HUB_OFFLINE")
|
||||
|
||||
|
||||
@@ -88,14 +88,14 @@ def test_concurrent_threads_share_offline_window():
|
||||
barrier.wait(timeout=5)
|
||||
assert fast_exited.wait(timeout=5), "fast thread did not exit"
|
||||
observations.append(_hf_const().HF_HUB_OFFLINE)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
def fast():
|
||||
try:
|
||||
with force_offline_if_cached(True, "fast"):
|
||||
barrier.wait(timeout=5)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
finally:
|
||||
fast_exited.set()
|
||||
|
||||
@@ -18,10 +18,10 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from huggingface_hub.errors import OfflineModeIsEnabled # noqa: E402
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase # noqa: E402
|
||||
from huggingface_hub.errors import OfflineModeIsEnabled
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
|
||||
|
||||
import utils.hf_offline_patch as hf_offline_patch # noqa: E402
|
||||
import utils.hf_offline_patch as hf_offline_patch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -32,11 +32,9 @@ import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
@@ -126,13 +124,13 @@ class Scorecard:
|
||||
refined: str
|
||||
latency_ms: int
|
||||
length_chars: int = 0
|
||||
prompt_leak: Optional[str] = None
|
||||
refusal: Optional[str] = None
|
||||
prompt_leak: str | None = None
|
||||
refusal: str | None = None
|
||||
stage_directions: list[str] = field(default_factory=list)
|
||||
flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def first_match(patterns, text: str) -> Optional[str]:
|
||||
def first_match(patterns, text: str) -> str | None:
|
||||
s = text.lstrip()
|
||||
for pat in patterns:
|
||||
m = pat.search(s)
|
||||
@@ -186,7 +184,7 @@ known-shipping Kokoro voice so the throwaway profile satisfies the
|
||||
preset-engine validator on creation."""
|
||||
|
||||
|
||||
def detect_backend_port(hint: Optional[int]) -> int:
|
||||
def detect_backend_port(hint: int | None) -> int:
|
||||
candidates: list[int] = []
|
||||
if hint is not None:
|
||||
candidates.append(hint)
|
||||
|
||||
@@ -5,20 +5,17 @@ This test suite verifies that the application correctly handles
|
||||
duplicate profile names and provides user-friendly error messages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
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
|
||||
from backend.models import VoiceProfileCreate
|
||||
from backend.services.profiles import create_profile, update_profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -4,9 +4,8 @@ Test script to debug model download progress tracking.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
import time
|
||||
|
||||
# Set up logging to see what's happening
|
||||
logging.basicConfig(
|
||||
@@ -14,8 +13,8 @@ logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
|
||||
|
||||
def test_progress_manager_basic():
|
||||
@@ -61,7 +60,7 @@ async def test_progress_manager_sse():
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
collected_events: List[Dict] = []
|
||||
collected_events: list[dict] = []
|
||||
|
||||
# Simulate SSE client
|
||||
async def sse_client():
|
||||
@@ -123,7 +122,7 @@ def test_hf_progress_tracker():
|
||||
print("Test 3: HFProgressTracker tqdm Patching")
|
||||
print("=" * 60)
|
||||
|
||||
captured_progress: List[tuple] = []
|
||||
captured_progress: list[tuple] = []
|
||||
|
||||
def progress_callback(downloaded: int, total: int, filename: str):
|
||||
"""Capture progress updates."""
|
||||
@@ -137,12 +136,14 @@ def test_hf_progress_tracker():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate downloading a file
|
||||
# Simulate downloading a file. The tracker only reports once the
|
||||
# combined total crosses MIN_TOTAL_BYTES (1 MB), so the simulated
|
||||
# file must be larger than that.
|
||||
print(" Simulating download with tqdm...")
|
||||
total_size = 1000
|
||||
total_size = 5_000_000
|
||||
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||
for chunk in range(0, total_size, 100):
|
||||
pbar.update(100)
|
||||
for chunk in range(0, total_size, 500_000):
|
||||
pbar.update(500_000)
|
||||
time.sleep(0.01)
|
||||
|
||||
print(f" Captured {len(captured_progress)} progress updates")
|
||||
@@ -170,7 +171,7 @@ async def test_full_integration():
|
||||
print("=" * 60)
|
||||
|
||||
pm = get_progress_manager()
|
||||
collected_events: List[Dict] = []
|
||||
collected_events: list[dict] = []
|
||||
|
||||
# SSE client
|
||||
async def sse_client():
|
||||
@@ -244,9 +245,8 @@ async def test_full_integration():
|
||||
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||
print("✓ Test 4 PASSED\n")
|
||||
return True
|
||||
else:
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
@@ -15,26 +15,26 @@ Prerequisites:
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> list[dict]:
|
||||
"""
|
||||
Monitor SSE stream for a model download.
|
||||
|
||||
|
||||
Args:
|
||||
model_name: Name of the model to monitor
|
||||
timeout: Maximum time to wait for download (seconds)
|
||||
|
||||
|
||||
Returns:
|
||||
List of SSE events received
|
||||
"""
|
||||
events: List[Dict] = []
|
||||
events: list[dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
last_progress = -1
|
||||
|
||||
|
||||
print(f"\n📡 Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
@@ -54,14 +54,14 @@ async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
events.append(data)
|
||||
|
||||
|
||||
# Print progress (only when it changes significantly)
|
||||
progress = data.get('progress', 0)
|
||||
status = data.get('status', 'unknown')
|
||||
filename = data.get('filename', '')
|
||||
current = data.get('current', 0)
|
||||
total = data.get('total', 0)
|
||||
|
||||
|
||||
# Print every 5% change or status change
|
||||
if abs(progress - last_progress) >= 5 or status in ('complete', 'error'):
|
||||
current_mb = current / (1024 * 1024)
|
||||
@@ -72,7 +72,7 @@ async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
# Stop if complete or error
|
||||
if status in ("complete", "error"):
|
||||
if status == "complete":
|
||||
print(f" ✅ Download complete!")
|
||||
print(" ✅ Download complete!")
|
||||
else:
|
||||
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||
break
|
||||
@@ -119,20 +119,19 @@ async def delete_model(model_name: str) -> bool:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(url)
|
||||
if response.status_code == 200:
|
||||
print(f" ✅ Model deleted")
|
||||
print(" ✅ Model deleted")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
print(f" ℹ️ Model not found (already deleted)")
|
||||
if response.status_code == 404:
|
||||
print(" ℹ️ Model not found (already deleted)")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Error deleting model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||
async def check_model_status(model_name: str) -> dict | None:
|
||||
"""Check the status of a model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
@@ -176,7 +175,7 @@ async def main():
|
||||
|
||||
# Test model
|
||||
model_name = "qwen-tts-0.6B"
|
||||
|
||||
|
||||
# Check current status
|
||||
print(f"\n📊 Checking status of {model_name}...")
|
||||
status = await check_model_status(model_name)
|
||||
@@ -196,13 +195,13 @@ async def main():
|
||||
print(" [y] Yes, delete and download fresh")
|
||||
print(" [n] No, just test SSE connection")
|
||||
print(" [q] Quit")
|
||||
|
||||
|
||||
choice = input("\nChoice [y/n/q]: ").strip().lower()
|
||||
|
||||
|
||||
if choice == 'q':
|
||||
print("Exiting...")
|
||||
return True
|
||||
|
||||
|
||||
if choice == 'y':
|
||||
if not await delete_model(model_name):
|
||||
print("Failed to delete model. Continue anyway? [y/n]")
|
||||
@@ -270,12 +269,12 @@ async def main():
|
||||
# Analyze events
|
||||
first_event = events[0]
|
||||
last_event = events[-1]
|
||||
|
||||
print(f"\n📊 First event:")
|
||||
|
||||
print("\n📊 First event:")
|
||||
print(f" Status: {first_event.get('status')}")
|
||||
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||
|
||||
print(f"\n📊 Last event:")
|
||||
|
||||
print("\n📊 Last event:")
|
||||
print(f" Status: {last_event.get('status')}")
|
||||
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||
|
||||
@@ -284,7 +283,7 @@ async def main():
|
||||
has_increasing_progress = False
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
has_100_percent = any(e.get('progress', 0) >= 100 for e in events)
|
||||
|
||||
|
||||
# Check if progress increased over time
|
||||
if len(events) >= 2:
|
||||
progress_values = [e.get('progress', 0) for e in events]
|
||||
@@ -298,7 +297,7 @@ async def main():
|
||||
|
||||
# Overall result
|
||||
success = has_progress_updates and has_complete
|
||||
|
||||
|
||||
if success:
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ TEST PASSED - Qwen TTS download progress tracking works!")
|
||||
|
||||
@@ -10,7 +10,6 @@ character-level pass added.
|
||||
|
||||
from backend.services.refinement import collapse_repetitive_artifacts
|
||||
|
||||
|
||||
# ── single-word loops (word-level pass) ─────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -32,28 +32,25 @@ import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
# Point sys.path at the repo root so ``backend.services.refinement`` resolves
|
||||
# as a package. Using backend/ as root breaks the service's own
|
||||
# ``from ..backends import …`` relative imports.
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend.services.refinement import ( # noqa: E402
|
||||
build_refinement_prompt,
|
||||
collapse_repetitive_artifacts,
|
||||
from backend.services.refinement import (
|
||||
REFINEMENT_EXAMPLES,
|
||||
RefinementFlags,
|
||||
build_refinement_prompt,
|
||||
collapse_repetitive_artifacts,
|
||||
)
|
||||
|
||||
|
||||
# ── Sample inputs ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -222,8 +219,8 @@ class Scorecard:
|
||||
filler_count_refined: int = 0
|
||||
length_ratio: float = 0.0
|
||||
has_loop_artifact: bool = False
|
||||
prompt_leak: Optional[str] = None
|
||||
answer_leak: Optional[str] = None
|
||||
prompt_leak: str | None = None
|
||||
answer_leak: str | None = None
|
||||
missing_substrings: list[str] = field(default_factory=list)
|
||||
missing_question_mark: bool = False
|
||||
flags: list[str] = field(default_factory=list)
|
||||
@@ -242,7 +239,7 @@ def has_loop_run(text: str, threshold: int = 6) -> bool:
|
||||
if len(tokens) < threshold:
|
||||
return False
|
||||
run = 1
|
||||
prev: Optional[str] = None
|
||||
prev: str | None = None
|
||||
for tok in tokens:
|
||||
key = re.sub(r"[^\w]", "", tok).lower()
|
||||
if key and key == prev:
|
||||
@@ -255,7 +252,7 @@ def has_loop_run(text: str, threshold: int = 6) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def first_match(patterns: Iterable[re.Pattern[str]], text: str) -> Optional[str]:
|
||||
def first_match(patterns: Iterable[re.Pattern[str]], text: str) -> str | None:
|
||||
stripped = text.lstrip()
|
||||
for pat in patterns:
|
||||
m = pat.search(stripped)
|
||||
@@ -319,7 +316,7 @@ def score(sample: Sample, model: str, refined: str, latency_ms: int) -> Scorecar
|
||||
DEFAULT_PORTS = (8000, 8765, 8899, 17493)
|
||||
|
||||
|
||||
def detect_backend_port(hint: Optional[int]) -> int:
|
||||
def detect_backend_port(hint: int | None) -> int:
|
||||
"""Return a port that answers /health, preferring the hint."""
|
||||
candidates: list[int] = []
|
||||
if hint is not None:
|
||||
|
||||
@@ -10,8 +10,6 @@ Usage:
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCheckCudaCompatibility:
|
||||
"""Unit tests for check_cuda_compatibility with ROCm awareness."""
|
||||
@@ -28,41 +26,38 @@ class TestCheckCudaCompatibility:
|
||||
"""On ROCm, the NVIDIA compute-capability check should be skipped."""
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", "6.2.41133"):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
with patch("torch.cuda.is_available", return_value=True), patch("torch.version.hip", "6.2.41133"):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_cuda_compatible_arch(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(8, 6)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 3060"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86", "sm_89"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
with patch("torch.cuda.is_available", return_value=True), patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(8, 6)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 3060"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86", "sm_89"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_cuda_incompatible_arch(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(9, 0)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 4090"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is False
|
||||
assert warning is not None
|
||||
assert "not supported" in warning
|
||||
with patch("torch.cuda.is_available", return_value=True), patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(9, 0)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 4090"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is False
|
||||
assert warning is not None
|
||||
assert "not supported" in warning
|
||||
|
||||
@@ -78,7 +78,7 @@ class TestRocmBuildCli:
|
||||
build_server(cuda=True, rocm=True)
|
||||
|
||||
|
||||
@pytest.mark.slow()
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="ROCm build E2E only runs on Windows")
|
||||
class TestRocmBuildE2E:
|
||||
"""
|
||||
|
||||
@@ -7,10 +7,9 @@ without hitting the network.
|
||||
|
||||
import json
|
||||
import tarfile
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def _has_amd_hardware():
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def backend_dir():
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
@@ -4,48 +4,47 @@ Test real model download with SSE progress monitoring.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||
"""Monitor SSE stream for a model download."""
|
||||
events: List[Dict] = []
|
||||
events: list[dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"Connecting to SSE endpoint: {url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
async with httpx.AsyncClient(timeout=timeout) as client, client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
Reference in New Issue
Block a user