Skip to content

Commit b117a0d

Browse files
Merge pull request #65 from agentevals-dev/feature/venv-support
Add venv support for custom evals
2 parents 9668ba8 + 74ce5df commit b117a0d

4 files changed

Lines changed: 181 additions & 14 deletions

File tree

examples/custom_evaluators/eval_config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,3 @@ evaluators:
3232
ref: evaluators/random_evaluator/random_evaluator.py
3333
threshold: 0.110
3434
executor: local
35-

src/agentevals/custom_evaluators.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def is_available(self) -> bool:
8181

8282

8383
class PythonRuntime(Runtime):
84+
def __init__(self, python_path: Path | None = None):
85+
self._exe = str(python_path) if python_path else sys.executable
86+
8487
@property
8588
def name(self) -> str:
8689
return "Python"
@@ -90,13 +93,16 @@ def extensions(self) -> tuple[str, ...]:
9093
return (".py",)
9194

9295
def build_command(self, path: Path) -> list[str]:
93-
return [sys.executable, str(path)]
96+
return [self._exe, str(path)]
9497

9598
def is_available(self) -> bool:
9699
return True
97100

98101

99102
class NodeRuntime(Runtime):
103+
def __init__(self) -> None:
104+
self._exe = shutil.which("node")
105+
100106
@property
101107
def name(self) -> str:
102108
return "Node.js"
@@ -106,10 +112,12 @@ def extensions(self) -> tuple[str, ...]:
106112
return (".js", ".ts")
107113

108114
def build_command(self, path: Path) -> list[str]:
109-
node = shutil.which("node")
110-
if not node:
115+
if not self._exe:
111116
raise RuntimeError("Node.js not found on PATH (required for .js/.ts evaluators)")
112-
return [node, str(path)]
117+
return [self._exe, str(path)]
118+
119+
def is_available(self) -> bool:
120+
return self._exe is not None
113121

114122

115123
_RUNTIMES: list[Runtime] = [
@@ -203,12 +211,13 @@ class SubprocessBackend(EvaluatorBackend):
203211
"""Runs a local code file (.py, .js, .ts, …) as a subprocess.
204212
205213
The correct interpreter is resolved from the file extension via the
206-
:data:`_RUNTIMES` registry.
214+
:data:`_RUNTIMES` registry. Pass a pre-configured *runtime* to override
215+
the default (e.g. a :class:`PythonRuntime` with a venv interpreter).
207216
"""
208217

209-
def __init__(self, path: Path, timeout: int = 30):
218+
def __init__(self, path: Path, timeout: int = 30, runtime: Runtime | None = None):
210219
self._path = path.resolve()
211-
self._runtime = _resolve_runtime(self._path)
220+
self._runtime = runtime or _resolve_runtime(self._path)
212221
self._timeout = timeout
213222

214223
if not self._path.exists():
@@ -223,7 +232,7 @@ async def run(self, eval_input: EvalInput, metric_name: str) -> EvalResult:
223232
# Executor factory
224233
# ---------------------------------------------------------------------------
225234

226-
_EXECUTOR_FACTORIES: dict[str, Callable[[Path, int], EvaluatorBackend]] = {
235+
_EXECUTOR_FACTORIES: dict[str, Callable[..., EvaluatorBackend]] = {
227236
"local": lambda path, timeout: SubprocessBackend(path, timeout),
228237
}
229238

@@ -236,7 +245,7 @@ def create_executor(executor_name: str, path: Path, timeout: int = 30) -> Evalua
236245
return factory(path, timeout)
237246

238247

239-
def register_executor(name: str, factory: Callable[[Path, int], EvaluatorBackend]) -> None:
248+
def register_executor(name: str, factory: Callable[..., EvaluatorBackend]) -> None:
240249
"""Register a new executor factory (e.g. for Docker support)."""
241250
_EXECUTOR_FACTORIES[name] = factory
242251

@@ -425,7 +434,27 @@ async def evaluate_custom_evaluator(
425434
evaluator_def = await get_default_resolver().resolve(evaluator_def)
426435

427436
if isinstance(evaluator_def, CodeEvaluatorDef):
428-
backend = create_executor(evaluator_def.executor, Path(evaluator_def.path), evaluator_def.timeout)
437+
evaluator_path = Path(evaluator_def.path)
438+
439+
runtime: Runtime | None = None
440+
if evaluator_path.suffix == ".py":
441+
from .evaluator.venv import ensure_venv_async
442+
443+
try:
444+
venv_python = await ensure_venv_async(evaluator_path)
445+
except Exception as exc:
446+
logger.error("Failed to set up venv for '%s': %s", evaluator_def.name, exc)
447+
return MetricResult(
448+
metric_name=evaluator_def.name,
449+
error=f"Dependency installation failed: {exc}",
450+
)
451+
if venv_python:
452+
runtime = PythonRuntime(python_path=venv_python)
453+
454+
if runtime is not None:
455+
backend = SubprocessBackend(evaluator_path, evaluator_def.timeout, runtime=runtime)
456+
else:
457+
backend = create_executor(evaluator_def.executor, evaluator_path, evaluator_def.timeout)
429458
else:
430459
raise ValueError(f"Unsupported custom evaluator type: {type(evaluator_def).__name__}")
431460

src/agentevals/evaluator/sources.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import os
99
import time
1010
from dataclasses import asdict, dataclass, field
11-
from pathlib import Path
11+
from pathlib import Path, PurePosixPath
1212

1313
import yaml
1414

@@ -216,8 +216,22 @@ async def fetch_evaluator(self, ref: str, dest: Path) -> Path:
216216
resp = await client.get(url, headers=self._headers(), timeout=30)
217217
resp.raise_for_status()
218218

219-
dest.parent.mkdir(parents=True, exist_ok=True)
220-
dest.write_text(resp.text, encoding="utf-8") # noqa: ASYNC240
219+
dest.parent.mkdir(parents=True, exist_ok=True)
220+
dest.write_text(resp.text, encoding="utf-8") # noqa: ASYNC240
221+
222+
# Also try to fetch requirements.txt from the same directory.
223+
ref_dir = str(PurePosixPath(ref).parent)
224+
req_ref = f"{ref_dir}/requirements.txt"
225+
req_url = self._raw_url(req_ref)
226+
try:
227+
req_resp = await client.get(req_url, headers=self._headers(), timeout=15)
228+
if req_resp.status_code == 200:
229+
req_dest = dest.parent / "requirements.txt"
230+
req_dest.write_text(req_resp.text, encoding="utf-8") # noqa: ASYNC240
231+
logger.info("Downloaded requirements.txt for evaluator")
232+
except httpx.HTTPError:
233+
logger.debug("No requirements.txt found for evaluator (or download failed)")
234+
221235
return dest
222236

223237

@@ -267,6 +281,12 @@ async def fetch_evaluator(self, ref: str, dest: Path) -> Path:
267281

268282
dest.parent.mkdir(parents=True, exist_ok=True)
269283
shutil.copy2(src, dest)
284+
285+
# Also copy requirements.txt if it exists alongside the source file.
286+
req_src = src.parent / "requirements.txt"
287+
if req_src.exists():
288+
shutil.copy2(req_src, dest.parent / "requirements.txt")
289+
270290
return dest
271291

272292

src/agentevals/evaluator/venv.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Virtual environment management for evaluators with dependencies.
2+
3+
When an evaluator ships a ``requirements.txt`` alongside its entrypoint, we
4+
create a cached venv, install the dependencies (plus the evaluator SDK), and
5+
return the path to that venv's Python interpreter so the evaluator subprocess
6+
runs in isolation.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import hashlib
13+
import logging
14+
import os
15+
import shutil
16+
import subprocess
17+
import sys
18+
from pathlib import Path
19+
20+
logger = logging.getLogger(__name__)
21+
22+
_VENV_CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "agentevals" / "venvs"
23+
_HASH_FILE = ".requirements_hash"
24+
25+
# Per-evaluator locks to prevent concurrent venv creation for the same evaluator.
26+
_venv_locks: dict[str, asyncio.Lock] = {}
27+
28+
29+
def _venv_python(venv_dir: Path) -> Path:
30+
if sys.platform == "win32":
31+
return venv_dir / "Scripts" / "python.exe"
32+
return venv_dir / "bin" / "python"
33+
34+
35+
def _venv_key(evaluator_path: Path) -> str:
36+
"""Stable cache directory name derived from evaluator location."""
37+
resolved = evaluator_path.resolve()
38+
name = resolved.parent.name
39+
path_hash = hashlib.sha256(str(resolved.parent).encode()).hexdigest()[:8]
40+
return f"{name}-{path_hash}"
41+
42+
43+
def _is_venv_valid(venv_dir: Path, req_hash: str) -> bool:
44+
hash_file = venv_dir / _HASH_FILE
45+
return _venv_python(venv_dir).exists() and hash_file.exists() and hash_file.read_text().strip() == req_hash
46+
47+
48+
def _create_venv(venv_dir: Path, uv: str | None) -> None:
49+
if venv_dir.exists():
50+
shutil.rmtree(venv_dir)
51+
cmd = (
52+
[uv, "venv", str(venv_dir), "--python", sys.executable] if uv else [sys.executable, "-m", "venv", str(venv_dir)]
53+
)
54+
subprocess.run(cmd, check=True, capture_output=True)
55+
56+
57+
def _install_deps(venv_dir: Path, requirements: Path, uv: str | None) -> None:
58+
python = str(_venv_python(venv_dir))
59+
sdk_spec = "agentevals-evaluator-sdk"
60+
61+
if uv:
62+
base = [uv, "pip", "install", "--python", python]
63+
else:
64+
base = [python, "-m", "pip", "install"]
65+
66+
subprocess.run(base + [sdk_spec], check=True, capture_output=True)
67+
logger.info("Installing dependencies from %s ...", requirements.name)
68+
subprocess.run(base + ["-r", str(requirements)], check=True, capture_output=True)
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# Public API
73+
# ---------------------------------------------------------------------------
74+
75+
76+
def ensure_venv(evaluator_path: Path) -> Path | None:
77+
"""Ensure a cached venv exists for *evaluator_path* if it has ``requirements.txt``.
78+
79+
Returns the venv Python path, or ``None`` if no venv is needed.
80+
"""
81+
requirements = evaluator_path.resolve().parent / "requirements.txt"
82+
if not requirements.exists():
83+
return None
84+
85+
req_hash = hashlib.sha256(requirements.read_bytes()).hexdigest()
86+
venv_dir = _VENV_CACHE_DIR / _venv_key(evaluator_path)
87+
88+
if _is_venv_valid(venv_dir, req_hash):
89+
logger.debug("Using cached venv for %s at %s", evaluator_path.name, venv_dir)
90+
return _venv_python(venv_dir)
91+
92+
uv = shutil.which("uv")
93+
logger.info(
94+
"Setting up environment for evaluator '%s' (using %s). This may take a while on first run...",
95+
evaluator_path.stem,
96+
"uv" if uv else "venv+pip",
97+
)
98+
99+
try:
100+
venv_dir.parent.mkdir(parents=True, exist_ok=True)
101+
_create_venv(venv_dir, uv)
102+
_install_deps(venv_dir, requirements, uv)
103+
except subprocess.CalledProcessError as exc:
104+
stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
105+
raise RuntimeError(f"Failed to set up environment for evaluator '{evaluator_path.stem}': {stderr}") from exc
106+
107+
(venv_dir / _HASH_FILE).write_text(req_hash)
108+
logger.info("Environment ready for '%s'", evaluator_path.stem)
109+
return _venv_python(venv_dir)
110+
111+
112+
async def ensure_venv_async(evaluator_path: Path) -> Path | None:
113+
"""Async wrapper around :func:`ensure_venv` with per-evaluator locking."""
114+
venv_key = _venv_key(evaluator_path)
115+
if venv_key not in _venv_locks:
116+
_venv_locks[venv_key] = asyncio.Lock()
117+
118+
async with _venv_locks[venv_key]:
119+
return await asyncio.to_thread(ensure_venv, evaluator_path)

0 commit comments

Comments
 (0)