Skip to content
Merged
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"rich~=14.3",
"click~=8.3",
"huggingface-hub~=1.4",
"uv~=0.12.10",
"numpy>=2.4.6; python_version >= '3.14'",
]
requires-python = ">=3.10"
Expand Down
1 change: 1 addition & 0 deletions src/cloudai/_core/installables/git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class GitRepo(Installable, BaseModel):
init_submodules: bool = False
installed_path: Optional[Path] = None
mount_as: Optional[str] = None
python_version: Optional[str] = None

def __repr__(self) -> str:
return f"GitRepo(url={self.url}, commit={self.commit})"
Expand Down
15 changes: 9 additions & 6 deletions src/cloudai/_core/installables/python_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import sys
from typing import TYPE_CHECKING

import uv

from .base import Installable, InstallStatusResult

if TYPE_CHECKING:
Expand Down Expand Up @@ -70,22 +72,23 @@ def install(self, installer: "BaseInstaller") -> InstallStatusResult:
if installed.success:
return installed

uv = shutil.which("uv")
if uv is None:
return InstallStatusResult(False, "Cannot install Python environment: 'uv' is not available.")
try:
uv_bin = uv.find_uv_bin()
except OSError as e:
return InstallStatusResult(False, f"Cannot install Python environment: {e}")

res = self._ensure_python_version(uv)
res = self._ensure_python_version(uv_bin)
if not res.success:
return res

venv_path = installer.system.install_path / self.venv_name
res = self._create_venv(uv, venv_path)
res = self._create_venv(uv_bin, venv_path)
if not res.success:
self._cleanup_venv(venv_path)
self.venv_path = None
return res

res = self._install_requirements(uv, installer)
res = self._install_requirements(uv_bin, installer)
if not res.success:
self._cleanup_venv(venv_path)
self.venv_path = None
Expand Down
66 changes: 62 additions & 4 deletions src/cloudai/_core/installables/python_executable.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@
import logging
import shutil
import subprocess
import sys
import typing
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional

import uv

from .base import Installable, InstallStatusResult
from .git_repo import GitRepo

if TYPE_CHECKING:
from ..base_installer import BaseInstaller


_PYTHON_VERSION_MARKER = ".cloudai-python-version"


@dataclass
class PythonExecutable(Installable):
"""Python executable object."""
Expand Down Expand Up @@ -93,6 +100,11 @@ def is_installed(self, installer: "BaseInstaller") -> InstallStatusResult:
venv_path = self.venv_path if self.venv_path else installer.system.install_path / self.venv_name
if not venv_path.exists():
return InstallStatusResult(False, f"Virtual environment not created for {self.git_repo.url}")

python_version = self._resolve_python_version(repo_path)
if not self._python_version_matches(venv_path, python_version):
return InstallStatusResult(False, f"Virtual environment uses a different Python than {python_version}")

self.venv_path = venv_path

return InstallStatusResult(True, "Python executable installed")
Expand All @@ -104,21 +116,35 @@ def mark_as_installed(self, installer: "BaseInstaller") -> InstallStatusResult:

def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult:
venv_path = installer.system.install_path / self.venv_name
project_dir = typing.cast(Path, self.git_repo.installed_path)
python_version = self._resolve_python_version(project_dir)
logging.debug(f"Creating virtual environment in {venv_path}")
if venv_path.exists():
if venv_path.exists() and self._python_version_matches(venv_path, python_version):
msg = f"Virtual environment already exists at {venv_path}."
logging.debug(msg)
return InstallStatusResult(True, msg)

cmd = ["python", "-m", "venv", str(venv_path)]
project_dir = project_dir / self.project_subpath if self.project_subpath else project_dir
if not project_dir.is_dir():
return InstallStatusResult(False, f"Python project directory does not exist: {project_dir}")

try:
uv_bin = uv.find_uv_bin()
except OSError as e:
return InstallStatusResult(False, f"Cannot create virtual environment: {e}")

if venv_path.exists():
shutil.rmtree(venv_path)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

cmd = [uv_bin, "venv", "--python", python_version, "--seed", str(venv_path)]
logging.debug(f"Creating venv using cmd: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
result = subprocess.run(cmd, cwd=str(project_dir), capture_output=True, text=True)
logging.debug(f"venv creation STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}")
if result.returncode != 0:
if venv_path.exists():
shutil.rmtree(venv_path)
return InstallStatusResult(
False, f"Failed to create venv:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
False, f"Failed to create venv using uv:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)

res = self._install_dependencies(installer)
Expand All @@ -127,10 +153,42 @@ def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult:
shutil.rmtree(venv_path)
return res

if python_version != sys.executable:
(venv_path / _PYTHON_VERSION_MARKER).write_text(f"{python_version}\n", encoding="utf-8")

self.venv_path = installer.system.install_path / self.venv_name

return InstallStatusResult(True)

def _resolve_python_version(self, repo_path: Path) -> str:
if self.git_repo.python_version:
return self.git_repo.python_version.strip()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

repo_root = repo_path.resolve()
current = (repo_path / self.project_subpath if self.project_subpath else repo_path).resolve()
if current != repo_root and repo_root not in current.parents:
return sys.executable

while True:
version_file = current / ".python-version"
if version_file.is_file():
python_version = version_file.read_text(encoding="utf-8").strip()
if python_version:
return python_version

if current == repo_root:
break
current = current.parent

return sys.executable

@staticmethod
def _python_version_matches(venv_path: Path, python_version: str) -> bool:
if python_version == sys.executable:
return True
marker = venv_path / _PYTHON_VERSION_MARKER
return marker.is_file() and marker.read_text(encoding="utf-8").strip() == python_version

def _install_dependencies(self, installer: "BaseInstaller") -> InstallStatusResult:
venv_path = installer.system.install_path / self.venv_name

Expand Down
46 changes: 45 additions & 1 deletion tests/core/installables/test_git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@

import pytest

from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult
from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, TestDefinition
from cloudai.models.scenario import TestRunModel


@pytest.fixture
Expand Down Expand Up @@ -72,6 +73,49 @@ def test_git_repo_name(url: str, expected: str):
assert GitRepo(url=url, commit="commit").repo_name == expected


def test_python_version_does_not_change_git_clone_identity() -> None:
py311 = GitRepo(url="./repo", commit="main", python_version="3.11.9")
py314 = GitRepo(url="./repo", commit="main", python_version="3.14.0")

assert py311 == py314
assert hash(py311) == hash(py314)
assert py311.repo_name == py314.repo_name
assert py311.container_mount == py314.container_mount


def test_test_definition_git_repo_accepts_python_version() -> None:
tdef = TestDefinition.model_validate(
{
"name": "test",
"description": "description",
"test_template_name": "Example",
"cmd_args": {},
"git_repos": [{"url": "./repo", "commit": "main", "python_version": "3.11.9"}],
}
)

assert tdef.git_repos[0].python_version == "3.11.9"


def test_scenario_git_repo_accepts_and_preserves_python_version() -> None:
model = TestRunModel.model_validate(
{
"id": "case",
"test_name": "base-test",
"git_repos": [{"url": "./repo", "commit": "main", "python_version": "3.11.9"}],
}
)

assert model.git_repos is not None
assert model.git_repos[0].python_version == "3.11.9"


def test_git_repo_toml_without_python_version_remains_valid() -> None:
repo = GitRepo.model_validate({"url": "./repo", "commit": "main"})

assert repo.python_version is None


@pytest.mark.parametrize("init_submodules", [True, False])
def test_check_submodules_state_no_submodules(git_unmocked: GitRepo, init_submodules: bool):
git_unmocked.init_submodules = init_submodules
Expand Down
24 changes: 17 additions & 7 deletions tests/core/installables/test_python_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,30 @@ def test_python_environment_identity_uses_stable_configuration() -> None:
def test_python_environment_install_uses_uv(installer: BaseInstaller) -> None:
env = PythonEnvironment(name="aiconfigurator", python_version="3.10", requirements=["aiconfigurator~=0.5.0"])

with patch("shutil.which", return_value="/usr/bin/uv"), patch("subprocess.run") as run:
with (
patch(
"cloudai._core.installables.python_environment.uv.find_uv_bin",
return_value="/cloudai/bin/uv",
) as resolve_uv,
patch("subprocess.run") as run,
):
run.return_value = CompletedProcess(args=[], returncode=0, stdout="", stderr="")

res = env.install(installer)

assert res.success
resolve_uv.assert_called_once_with()
assert env.venv_path == installer.system.install_path / env.venv_name
assert run.call_args_list[0].args[0] == ["/usr/bin/uv", "python", "install", "3.10"]
assert run.call_args_list[0].args[0] == ["/cloudai/bin/uv", "python", "install", "3.10"]
assert run.call_args_list[1].args[0] == [
"/usr/bin/uv",
"/cloudai/bin/uv",
"venv",
"--python",
"3.10",
str(installer.system.install_path / env.venv_name),
]
assert run.call_args_list[2].args[0] == [
"/usr/bin/uv",
"/cloudai/bin/uv",
"pip",
"install",
"--python",
Expand All @@ -68,14 +75,17 @@ def test_python_environment_install_uses_uv(installer: BaseInstaller) -> None:
]


def test_python_environment_install_requires_uv(installer: BaseInstaller) -> None:
def test_python_environment_reports_uv_resolution_failure(installer: BaseInstaller) -> None:
env = PythonEnvironment(name="aiconfigurator", python_version="3.10")

with patch("shutil.which", return_value=None):
with patch(
"cloudai._core.installables.python_environment.uv.find_uv_bin",
side_effect=OSError("uv is unavailable"),
):
res = env.install(installer)

assert not res.success
assert res.message == "Cannot install Python environment: 'uv' is not available."
assert res.message == "Cannot install Python environment: uv is unavailable"


def test_python_environment_is_installed_checks_python_executable(installer: BaseInstaller) -> None:
Expand Down
Loading
Loading