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:
Jamie Pine
2026-07-26 23:16:09 -07:00
parent 766c51a8a1
commit b434db22f6
82 changed files with 970 additions and 999 deletions
+17
View File
@@ -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 -27
View File
@@ -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)
+1 -2
View File
@@ -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
View File
@@ -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 -8
View File
@@ -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}")
+4 -4
View File
@@ -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()
+3 -3
View File
@@ -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)
+4 -6
View File
@@ -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
+13 -13
View File
@@ -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():
+26 -27
View File
@@ -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) ─────────────────────────────────
+9 -12
View File
@@ -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:
+27 -32
View File
@@ -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
+1 -1
View File
@@ -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:
"""
+1 -2
View File
@@ -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
+1 -1
View File
@@ -40,7 +40,7 @@ def _has_amd_hardware():
return False
@pytest.fixture()
@pytest.fixture
def backend_dir():
return Path(__file__).parent.parent
+26 -27
View File
@@ -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