Skip to content
Draft
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
35 changes: 30 additions & 5 deletions scripts/package_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,17 +127,42 @@ def build_payload(staging: Path) -> Path:
wheels = tuple(python_dir.glob("*.whl"))
if len(wheels) != 1:
raise RuntimeError(f"expected exactly one wheel, found {len(wheels)}")

run(
[
"uv",
"export",
"--locked",
"--extra",
"ui",
"--no-dev",
"--no-emit-project",
"--format",
"requirements-txt",
"--output-file",
str(staging / "runtime-requirements.txt"),
]
)
shutil.copy2(ROOT / "src" / "performance_lab" / "artifact_launcher.py", staging / "launch.py")
return wheels[0]


def write_run_instructions(staging: Path, wheel_name: str) -> None:
(staging / "RUN.md").write_text(
"# Run this artifact\n\n"
"1. Extract the ZIP.\n"
f"2. Install `python/{wheel_name}[ui]` into an isolated Python 3.12+ environment.\n"
"3. Create a versioned `StarterRunConfig` JSON for the target endpoint.\n"
"4. Run `performance-lab-ui --config <config.json> --assets web`.\n\n"
"The product binds to loopback by default. Model serving remains external.\n",
"1. Extract the ZIP into a writable directory.\n"
"2. Ensure Python 3.12 or newer is available. No repository checkout, uv, Node or pnpm "
"is required.\n"
"3. Create a versioned `StarterRunConfig` JSON for the external inference target.\n"
"4. Run `python launch.py --config <config.json>`.\n"
"5. Open `http://127.0.0.1:8765`. Stop the foreground process with Ctrl-C.\n\n"
"On first launch, `launch.py` creates an artifact-owned `.runtime` virtual environment "
"and installs the exact locked runtime requirements plus "
f"`python/{wheel_name}`. Dependency download therefore requires package-index access on "
"the first launch; subsequent launches reuse the matching runtime. Use `--runtime-dir` "
"when the extracted artifact directory is not writable.\n\n"
"The product binds to loopback by default. Model serving and model lifecycle remain "
"external to Performance Lab.\n",
encoding="utf-8",
)

Expand Down
247 changes: 247 additions & 0 deletions src/performance_lab/artifact_launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
"""Standalone launcher shipped with the distributed Performance Lab artifact.

This module intentionally depends only on the Python standard library so the same
source file can be copied to ``launch.py`` beside the packaged wheel and built web
assets. The launcher owns only its local runtime directory; model serving remains
external.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import venv
from collections.abc import Sequence
from pathlib import Path
from typing import Any

MIN_PYTHON = (3, 12)
RUNTIME_OWNER = "performance-lab-artifact-launcher-v1"
RUNTIME_MARKER = ".performance-lab-runtime.json"


def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def _load_json(path: Path) -> dict[str, Any]:
try:
value: object = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"cannot read launcher metadata: {path}") from exc
if not isinstance(value, dict):
raise RuntimeError(f"launcher metadata must contain an object: {path}")
return value


def _runtime_python(runtime_dir: Path) -> Path:
if sys.platform == "win32":
return runtime_dir / "Scripts" / "python.exe"
return runtime_dir / "bin" / "python"


def _packaged_wheel(root: Path) -> Path:
wheels = tuple((root / "python").glob("*.whl"))
if len(wheels) != 1:
raise RuntimeError(f"expected exactly one packaged wheel, found {len(wheels)}")
return wheels[0]


def runtime_identity(root: Path) -> dict[str, str]:
manifest = _load_json(root / "build-manifest.json")
artifact_stem = manifest.get("artifact_stem")
source_revision = manifest.get("source_revision")
if not isinstance(artifact_stem, str) or not artifact_stem:
raise RuntimeError("build manifest is missing artifact_stem")
if not isinstance(source_revision, str) or not source_revision:
raise RuntimeError("build manifest is missing source_revision")

requirements = root / "runtime-requirements.txt"
if not requirements.is_file():
raise RuntimeError("artifact is missing runtime-requirements.txt")
wheel = _packaged_wheel(root)
return {
"owner": RUNTIME_OWNER,
"artifact_stem": artifact_stem,
"source_revision": source_revision,
"requirements_sha256": _sha256(requirements),
"wheel_sha256": _sha256(wheel),
}


def _read_runtime_marker(runtime_dir: Path) -> dict[str, Any] | None:
marker = runtime_dir / RUNTIME_MARKER
if not marker.is_file():
return None
try:
value: object = json.loads(marker.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None


def runtime_is_current(root: Path, runtime_dir: Path) -> bool:
marker = _read_runtime_marker(runtime_dir)
if marker is None or marker.get("state") != "ready":
return False
expected = runtime_identity(root)
return (
all(marker.get(key) == value for key, value in expected.items())
and _runtime_python(runtime_dir).is_file()
)


def _assert_runtime_owned(runtime_dir: Path) -> None:
if not runtime_dir.exists():
return
marker = _read_runtime_marker(runtime_dir)
if marker is None or marker.get("owner") != RUNTIME_OWNER:
raise RuntimeError(
f"refusing to replace unowned runtime directory: {runtime_dir}. "
"Choose another --runtime-dir or remove it manually."
)


def _write_runtime_marker(runtime_dir: Path, identity: dict[str, str], *, state: str) -> None:
payload: dict[str, str] = {**identity, "state": state}
(runtime_dir / RUNTIME_MARKER).write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)


def prepare_runtime(root: Path, runtime_dir: Path) -> Path:
"""Create or reuse the launcher-owned isolated runtime for this artifact."""

root = root.resolve()
runtime_dir = runtime_dir.resolve()
if runtime_is_current(root, runtime_dir):
return _runtime_python(runtime_dir)

identity = runtime_identity(root)
_assert_runtime_owned(runtime_dir)
if runtime_dir.exists():
shutil.rmtree(runtime_dir)
runtime_dir.mkdir(parents=True)
_write_runtime_marker(runtime_dir, identity, state="installing")

try:
venv.EnvBuilder(with_pip=True).create(runtime_dir)
python = _runtime_python(runtime_dir)
if not python.is_file():
raise RuntimeError("Python venv was created without an executable")
subprocess.run(
[
str(python),
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--requirement",
str(root / "runtime-requirements.txt"),
],
check=True,
)
subprocess.run(
[
str(python),
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--no-deps",
str(_packaged_wheel(root)),
],
check=True,
)
except Exception:
# The marker proves ownership, so removing this partial runtime cannot delete
# unrelated user state.
shutil.rmtree(runtime_dir, ignore_errors=True)
raise

_write_runtime_marker(runtime_dir, identity, state="ready")
return python


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="performance-lab-artifact")
parser.add_argument(
"--config",
required=True,
type=Path,
help="Versioned StarterRunConfig JSON for the external inference target.",
)
parser.add_argument(
"--runtime-dir",
type=Path,
default=None,
help="Launcher-owned runtime directory. Defaults to .runtime beside launch.py.",
)
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--prepare-only",
action="store_true",
help="Prepare/reuse the isolated runtime without starting the product.",
)
return parser


def main(argv: Sequence[str] | None = None) -> int:
if sys.version_info < MIN_PYTHON:
print("Performance Lab requires Python 3.12 or newer.", file=sys.stderr)
return 2

args = build_parser().parse_args(list(argv) if argv is not None else None)
root = Path(__file__).resolve().parent
config = args.config.expanduser().resolve()
if not config.is_file():
print(f"error: config does not exist: {config}", file=sys.stderr)
return 2
if not (root / "web" / "index.html").is_file():
print("error: artifact is missing built web assets", file=sys.stderr)
return 2

runtime_dir = (
args.runtime_dir.expanduser().resolve()
if args.runtime_dir is not None
else root / ".runtime"
)
try:
python = prepare_runtime(root, runtime_dir)
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"error: cannot prepare Performance Lab runtime: {exc}", file=sys.stderr)
return 2

if args.prepare_only:
print(f"Performance Lab runtime ready: {runtime_dir}")
return 0

command = [
str(python),
"-m",
"performance_lab.ui_server",
"--config",
str(config),
"--assets",
str(root / "web"),
"--port",
str(args.port),
]
os.execv(str(python), command)
raise AssertionError("os.execv returned unexpectedly")


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
103 changes: 103 additions & 0 deletions tests/test_artifact_launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import json
from pathlib import Path

import pytest

from performance_lab import artifact_launcher


def _artifact(tmp_path: Path) -> Path:
root = tmp_path / "artifact"
(root / "python").mkdir(parents=True)
(root / "runtime-requirements.txt").write_text("pydantic==2.0\n", encoding="utf-8")
(root / "python" / "performance_lab.whl").write_bytes(b"wheel")
(root / "build-manifest.json").write_text(
json.dumps(
{
"artifact_stem": "ai-performance-lab-test",
"source_revision": "abc123",
}
),
encoding="utf-8",
)
return root


def test_runtime_identity_is_bound_to_artifact_inputs(tmp_path: Path) -> None:
root = _artifact(tmp_path)

identity = artifact_launcher.runtime_identity(root)

assert identity["owner"] == artifact_launcher.RUNTIME_OWNER
assert identity["artifact_stem"] == "ai-performance-lab-test"
assert identity["source_revision"] == "abc123"
assert len(identity["requirements_sha256"]) == 64
assert len(identity["wheel_sha256"]) == 64


def test_prepare_runtime_installs_once_then_reuses_matching_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
root = _artifact(tmp_path)
runtime = tmp_path / "runtime"
calls: list[list[str]] = []

class FakeBuilder:
def __init__(self, *, with_pip: bool) -> None:
assert with_pip is True

def create(self, path: Path) -> None:
python = artifact_launcher._runtime_python(Path(path))
python.parent.mkdir(parents=True, exist_ok=True)
python.write_text("fake-python", encoding="utf-8")

def fake_run(command: list[str], *, check: bool) -> None:
assert check is True
calls.append(command)

monkeypatch.setattr(artifact_launcher.venv, "EnvBuilder", FakeBuilder)
monkeypatch.setattr(artifact_launcher.subprocess, "run", fake_run)

python = artifact_launcher.prepare_runtime(root, runtime)
reused = artifact_launcher.prepare_runtime(root, runtime)

assert python == reused
assert python.is_file()
assert len(calls) == 2
marker = json.loads((runtime / artifact_launcher.RUNTIME_MARKER).read_text(encoding="utf-8"))
assert marker["state"] == "ready"
assert marker["owner"] == artifact_launcher.RUNTIME_OWNER
assert artifact_launcher.runtime_is_current(root, runtime) is True


def test_prepare_runtime_refuses_to_replace_unowned_directory(tmp_path: Path) -> None:
root = _artifact(tmp_path)
runtime = tmp_path / "runtime"
runtime.mkdir()
(runtime / "user-data.txt").write_text("keep", encoding="utf-8")

with pytest.raises(RuntimeError, match="refusing to replace unowned runtime directory"):
artifact_launcher.prepare_runtime(root, runtime)

assert (runtime / "user-data.txt").read_text(encoding="utf-8") == "keep"


def test_runtime_is_invalidated_when_locked_requirements_change(tmp_path: Path) -> None:
root = _artifact(tmp_path)
runtime = tmp_path / "runtime"
runtime.mkdir()
identity = artifact_launcher.runtime_identity(root)
marker = {**identity, "state": "ready"}
(runtime / artifact_launcher.RUNTIME_MARKER).write_text(
json.dumps(marker),
encoding="utf-8",
)
python = artifact_launcher._runtime_python(runtime)
python.parent.mkdir(parents=True)
python.write_text("fake-python", encoding="utf-8")
assert artifact_launcher.runtime_is_current(root, runtime) is True

(root / "runtime-requirements.txt").write_text("pydantic==2.1\n", encoding="utf-8")

assert artifact_launcher.runtime_is_current(root, runtime) is False
Loading