fix(appserver): target the venv uv resolves, not <template>/.venv (#562)

This commit is contained in:
Adrian Lyjak
2026-04-22 14:24:41 -04:00
committed by GitHub
parent f7e037e33b
commit 916b157fa0
3 changed files with 130 additions and 44 deletions
@@ -0,0 +1,5 @@
---
"llama-agents-appserver": patch
---
Fix appserver install when the target template is a uv workspace member. Install now targets whichever venv `uv run` resolves to, instead of a hard-coded `<template>/.venv`, so `llamactl dev validate` and `llamactl serve` work in workspace layouts.
@@ -193,31 +193,40 @@ def inject_appserver_into_target(
)
def _uv_run_python(cwd: Path, snippet: str, *, stderr: int | None = None) -> str:
"""
Run a python snippet via ``uv run`` inside the project venv uv resolves for
``cwd``. Returns stripped stdout. Use this anywhere we need to probe the
target venv (installed version, ``sys.prefix``, etc.) so all probes agree
with what the start-time runners see.
"""
result = subprocess.check_output(
["uv", "run", "--no-progress", "python", "-c", snippet],
cwd=cwd,
stderr=stderr,
)
return result.decode("utf-8").strip()
def _get_installed_version_within_target(
path: Path, package: str = "llama-agents-appserver"
) -> Version | None:
packages = [package, _OLD_DIST_NAME] if package == _NEW_DIST_NAME else [package]
for pkg_name in packages:
try:
result = subprocess.check_output(
[
"uv",
"run",
"python",
"-c",
dedent(f"""
from importlib.metadata import version
try:
print(version("{pkg_name}"))
except Exception:
pass
"""),
],
cwd=path,
output = _uv_run_python(
path,
dedent(f"""
from importlib.metadata import version
try:
print(version("{pkg_name}"))
except Exception:
pass
"""),
stderr=subprocess.DEVNULL,
)
try:
return Version(result.decode("utf-8").strip())
return Version(output)
except InvalidVersion:
continue
except subprocess.CalledProcessError:
@@ -254,7 +263,6 @@ def _get_appserver_workflows_requirement() -> SpecifierSet | None:
def _ensure_compatible_workflows(
source_root: Path,
path: Path,
venv_path: Path,
) -> None:
"""Check if the user's llama-index-workflows version is compatible with the appserver.
@@ -289,7 +297,6 @@ def _ensure_compatible_workflows(
path,
"add",
[f"llama-index-workflows{req_str}"],
extra_env={"UV_PROJECT_ENVIRONMENT": str(venv_path)},
)
except subprocess.CalledProcessError:
raise RuntimeError(
@@ -321,11 +328,15 @@ def run_uv(
)
def ensure_venv(source_root: Path, path: Path, force: bool = False) -> Path:
venv_path = source_root / path / ".venv"
if force or not venv_path.exists():
run_uv(source_root, path, "venv", [str(venv_path)])
return venv_path
def _resolve_project_venv(source_root: Path, path: Path) -> Path:
"""
Return the venv path uv would use for this project.
Must be called after ``uv sync`` so the venv is guaranteed to exist. Asks uv
itself rather than reimplementing uv's workspace / project resolution, so the
install side stays aligned with ``start_*_in_target_venv`` (also bare ``uv run``).
"""
return Path(_uv_run_python(source_root / path, "import sys; print(sys.prefix)"))
def _install_and_add_appserver_if_missing(
@@ -337,7 +348,9 @@ def _install_and_add_appserver_if_missing(
auto_upgrade: bool = True,
) -> None:
"""
Ensure venv, install project deps, and add the appserver to the venv if it's missing or outdated
Sync project deps (letting uv pick the venv location, so we agree with uv run
in workspace and non-workspace layouts) and install the appserver if missing
or outdated.
"""
if not (source_root / path / "pyproject.toml").exists():
@@ -347,17 +360,16 @@ def _install_and_add_appserver_if_missing(
return
editable = are_we_editable_mode()
venv_path = ensure_venv(source_root, path, force=editable)
run_uv(
source_root,
path,
"sync",
["--no-dev", "--inexact"],
extra_env={"UV_PROJECT_ENVIRONMENT": str(venv_path)},
)
venv_path = _resolve_project_venv(source_root, path)
if auto_upgrade:
_ensure_compatible_workflows(source_root, path, venv_path)
_ensure_compatible_workflows(source_root, path)
if sdists:
run_uv(
@@ -368,15 +380,20 @@ def _install_and_add_appserver_if_missing(
+ [str(s.absolute()) for s in sdists]
+ ["--prefix", str(venv_path)],
)
elif are_we_editable_mode():
elif editable:
same_python_version = _same_python_version(venv_path)
if not same_python_version.is_same:
msg = (
f"Python version mismatch at {venv_path}: runtime "
f"{same_python_version.current_version} != venv "
f"{same_python_version.target_version}"
)
logger.error(
f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}. During development, the target environment must be running the same Python version, otherwise the appserver cannot be installed."
)
raise RuntimeError(
f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}"
f"{msg}. In editable-appserver mode the target venv must run "
f"the same Python as the appserver process, otherwise the "
f"appserver cannot be installed."
)
raise RuntimeError(msg)
pyproject = _find_development_pyproject()
if pyproject is None:
raise RuntimeError("No pyproject.toml found in llama-agents-appserver")
@@ -22,6 +22,19 @@ from llama_agents.core.path_util import validate_path_traversal
from packaging.version import Version
@pytest.fixture
def resolve_venv_to_pkg(monkeypatch: pytest.MonkeyPatch) -> None:
"""
Stub ``_resolve_project_venv`` to return ``<source_root>/<path>/.venv``,
mirroring uv's choice for a non-workspace target. Tests that simulate a
workspace layout override this by patching ``_resolve_project_venv`` directly.
"""
monkeypatch.setattr(
"llama_agents.appserver.workflow_loader._resolve_project_venv",
lambda source_root, path: source_root / path / ".venv",
)
def test_ensure_uv_available_success_and_bootstrap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -68,7 +81,7 @@ def test_ensure_uv_available_success_and_bootstrap(
def test_add_appserver_pypi_install_calls_uv_with_prefix(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
pkg_dir = tmp_path / "pkg"
pkg_dir.mkdir()
@@ -106,8 +119,59 @@ def test_add_appserver_pypi_install_calls_uv_with_prefix(
assert install_cmd[install_cmd.index("--prefix") + 1] == str(pkg_dir / ".venv")
def test_add_appserver_sdists_install(
def test_add_appserver_install_targets_resolved_venv_when_outside_pkg(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
Install must target whichever venv ``_resolve_project_venv`` returns, even
when that path is outside ``<pkg>/.venv`` (e.g. a uv workspace member whose
venv lives at the workspace root). Regression guard for the install/runtime
venv-path disagreement that broke ``llamactl dev validate`` in workspace
layouts.
"""
pkg_dir = tmp_path / "pkg"
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text("[project]\nname='x'\n")
# Simulate uv picking a venv outside the target dir (what happens when the
# target is a workspace member).
resolved_venv = tmp_path / "elsewhere" / ".venv"
cmds: list[list[str]] = []
def run_capture(cmd: list[str], **kwargs: Any) -> None:
cmds.append(cmd)
return None
monkeypatch.setattr(
"llama_agents.appserver.workflow_loader.run_process", run_capture
)
monkeypatch.setattr(
"llama_agents.appserver.workflow_loader.are_we_editable_mode", lambda: False
)
monkeypatch.setattr(
"llama_agents.appserver.workflow_loader._is_missing_or_outdated",
lambda p: Version("1.2.3"),
)
monkeypatch.setattr(
"llama_agents.appserver.workflow_loader._ensure_compatible_workflows",
lambda *a, **k: None,
)
monkeypatch.setattr(
"llama_agents.appserver.workflow_loader._resolve_project_venv",
lambda source_root, path: resolved_venv,
)
_install_and_add_appserver_if_missing(Path("pkg"), tmp_path)
install_cmd = cmds[-1]
assert install_cmd[:3] == ["uv", "pip", "install"]
assert "--prefix" in install_cmd
assert install_cmd[install_cmd.index("--prefix") + 1] == str(resolved_venv)
assert str(pkg_dir / ".venv") not in install_cmd
def test_add_appserver_sdists_install(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
pkg_dir = tmp_path / "pkg"
pkg_dir.mkdir()
@@ -144,7 +208,7 @@ def test_add_appserver_sdists_install(
def test_add_appserver_editable_install(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
pkg_dir = tmp_path / "pkg"
pkg_dir.mkdir()
@@ -323,7 +387,7 @@ def test_current_and_outdated_logic(
def test_add_appserver_target_version_installs_from_pypi(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
"""When target_version is set, install that exact version from PyPI."""
pkg_dir = tmp_path / "pkg"
@@ -360,7 +424,7 @@ def test_add_appserver_target_version_installs_from_pypi(
def test_add_appserver_target_version_ignored_in_editable_mode(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
"""In editable mode, target_version is ignored — editable installs use local source."""
pkg_dir = tmp_path / "pkg"
@@ -413,7 +477,7 @@ def test_add_appserver_target_version_ignored_in_editable_mode(
def test_add_appserver_target_version_ignored_when_sdists_provided(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
"""When sdists are provided, they take priority over target_version."""
pkg_dir = tmp_path / "pkg"
@@ -533,7 +597,7 @@ def test_ensure_compatible_workflows_compatible_noop(
monkeypatch.setattr("llama_agents.appserver.workflow_loader.run_uv", track_run_uv)
_ensure_compatible_workflows(tmp_path, Path("."), tmp_path / ".venv")
_ensure_compatible_workflows(tmp_path, Path("."))
assert len(uv_calls) == 0
@@ -565,7 +629,7 @@ def test_ensure_compatible_workflows_incompatible_auto_updates(
monkeypatch.setattr("llama_agents.appserver.workflow_loader.run_uv", track_run_uv)
_ensure_compatible_workflows(tmp_path, Path("."), tmp_path / ".venv")
_ensure_compatible_workflows(tmp_path, Path("."))
assert len(uv_calls) == 1
cmd, args = uv_calls[0]
assert cmd == "add"
@@ -593,7 +657,7 @@ def test_ensure_compatible_workflows_not_installed_noop(
lambda *a, **k: uv_calls.append(1),
)
_ensure_compatible_workflows(tmp_path, Path("."), tmp_path / ".venv")
_ensure_compatible_workflows(tmp_path, Path("."))
assert len(uv_calls) == 0
@@ -618,11 +682,11 @@ def test_ensure_compatible_workflows_update_fails_raises(
monkeypatch.setattr("llama_agents.appserver.workflow_loader.run_uv", fail_run_uv)
with pytest.raises(RuntimeError, match="conflicting constraints"):
_ensure_compatible_workflows(tmp_path, Path("."), tmp_path / ".venv")
_ensure_compatible_workflows(tmp_path, Path("."))
def test_install_calls_ensure_compatible_workflows(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, resolve_venv_to_pkg: None
) -> None:
"""Integration: _install_and_add_appserver_if_missing calls _ensure_compatible_workflows."""
pkg_dir = tmp_path / "pkg"
@@ -647,7 +711,7 @@ def test_install_calls_ensure_compatible_workflows(
compat_called = {"called": False}
def mock_ensure_compat(source_root: Path, path: Path, venv_path: Path) -> None:
def mock_ensure_compat(source_root: Path, path: Path) -> None:
compat_called["called"] = True
monkeypatch.setattr(