blob: 869066126d9c2c45b74655374f11b4fc8cd2bdec [file]
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Module for handling promptfoo installations."""
import abc
import logging
import os
import pathlib
import signal
import subprocess
import sys
CIPD_ROOT = pathlib.Path(__file__).resolve().parent / 'cipd' / 'promptfoo'
CIPD_PACKAGES = [
('infra/3pp/tools/nodejs/linux-${arch}', 'version:3@25.6.1'),
('infra/3pp/npm/promptfoo/linux-${arch}', 'version:3@0.118.17'),
]
def _run(cmd: list[str],
cwd: os.PathLike | None = None) -> subprocess.CompletedProcess:
"""Runs a command and kills its entire process group after if possible.
This is used for promptfoo in order to ensure that no orphaned processes
can write logs, etc. to temporary directories while they are being
deleted.
Args:
cmd: The command to run
cwd: An optional path to set as the cwd when running the command
Returns:
The CompletedProcess from running the command.
"""
# os.killpg is not available on Windows. Not handling orphaned processes
# for now is acceptable since these tests are really only meant to be
# run on Linux. If/when Windows is more officially supported, then an
# alternative process group killing approach may be needed. Worst case,
# the temporary directory cleanup can be directed to ignore any errors
# which will at least prevent cleanup race conditions from causing
# failures.
if sys.platform == 'win32':
return subprocess.run(cmd,
cwd=cwd,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
with subprocess.Popen(cmd,
cwd=cwd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True) as proc:
stdout, _ = proc.communicate()
try:
os.killpg(proc.pid, signal.SIGKILL)
except OSError:
# This is expected to happen regularly if all processes in the
# group have already terminated by the time killpg is called.
pass
return subprocess.CompletedProcess(proc.args, proc.returncode, stdout,
'')
class PromptfooInstallation(abc.ABC):
"""Partial implementation of a promptfoo installation."""
def __init__(self, directory: pathlib.Path):
self._directory = directory
@property
@abc.abstractmethod
def installed(self) -> bool:
"""Test whether promptfoo is installed with this method."""
@abc.abstractmethod
def run(self,
cmd: list[str],
cwd: os.PathLike | None = None) -> subprocess.CompletedProcess:
"""Runs a promptfoo command.
Args:
cmd: The command to run
cwd: The working directory from which the command should be run
Returns:
The CompletedProcess of the command that was run.
"""
class FromCipdPromptfooInstallation(PromptfooInstallation):
"""A promptfoo installation retrieved via cipd."""
def __init__(self, verbose=False):
super().__init__(None)
self._setup(verbose)
def _setup(self, verbose=False) -> None:
# To avoid requiring users to modify their DEPS file, just pull the
# cipd deps in the runner itself
if not self._executable.exists():
logging.debug('Cipd root not initialized. Creating.')
subprocess.check_call([
'cipd',
'init',
'-force',
str(CIPD_ROOT),
])
for package, version in CIPD_PACKAGES:
logging.debug('install %s@%s', package, version)
subprocess.check_call(
[
'cipd',
'install',
package,
version,
'-root',
CIPD_ROOT,
'-log-level',
'debug' if verbose else 'warning',
],
stdout=subprocess.DEVNULL,
)
@property
def installed(self) -> bool:
return self._executable.exists()
def run(self,
cmd: list[str],
cwd: os.PathLike | None = None) -> subprocess.CompletedProcess:
node_path = str(CIPD_ROOT / 'bin' / 'node')
return _run([
node_path,
str(self._executable),
*cmd,
], cwd=cwd)
@property
def _executable(self) -> pathlib.Path:
return CIPD_ROOT / 'node_modules' / '.bin' / 'promptfoo'
class PreinstalledPromptfooInstallation(PromptfooInstallation):
"""A promptfoo installation that is preinstalled."""
def __init__(self, executable: pathlib.Path):
super().__init__(executable.parent)
self._executable = executable
@property
def installed(self) -> bool:
return self._executable.is_file()
def run(self,
cmd: list[str],
cwd: os.PathLike | None = None) -> subprocess.CompletedProcess:
return _run([str(self._executable), *cmd], cwd=cwd)