Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions app/ui/launcher/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# ---------------------------------------------------------------------------

from pathlib import Path
import os
import sys
import subprocess
from PySide6 import QtWidgets
Expand Down Expand Up @@ -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",
Expand All @@ -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,
)
15 changes: 14 additions & 1 deletion app/ui/launcher/launcher_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
123 changes: 123 additions & 0 deletions tests/unit/ui/test_launcher_dependency_update.py
Original file line number Diff line number Diff line change
@@ -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",
]