mirror of
https://github.com/run-llama/workflows-py.git
synced 2026-07-18 16:14:58 -04:00
fix(appserver): target the venv uv resolves, not <template>/.venv (#562)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user