aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src/voiceforge/setup.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project/src/voiceforge/setup.py')
-rw-r--r--lib/project/src/voiceforge/setup.py203
1 files changed, 203 insertions, 0 deletions
diff --git a/lib/project/src/voiceforge/setup.py b/lib/project/src/voiceforge/setup.py
new file mode 100644
index 0000000..9afeb44
--- /dev/null
+++ b/lib/project/src/voiceforge/setup.py
@@ -0,0 +1,203 @@
+"""User-local tools. No sudo and no changes to the application's interpreter."""
+from __future__ import annotations
+
+from collections import deque
+from collections.abc import Callable
+import json
+import os
+from pathlib import Path
+import selectors
+import shutil
+import signal
+import subprocess
+import sys
+import time
+
+Progress = Callable[[str, float | None, float | None], None]
+
+
+def data_dir() -> Path:
+ """The VoiceForge data/prefix directory.
+
+ VOICEFORGE_HOME wins (the single-directory launcher exports it), then
+ XDG_DATA_HOME/voiceforge, then ~/.local/share/voiceforge. A relative
+ VOICEFORGE_HOME resolves against the current directory.
+ """
+ home = os.environ.get("VOICEFORGE_HOME")
+ if home:
+ return Path(home).absolute()
+ root = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
+ if not root.is_absolute():
+ root = Path.home() / ".local/share"
+ return root / "voiceforge"
+
+
+def tool_env() -> dict[str, str]:
+ """Sanitized environment for bootstrap, setup, and worker subprocesses.
+
+ Host Python settings are removed so a virtualenv or sitecustomize on the
+ machine cannot leak into managed environments. Installation-target,
+ index, and constraint overrides are dropped and package-manager
+ configuration files disabled, so packages can only come from the
+ explicitly passed trusted indexes and land inside the data directory;
+ every cache (pip, uv, managed Python, XDG, temporary files) stays there
+ too.
+ """
+ env = os.environ.copy()
+ for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV",
+ "PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS",
+ "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT",
+ "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL",
+ "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"):
+ env.pop(name, None)
+ env["PYTHONNOUSERSITE"] = "1"
+ env["PIP_CONFIG_FILE"] = os.devnull
+ env["UV_NO_CONFIG"] = "1"
+ cache = data_dir() / "cache"
+ env["XDG_CACHE_HOME"] = str(cache)
+ env["PIP_CACHE_DIR"] = str(cache / "pip")
+ env["UV_CACHE_DIR"] = str(cache / "uv")
+ env["UV_PYTHON_INSTALL_DIR"] = str(data_dir() / "uv/python")
+ tmp = data_dir() / "tmp"
+ try:
+ tmp.mkdir(parents=True, exist_ok=True)
+ env["TMPDIR"] = str(tmp)
+ except OSError:
+ pass # Best effort: never break subprocesses over temporary-file containment.
+ return env
+
+
+def terminate_group(process: subprocess.Popen) -> None:
+ """SIGTERM the child's whole process group, escalate to SIGKILL, and reap.
+
+ The group is signalled even when the leader has already exited, because
+ descendants can outlive it while still holding the output pipe open.
+ A race between exit and signalling is tolerated.
+ """
+ for sig in (signal.SIGTERM, signal.SIGKILL):
+ try:
+ os.killpg(process.pid, sig)
+ except ProcessLookupError:
+ pass
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ continue
+ try:
+ os.killpg(process.pid, 0)
+ except ProcessLookupError:
+ return
+ process.wait()
+
+
+def run_process(args: list[str], stage: str, progress: Progress | None = None,
+ *, env: dict[str, str] | None = None) -> None:
+ """Drain output without blocking; send heartbeat callbacks every 0.25s.
+
+ Worker JSON records carry frame/byte counts. Other processes report elapsed seconds
+ as completed with total=None. Only the last 64 KiB of diagnostics are retained.
+ Callback exceptions (including cancellation) terminate the process group.
+ """
+ started = time.monotonic()
+ current_stage, completed, total = stage, None, None
+ diagnostics: deque[bytes] = deque(maxlen=16)
+ pending = b""
+ if progress:
+ progress(stage, None, None)
+ with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ env=env, start_new_session=True) as process:
+ assert process.stdout is not None
+ os.set_blocking(process.stdout.fileno(), False)
+ try:
+ with selectors.DefaultSelector() as selector:
+ selector.register(process.stdout, selectors.EVENT_READ)
+ while selector.get_map() or process.poll() is None:
+ for key, _ in selector.select(timeout=0.25):
+ chunk = os.read(key.fd, 4096)
+ if not chunk:
+ selector.unregister(key.fileobj)
+ continue
+ diagnostics.append(chunk)
+ pending += chunk
+ while b"\n" in pending:
+ line, pending = pending.split(b"\n", 1)
+ if line.startswith(b"VOICEFORGE_PROGRESS "):
+ event = json.loads(line[len(b"VOICEFORGE_PROGRESS "):])
+ current_stage, completed, total = event
+ if len(pending) > 65536:
+ pending = pending[-4096:]
+ if progress:
+ progress(current_stage, completed if total is not None else
+ time.monotonic() - started, total)
+ code = process.wait()
+ if code:
+ detail = b"".join(diagnostics).decode("utf-8", errors="replace")
+ raise RuntimeError(f"{stage} failed (exit {code}):\n{detail}")
+ except BaseException:
+ terminate_group(process)
+ raise
+
+
+def _uv_supports_relocatable(uv: str) -> bool:
+ """Whether this uv understands the venv flags AI setup relies on.
+
+ A host uv can be older than the pinned one; capability is checked
+ directly instead of trusting the version string.
+ """
+ try:
+ result = subprocess.run([uv, "venv", "--help"], capture_output=True,
+ text=True, check=True)
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return "--relocatable" in result.stdout + result.stderr
+
+
+def ensure_uv(progress: Progress | None = None) -> str:
+ """Return a uv that supports the flags AI setup relies on.
+
+ The launcher's contained bootstrap uv is preferred; a host uv is trusted
+ only if it actually supports --relocatable. Otherwise uv==0.8.17 is
+ installed from PyPI (trust PyPI's distribution, not a downloaded shell
+ script).
+ """
+ contained = data_dir() / "bootstrap/bin/uv"
+ if contained.is_file() and os.access(contained, os.X_OK):
+ return str(contained)
+ found = shutil.which("uv")
+ if found and _uv_supports_relocatable(found):
+ return found
+ root = contained.parent.parent
+ root.parent.mkdir(parents=True, exist_ok=True)
+ python = sys.executable or shutil.which("python3")
+ env = tool_env()
+ run_process([python, "-m", "venv", str(root)], "Creating uv bootstrap",
+ progress, env=env)
+ run_process([str(root / "bin/python"), "-m", "pip", "install",
+ "--index-url", "https://pypi.org/simple", "uv==0.8.17"],
+ "Installing uv from PyPI", progress, env=env)
+ if not (contained.is_file() and os.access(contained, os.X_OK)):
+ raise RuntimeError("Installing uv from PyPI did not produce a usable "
+ "uv; check for host pip settings (such as "
+ "PIP_TARGET) that redirect installations outside "
+ "the data directory.")
+ return str(contained)
+
+
+def ensure_ffmpeg() -> str:
+ """Return the bundled FFmpeg, or a system one where the bundle is
+ absent, not ffprobe.
+
+ The launcher installs imageio-ffmpeg via the bundled-ffmpeg extra; direct
+ package installs should declare it as a dependency. This helper never
+ runs pip in the current interpreter.
+ """
+ try:
+ import imageio_ffmpeg
+ return imageio_ffmpeg.get_ffmpeg_exe()
+ except (ImportError, RuntimeError) as error:
+ found = shutil.which("ffmpeg")
+ if found:
+ return found
+ raise RuntimeError("FFmpeg is unavailable. Rebuild the contained "
+ "runtime with './producer.sh --rebuild', or "
+ "install a system FFmpeg.") from error