diff --git a/app/ui/launcher/core.py b/app/ui/launcher/core.py index 76f3c091..c49c5ebd 100644 --- a/app/ui/launcher/core.py +++ b/app/ui/launcher/core.py @@ -10,6 +10,7 @@ # --------------------------------------------------------------------------- from pathlib import Path +import os import sys import subprocess from PySide6 import QtWidgets @@ -89,24 +90,29 @@ def run_python(script_path: Path, args: list | None = None): # UV network tuning defaults. # These can be overridden by setting environment variables before launching. -# UV_TIMEOUT: per-request HTTP timeout in seconds (default 120 s). +# UV_HTTP_TIMEOUT: per-request HTTP read timeout in seconds (default 120 s). # Increase if large wheels (torch, onnxruntime) time out on slow connections. -# UV_RETRIES: number of retry attempts on transient network errors (default 5). +# UV_HTTP_RETRIES: number of retry attempts on transient network errors (default 5). # UV_CONCURRENT_DOWNLOADS: parallel download slots (default 4). # Reduce to 1 on very slow / metered connections to avoid overloading the pipe. -_UV_TIMEOUT = "120" -_UV_RETRIES = "5" +_UV_HTTP_TIMEOUT = "120" +_UV_HTTP_RETRIES = "5" _UV_CONCURRENT_DOWNLOADS = "4" def uv_pip_install(): """Run dependency installation using the portable uv executable. - Passes explicit timeout, retry, and concurrency flags so that slow or - unstable connections (common in portable installs) do not abort mid-install - with a cryptic timeout error. + Passes uv network tuning via environment variables so that slow or unstable + connections (common in portable installs) do not abort mid-install with a + cryptic timeout error. Existing user-provided uv environment values win. """ - subprocess.run( + env = os.environ.copy() + env.setdefault("UV_HTTP_TIMEOUT", _UV_HTTP_TIMEOUT) + env.setdefault("UV_HTTP_RETRIES", _UV_HTTP_RETRIES) + env.setdefault("UV_CONCURRENT_DOWNLOADS", _UV_CONCURRENT_DOWNLOADS) + + return subprocess.run( [ str(PATHS["UV_EXE"]), "pip", @@ -115,13 +121,9 @@ def uv_pip_install(): str(PATHS["REQ_FILE"]), "--python", str(PATHS["PYTHON_EXE"]), - "--timeout", - _UV_TIMEOUT, - "--retries", - _UV_RETRIES, - "--concurrent-downloads", - _UV_CONCURRENT_DOWNLOADS, ], cwd=str(PATHS["APP_DIR"]), + env=env, + check=True, shell=False, ) diff --git a/app/ui/launcher/launcher_window.py b/app/ui/launcher/launcher_window.py index 6fb71bcf..6a2d95b0 100644 --- a/app/ui/launcher/launcher_window.py +++ b/app/ui/launcher/launcher_window.py @@ -16,6 +16,7 @@ # 3. The button will appear automatically on the respective page. # --------------------------------------------------------------------------- +import subprocess import sys from datetime import datetime, timezone from PySide6 import QtWidgets, QtGui, QtCore @@ -640,7 +641,19 @@ def on_repair_installation(self): def on_update_deps(self): print("[Launcher] Updating/checking dependencies via uv...") with with_busy_state(self, busy=True, text="Updating dependencies..."): - uv_pip_install() + try: + uv_pip_install() + except subprocess.CalledProcessError as e: + print( + f"[Launcher] Dependency update failed (exit code {e.returncode})." + ) + QtWidgets.QMessageBox.critical( + self, + "Dependency Update Failed", + "Dependency update failed.\n\n" + "Check the console for details, then try again.", + ) + return write_checksum_state(deps_sha=compute_file_sha256(PATHS["REQ_FILE"])) self._load_checksum_status() self._refresh_update_indicators() diff --git a/tests/unit/ui/test_launcher_dependency_update.py b/tests/unit/ui/test_launcher_dependency_update.py new file mode 100644 index 00000000..59cd22af --- /dev/null +++ b/tests/unit/ui/test_launcher_dependency_update.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import contextlib +import subprocess + + +def test_uv_pip_install_uses_uv_environment_variables(monkeypatch): + from app.ui.launcher import core + + monkeypatch.setattr( + core, + "PATHS", + { + "UV_EXE": "uv.exe", + "REQ_FILE": "requirements_cu13.txt", + "PYTHON_EXE": "python.exe", + "APP_DIR": "repo", + }, + ) + monkeypatch.setenv("UV_HTTP_TIMEOUT", "999") + monkeypatch.delenv("UV_HTTP_RETRIES", raising=False) + monkeypatch.delenv("UV_CONCURRENT_DOWNLOADS", raising=False) + + recorded = {} + + def fake_run(cmd, **kwargs): + recorded["cmd"] = cmd + recorded["kwargs"] = kwargs + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(core.subprocess, "run", fake_run) + + result = core.uv_pip_install() + + assert result.returncode == 0 + assert recorded["cmd"] == [ + "uv.exe", + "pip", + "install", + "-r", + "requirements_cu13.txt", + "--python", + "python.exe", + ] + assert "--timeout" not in recorded["cmd"] + assert "--retries" not in recorded["cmd"] + assert "--concurrent-downloads" not in recorded["cmd"] + assert recorded["kwargs"]["check"] is True + assert recorded["kwargs"]["env"]["UV_HTTP_TIMEOUT"] == "999" + assert recorded["kwargs"]["env"]["UV_HTTP_RETRIES"] == "5" + assert recorded["kwargs"]["env"]["UV_CONCURRENT_DOWNLOADS"] == "4" + + +def test_update_deps_failure_skips_checksum_update(monkeypatch): + from app.ui.launcher import launcher_window + + checksum_writes = [] + dialogs = [] + + monkeypatch.setattr( + launcher_window, + "with_busy_state", + lambda *_args, **_kwargs: contextlib.nullcontext(), + ) + monkeypatch.setattr( + launcher_window, + "uv_pip_install", + lambda: (_ for _ in ()).throw( + subprocess.CalledProcessError(2, ["uv", "pip", "install"]) + ), + ) + monkeypatch.setattr( + launcher_window, + "write_checksum_state", + lambda **kwargs: checksum_writes.append(kwargs), + ) + monkeypatch.setattr( + launcher_window.QtWidgets.QMessageBox, + "critical", + lambda *args: dialogs.append(args), + ) + + launcher_window.LauncherWindow.on_update_deps(object()) + + assert checksum_writes == [] + assert dialogs + + +def test_update_deps_success_updates_checksum_and_refreshes(monkeypatch): + from app.ui.launcher import launcher_window + + events = [] + + class DummyWindow: + def _load_checksum_status(self): + events.append("load") + + def _refresh_update_indicators(self): + events.append("refresh") + + monkeypatch.setattr( + launcher_window, + "with_busy_state", + lambda *_args, **_kwargs: contextlib.nullcontext(), + ) + monkeypatch.setattr(launcher_window, "uv_pip_install", lambda: events.append("uv")) + monkeypatch.setattr( + launcher_window, "compute_file_sha256", lambda _path: "deps-sha" + ) + monkeypatch.setattr( + launcher_window, + "write_checksum_state", + lambda **kwargs: events.append(("checksum", kwargs)), + ) + + launcher_window.LauncherWindow.on_update_deps(DummyWindow()) + + assert events == [ + "uv", + ("checksum", {"deps_sha": "deps-sha"}), + "load", + "refresh", + ]