From 6252e9c2136005bca54714bc7a99d61ad862224d Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 14:02:59 +0200 Subject: [PATCH 1/9] [Installables] Python Executable uses custom python version --- .github/workflows/ci.yml | 1 + .../workloads_requirements_installation.rst | 59 ++ pyproject.toml | 1 + src/cloudai/_core/installables/_uv.py | 29 + src/cloudai/_core/installables/git_repo.py | 28 +- .../_core/installables/python_environment.py | 8 +- .../_core/installables/python_executable.py | 231 ++++++- src/cloudai/models/workload.py | 10 +- tests/core/installables/test_git_repo.py | 123 +++- .../installables/test_python_environment.py | 33 +- .../installables/test_python_executable.py | 589 +++++++++++++++--- uv.lock | 28 + 12 files changed, 993 insertions(+), 147 deletions(-) create mode 100644 src/cloudai/_core/installables/_uv.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f452349c1..8cdbff7a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,7 @@ jobs: set -eEx set -o pipefail + .smoke-venv/bin/python -c "from pathlib import Path; from uv import find_uv_bin; assert Path(find_uv_bin()).is_file()" .smoke-venv/bin/cloudai --help # this checks that all TOMLs are valid, Test Scenarios are checked _only_ the tests in the specified directory diff --git a/doc/workloads/workloads_requirements_installation.rst b/doc/workloads/workloads_requirements_installation.rst index db3b5951f..e7711a287 100644 --- a/doc/workloads/workloads_requirements_installation.rst +++ b/doc/workloads/workloads_requirements_installation.rst @@ -4,6 +4,65 @@ Installation Requirements CloudAI workloads can define multiple installables as prerequisites. The installable can be a container image, git repository, HF model, etc. +Python Executables from Git Repositories +---------------------------------------- + +Some workloads wrap a git repository in a ``PythonExecutable`` and install the +repository in a dedicated virtual environment. Such an environment can use a +different Python interpreter from the one running CloudAI. Set ``python_version`` +on the repository to select that interpreter explicitly. + +In a test definition: + +.. code-block:: toml + + [[git_repos]] + url = "https://github.com/NVIDIA-NeMo/Run.git" + commit = "v0.10.0" + python_version = "3.11.9" + +In a test embedded in a scenario: + +.. code-block:: toml + + [[Tests.git_repos]] + url = "https://github.com/NVIDIA-NeMo/Run.git" + commit = "v0.10.0" + python_version = "3.11.9" + +CloudAI selects the interpreter for a ``PythonExecutable`` in this order: + +1. The repository's explicit ``python_version`` value. +2. The nearest ``.python-version`` file, searching from the executable's + project subdirectory towards the repository root. The search never leaves + the repository. +3. The interpreter running CloudAI (``sys.executable``), which preserves the + behavior of repositories without a Python version setting. + +Other version declarations, including ``.python-versions``, ``.tool-versions``, +``runtime.txt``, global uv configuration, and ``requires-python`` in +``pyproject.toml``, are not used for this selection. + +CloudAI ships the uv Python package and uses its bundled executable to create +these virtual environments; a separately installed ``uv`` command is not +required. If the selected interpreter is unavailable locally, uv can download +it during the first installation, so that installation requires network access +and can take longer than subsequent runs. See `uv Python version management`_ +for details. + +The ``python_version`` field does not by itself make a generic ``GitRepo`` +executable. Repositories used only as mounts are still cloned and mounted; the +field is consumed only by workloads that wrap the repository in a +``PythonExecutable``. + +After upgrading CloudAI, an existing virtual environment that uses a repository +``.python-version`` pin might be recreated once. CloudAI records the effective +interpreter request for future checks and rebuilds a pinned legacy environment +when that record is missing or no longer matches. + +.. _uv Python version management: https://docs.astral.sh/uv/concepts/python-versions/ + + Setting Up Access to the Private NGC Registry --------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 4507e5048..7bc8d3820 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/cloudai/_core/installables/_uv.py b/src/cloudai/_core/installables/_uv.py new file mode 100644 index 000000000..d7a5fa2f2 --- /dev/null +++ b/src/cloudai/_core/installables/_uv.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: NVIDIA CORPORATION & AFFILIATES +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uv + + +def resolve_uv_bin() -> str: + """Return the uv executable shipped with the current CloudAI installation.""" + try: + uv_bin = uv.find_uv_bin() + except Exception as e: + raise RuntimeError("Cannot locate the uv executable shipped with CloudAI.") from e + + if not uv_bin: + raise RuntimeError("Cannot locate the uv executable shipped with CloudAI.") + return str(uv_bin) diff --git a/src/cloudai/_core/installables/git_repo.py b/src/cloudai/_core/installables/git_repo.py index 38d1334cf..37e6c1915 100644 --- a/src/cloudai/_core/installables/git_repo.py +++ b/src/cloudai/_core/installables/git_repo.py @@ -17,8 +17,10 @@ import logging import shutil import subprocess +import threading +from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Iterator, Optional from pydantic import BaseModel, ConfigDict @@ -28,6 +30,21 @@ from ..base_installer import BaseInstaller +_REPO_LOCKS: dict[Path, threading.Lock] = {} +_REPO_LOCKS_GUARD = threading.Lock() + + +@contextmanager +def _repo_lock(repo_path: Path) -> Iterator[None]: + """Serialize operations on a checkout shared by multiple installables.""" + key = repo_path.resolve() + with _REPO_LOCKS_GUARD: + lock = _REPO_LOCKS.setdefault(key, threading.Lock()) + + with lock: + yield + + class GitRepo(Installable, BaseModel): """Git repository object.""" @@ -38,6 +55,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})" @@ -107,6 +125,10 @@ def ensure_submodules_state(self, repo_path: Path) -> tuple[bool, str]: def install(self, installer: "BaseInstaller") -> InstallStatusResult: repo_path = installer.system.install_path / self.repo_name + with _repo_lock(repo_path): + return self._install(installer, repo_path) + + def _install(self, installer: "BaseInstaller", repo_path: Path) -> InstallStatusResult: if repo_path.exists(): verify_res = self._verify_commit(self.commit, repo_path) if not verify_res.success: @@ -129,6 +151,10 @@ def install(self, installer: "BaseInstaller") -> InstallStatusResult: def uninstall(self, installer: "BaseInstaller") -> InstallStatusResult: logging.debug(f"Uninstalling git repository at {self.installed_path=}") repo_path = self.installed_path if self.installed_path else installer.system.install_path / self.repo_name + with _repo_lock(repo_path): + return self._uninstall(repo_path) + + def _uninstall(self, repo_path: Path) -> InstallStatusResult: if not repo_path.exists(): return InstallStatusResult(True, f"Repository {self.url} is not cloned.") diff --git a/src/cloudai/_core/installables/python_environment.py b/src/cloudai/_core/installables/python_environment.py index 9f2fbac09..9e31b93e8 100644 --- a/src/cloudai/_core/installables/python_environment.py +++ b/src/cloudai/_core/installables/python_environment.py @@ -24,6 +24,7 @@ import sys from typing import TYPE_CHECKING +from ._uv import resolve_uv_bin from .base import Installable, InstallStatusResult if TYPE_CHECKING: @@ -70,9 +71,10 @@ 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 = resolve_uv_bin() + except RuntimeError as e: + return InstallStatusResult(False, f"Cannot install Python environment: {e}") res = self._ensure_python_version(uv) if not res.success: diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index 55da19841..929415fc1 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -14,13 +14,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import hashlib +import json import logging import shutil import subprocess +import sys from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Optional +from ._uv import resolve_uv_bin from .base import Installable, InstallStatusResult from .git_repo import GitRepo @@ -28,6 +32,9 @@ from ..base_installer import BaseInstaller +_PYTHON_REQUEST_MARKER = ".cloudai-python-request" + + @dataclass class PythonExecutable(Installable): """Python executable object.""" @@ -39,15 +46,11 @@ class PythonExecutable(Installable): def __eq__(self, other: object) -> bool: """Check if two installable objects are equal.""" - return ( - isinstance(other, PythonExecutable) - and other.git_repo.url == self.git_repo.url - and other.git_repo.commit == self.git_repo.commit - ) + return isinstance(other, PythonExecutable) and other._identity() == self._identity() def __hash__(self) -> int: """Hash the installable object.""" - return self.git_repo.__hash__() + return hash(self._identity()) def __str__(self) -> str: """Return the string representation of the python executable.""" @@ -55,7 +58,13 @@ def __str__(self) -> str: @property def venv_name(self) -> str: - return f"{self.git_repo.repo_name}-venv" + base_name = f"{self.git_repo.repo_name}-venv" + if self._uses_default_environment_config(): + return base_name + + payload = json.dumps(self._identity(), separators=(",", ":"), ensure_ascii=True) + config_hash = hashlib.sha256(payload.encode()).hexdigest()[:12] + return f"{base_name}-{config_hash}" def install(self, installer: "BaseInstaller") -> InstallStatusResult: res = self.git_repo.install(installer) @@ -93,8 +102,22 @@ 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}") - self.venv_path = venv_path + python_path = self._python_path(venv_path) + if not python_path.is_file(): + return InstallStatusResult(False, f"Python executable does not exist at {python_path}") + + request_res = self._get_python_request(repo_path) + if isinstance(request_res, InstallStatusResult): + return request_res + python_request, is_pinned = request_res + if is_pinned and not self._request_marker_matches(venv_path, python_request): + return InstallStatusResult( + False, + f"Python interpreter request marker is missing or does not match {python_request!r}", + ) + + self.venv_path = venv_path return InstallStatusResult(True, "Python executable installed") def mark_as_installed(self, installer: "BaseInstaller") -> InstallStatusResult: @@ -104,30 +127,48 @@ def mark_as_installed(self, installer: "BaseInstaller") -> InstallStatusResult: def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: venv_path = installer.system.install_path / self.venv_name + repo_path = self.git_repo.installed_path or installer.system.install_path / self.git_repo.repo_name + project_dir = self._project_dir(repo_path) + request_res = self._get_python_request(repo_path) + if isinstance(request_res, InstallStatusResult): + return request_res + python_request, is_pinned = request_res + logging.debug(f"Creating virtual environment in {venv_path}") - if venv_path.exists(): - msg = f"Virtual environment already exists at {venv_path}." - logging.debug(msg) - return InstallStatusResult(True, msg) + existing_res = self._prepare_existing_venv(venv_path, python_request, is_pinned) + if existing_res is not None: + return existing_res + + if not project_dir.is_dir(): + return InstallStatusResult(False, f"Python project directory does not exist: {project_dir}") - cmd = ["python", "-m", "venv", str(venv_path)] + try: + uv = resolve_uv_bin() + except RuntimeError as e: + return InstallStatusResult(False, f"Cannot create virtual environment: {e}") + + cmd = [uv, "venv", "--python", python_request, "--seed", str(venv_path)] logging.debug(f"Creating venv using cmd: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True) + try: + result = subprocess.run(cmd, cwd=str(project_dir), capture_output=True, text=True) + except OSError as e: + return self._failure_with_cleanup(venv_path, f"Failed to create venv using uv: {e}") 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}" + return self._failure_with_cleanup( + venv_path, + f"Failed to create venv using uv:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", ) res = self._install_dependencies(installer) if not res.success: - if venv_path.exists(): - shutil.rmtree(venv_path) - return res + return self._failure_with_cleanup(venv_path, res.message) - self.venv_path = installer.system.install_path / self.venv_name + marker_res = self._write_request_marker(venv_path, python_request, is_pinned) + if marker_res is not None: + return marker_res + + self.venv_path = venv_path return InstallStatusResult(True) @@ -157,9 +198,12 @@ def _install_dependencies(self, installer: "BaseInstaller") -> InstallStatusResu return InstallStatusResult(False, "No pyproject.toml or requirements.txt found for installation.") def _install_pyproject(self, venv_dir: Path, project_dir: Path) -> InstallStatusResult: - install_cmd = [str(venv_dir / "bin" / "python"), "-m", "pip", "install", str(project_dir)] + install_cmd = [str(self._python_path(venv_dir)), "-m", "pip", "install", str(project_dir)] logging.debug(f"Installing dependencies using: {' '.join(install_cmd)}") - result = subprocess.run(install_cmd, capture_output=True, text=True) + try: + result = subprocess.run(install_cmd, capture_output=True, text=True) + except OSError as e: + return InstallStatusResult(False, f"Failed to install {project_dir} using pip: {e}") if result.returncode != 0: return InstallStatusResult(False, f"Failed to install {project_dir} using pip: {result.stderr}") @@ -170,11 +214,146 @@ def _install_requirements(self, venv_dir: Path, requirements_txt: Path) -> Insta if not requirements_txt.is_file(): return InstallStatusResult(False, f"Requirements file is invalid or does not exist: {requirements_txt}") - install_cmd = [str(venv_dir / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_txt)] + install_cmd = [ + str(self._python_path(venv_dir)), + "-m", + "pip", + "install", + "-r", + str(requirements_txt), + ] logging.debug(f"Installing dependencies using: {' '.join(install_cmd)}") - result = subprocess.run(install_cmd, capture_output=True, text=True) + try: + result = subprocess.run(install_cmd, capture_output=True, text=True) + except OSError as e: + return InstallStatusResult(False, f"Failed to install dependencies from requirements.txt: {e}") if result.returncode != 0: return InstallStatusResult(False, f"Failed to install dependencies from requirements.txt: {result.stderr}") return InstallStatusResult(True) + + def _identity(self) -> tuple[str, str, Optional[str], Optional[str], bool]: + python_version = self.git_repo.python_version + normalized_python_version = python_version.strip() if python_version is not None else None + project_subpath = Path(self.project_subpath).as_posix() if self.project_subpath is not None else None + return ( + self.git_repo.url, + self.git_repo.commit, + normalized_python_version, + project_subpath, + self.dependencies_from_pyproject, + ) + + def _uses_default_environment_config(self) -> bool: + return ( + self.git_repo.python_version is None and self.project_subpath is None and self.dependencies_from_pyproject + ) + + def _project_dir(self, repo_path: Path) -> Path: + return repo_path / self.project_subpath if self.project_subpath is not None else repo_path + + def _resolve_python_request(self, repo_path: Path) -> tuple[str, bool]: + """Resolve the uv Python request and whether it came from an explicit pin.""" + if self.git_repo.python_version is not None: + request = self.git_repo.python_version.strip() + if not request: + raise ValueError("Git repository python_version must not be empty.") + return request, True + + repo_root = repo_path.resolve() + current = self._project_dir(repo_path).resolve() + try: + current.relative_to(repo_root) + except ValueError: + return sys.executable, False + + while True: + version_file = current / ".python-version" + if version_file.is_file(): + try: + request = version_file.read_text(encoding="utf-8").strip() + except OSError as e: + raise RuntimeError(f"Failed to read Python version from {version_file}: {e}") from e + if not request: + raise ValueError(f"Python version file is empty: {version_file}") + return request, True + + if current == repo_root: + break + current = current.parent + + return sys.executable, False + + def _get_python_request(self, repo_path: Path) -> tuple[str, bool] | InstallStatusResult: + try: + return self._resolve_python_request(repo_path) + except (OSError, RuntimeError, ValueError) as e: + return InstallStatusResult(False, f"Failed to resolve Python interpreter request: {e}") + + @staticmethod + def _python_path(venv_path: Path) -> Path: + if sys.platform == "win32": + return venv_path / "Scripts" / "python.exe" + return venv_path / "bin" / "python" + + @staticmethod + def _request_marker_matches(venv_path: Path, python_request: str) -> bool: + marker = venv_path / _PYTHON_REQUEST_MARKER + try: + return marker.is_file() and marker.read_text(encoding="utf-8").strip() == python_request + except OSError: + return False + + def _prepare_existing_venv( + self, venv_path: Path, python_request: str, is_pinned: bool + ) -> Optional[InstallStatusResult]: + if not venv_path.exists(): + return None + + has_python = self._python_path(venv_path).is_file() + has_matching_request = not is_pinned or self._request_marker_matches(venv_path, python_request) + if has_python and has_matching_request: + self.venv_path = venv_path + msg = f"Virtual environment already exists at {venv_path}." + logging.debug(msg) + return InstallStatusResult(True, msg) + + logging.info(f"Recreating stale virtual environment at {venv_path}") + try: + self._cleanup_venv(venv_path) + except OSError as e: + return InstallStatusResult(False, f"Failed to remove stale virtual environment {venv_path}: {e}") + return None + + @classmethod + def _write_request_marker( + cls, venv_path: Path, python_request: str, is_pinned: bool + ) -> Optional[InstallStatusResult]: + if not is_pinned: + return None + + marker = venv_path / _PYTHON_REQUEST_MARKER + try: + marker.write_text(f"{python_request}\n", encoding="utf-8") + except OSError as e: + return cls._failure_with_cleanup( + venv_path, + f"Failed to record Python interpreter request {python_request!r} in {marker}: {e}", + ) + return None + + @staticmethod + def _cleanup_venv(venv_path: Path) -> None: + if venv_path.is_symlink() or venv_path.is_file(): + venv_path.unlink() + elif venv_path.exists(): + shutil.rmtree(venv_path) + + @classmethod + def _failure_with_cleanup(cls, venv_path: Path, message: str) -> InstallStatusResult: + try: + cls._cleanup_venv(venv_path) + except OSError as e: + message = f"{message}\nFailed to clean up partial virtual environment {venv_path}: {e}" + return InstallStatusResult(False, message) diff --git a/src/cloudai/models/workload.py b/src/cloudai/models/workload.py index 22c3c04ad..efbffe259 100644 --- a/src/cloudai/models/workload.py +++ b/src/cloudai/models/workload.py @@ -75,20 +75,12 @@ def cmd_args(self) -> list[str]: return parts -@dataclass +@dataclass(eq=False) class PredictorConfig(PythonExecutable): """Predictor configuration.""" bin_name: Optional[str] = None - def __hash__(self) -> int: - """ - Hash the PredictorConfig. - - It is based on git repo on purpose to avoid re-downloading the same repo for multiple scripts. - """ - return self.git_repo.__hash__() - class TrainingReportConfig(BaseModel): """Training-report aggregation window: steps excluded before computing per-metric stats.""" diff --git a/tests/core/installables/test_git_repo.py b/tests/core/installables/test_git_repo.py index 10e602458..9b5e2ab91 100644 --- a/tests/core/installables/test_git_repo.py +++ b/tests/core/installables/test_git_repo.py @@ -14,14 +14,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +import threading +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from subprocess import CompletedProcess from typing import Iterator from unittest.mock import MagicMock, patch import pytest +import toml -from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult +from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, TestDefinition +from cloudai.models.scenario import TestRunModel @pytest.fixture @@ -72,6 +76,82 @@ def test_git_repo_name(url: str, expected: str): assert GitRepo(url=url, commit="commit").repo_name == expected +def test_python_version_is_optional_and_round_trips() -> None: + legacy = GitRepo.model_validate({"url": "./repo", "commit": "main"}) + pinned = GitRepo.model_validate({"url": "./repo", "commit": "main", "python_version": "3.11.9"}) + + assert legacy.python_version is None + assert pinned.python_version == "3.11.9" + assert pinned.model_dump()["python_version"] == "3.11.9" + + +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: + data = toml.loads( + """ +name = "test" +description = "description" +test_template_name = "Example" + +[cmd_args] + +[[git_repos]] +url = "./repo" +commit = "main" +python_version = "3.11.9" +""" + ) + tdef = TestDefinition.model_validate(data) + + assert tdef.git_repos[0].python_version == "3.11.9" + assert tdef.model_dump()["git_repos"][0]["python_version"] == "3.11.9" + + +def test_scenario_git_repo_accepts_and_preserves_python_version() -> None: + data = toml.loads( + """ +name = "scenario" + +[[Tests]] +id = "case" +test_name = "base-test" + +[[Tests.git_repos]] +url = "./repo" +commit = "main" +python_version = "3.11.9" +""" + ) + model = TestRunModel.model_validate(data["Tests"][0]) + + assert model.git_repos is not None + assert model.git_repos[0].python_version == "3.11.9" + assert model.tdef_model_dump(by_alias=True)["git_repos"][0]["python_version"] == "3.11.9" + + +def test_legacy_git_repo_toml_without_python_version_remains_valid() -> None: + data = toml.loads( + """ +[[git_repos]] +url = "./repo" +commit = "main" +""" + ) + + repo = GitRepo.model_validate(data["git_repos"][0]) + + 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 @@ -266,6 +346,47 @@ def test_repo_exists_with_wrong_commit(installer: BaseInstaller, git: GitRepo): assert res.message == "wrong commit" +def test_concurrent_python_variants_clone_shared_repo_once(installer: BaseInstaller) -> None: + py311 = GitRepo(url="./shared_repo", commit="commit_hash", python_version="3.11.9") + py314 = GitRepo(url="./shared_repo", commit="commit_hash", python_version="3.14.0") + first_clone_started = threading.Event() + second_clone_started = threading.Event() + release_clone = threading.Event() + calls_lock = threading.Lock() + clone_calls = 0 + + def clone_repository(item: GitRepo, installer: BaseInstaller, path: Path) -> InstallStatusResult: + nonlocal clone_calls + with calls_lock: + clone_calls += 1 + call_number = clone_calls + if call_number == 1: + first_clone_started.set() + else: + second_clone_started.set() + assert release_clone.wait(timeout=2) + path.mkdir(parents=True, exist_ok=True) + return InstallStatusResult(True) + + with ( + patch.object(GitRepo, "_clone_repository", autospec=True, side_effect=clone_repository), + patch.object(GitRepo, "_checkout_commit", return_value=InstallStatusResult(True)), + patch.object(GitRepo, "_verify_commit", return_value=InstallStatusResult(True)), + patch.object(GitRepo, "ensure_submodules_state", return_value=(True, "")), + ThreadPoolExecutor(max_workers=2) as executor, + ): + first = executor.submit(py311.install, installer) + assert first_clone_started.wait(timeout=2) + second = executor.submit(py314.install, installer) + assert not second_clone_started.wait(timeout=0.1), "second clone was not serialized by repository path" + release_clone.set() + results = [first.result(timeout=2), second.result(timeout=2)] + + assert all(result.success for result in results) + assert clone_calls == 1 + assert py311.installed_path == py314.installed_path == installer.system.install_path / py311.repo_name + + def test_repo_cloned(installer: BaseInstaller, git: GitRepo): repo_path = installer.system.install_path / git.repo_name with patch("subprocess.run") as mock_run: diff --git a/tests/core/installables/test_python_environment.py b/tests/core/installables/test_python_environment.py index 243ecd2c1..aaf4b55f3 100644 --- a/tests/core/installables/test_python_environment.py +++ b/tests/core/installables/test_python_environment.py @@ -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.resolve_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", @@ -68,14 +75,26 @@ 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_bundled_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.resolve_uv_bin", + side_effect=RuntimeError("bundled 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: bundled uv is unavailable" + + +def test_packaged_uv_resolver_uses_public_uv_api() -> None: + from cloudai._core.installables._uv import resolve_uv_bin + + with patch("cloudai._core.installables._uv.uv.find_uv_bin", return_value="/cloudai/bin/uv") as find_uv_bin: + assert resolve_uv_bin() == "/cloudai/bin/uv" + + find_uv_bin.assert_called_once_with() def test_python_environment_is_installed_checks_python_executable(installer: BaseInstaller) -> None: diff --git a/tests/core/installables/test_python_executable.py b/tests/core/installables/test_python_executable.py index 69944d2f0..4c49c3df1 100644 --- a/tests/core/installables/test_python_executable.py +++ b/tests/core/installables/test_python_executable.py @@ -14,13 +14,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import subprocess +import sys +import threading +import time from pathlib import Path from subprocess import CompletedProcess from unittest.mock import patch import pytest -from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, PythonExecutable +from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, PredictorConfig, PythonExecutable @pytest.fixture @@ -29,7 +33,7 @@ def git() -> GitRepo: @pytest.fixture -def installer(slurm_system): +def installer(slurm_system) -> BaseInstaller: installer = BaseInstaller(slurm_system) installer.system.install_path.mkdir(parents=True) installer._check_low_thread_environment = lambda threshold=None: False @@ -53,182 +57,508 @@ def setup_repo(installer: BaseInstaller, git: GitRepo): return repo_dir, subdir, pyproject_file, requirements_file -def test_venv_created(installer: BaseInstaller, git: GitRepo): - py = PythonExecutable(git) +def _create_python_file(venv_path: Path) -> Path: + python_path = venv_path / "bin" / "python" + python_path.parent.mkdir(parents=True, exist_ok=True) + python_path.touch() + return python_path + + +def test_explicit_python_version_overrides_repository_pin(tmp_path: Path) -> None: + repo_path = tmp_path / "repo" + project_dir = repo_path / "package" + project_dir.mkdir(parents=True) + (project_dir / ".python-version").write_text("3.10.16\n") + py = PythonExecutable( + GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9"), + project_subpath=Path("package"), + ) + + assert py._resolve_python_request(repo_path) == ("3.11.9", True) + + +def test_nearest_python_version_is_used_from_project_subpath(tmp_path: Path) -> None: + repo_path = tmp_path / "repo" + project_dir = repo_path / "packages" / "nested" / "project" + project_dir.mkdir(parents=True) + (repo_path / ".python-version").write_text("3.10.16\n") + (repo_path / "packages" / ".python-version").write_text("3.11.9\n") + py = PythonExecutable( + GitRepo(url="./git_url", commit="commit_hash"), + project_subpath=Path("packages/nested/project"), + ) + + assert py._resolve_python_request(repo_path) == ("3.11.9", True) + + +def test_python_version_lookup_is_bounded_by_repository_root(tmp_path: Path) -> None: + repo_path = tmp_path / "repo" + project_dir = repo_path / "package" + project_dir.mkdir(parents=True) + (tmp_path / ".python-version").write_text("9.9.9\n") + py = PythonExecutable( + GitRepo(url="./git_url", commit="commit_hash"), + project_subpath=Path("package"), + ) + + with patch("cloudai._core.installables.python_executable.sys.executable", "/cloudai/bin/python"): + assert py._resolve_python_request(repo_path) == ("/cloudai/bin/python", False) + + +@pytest.mark.parametrize("filename", [".python-versions", ".tool-versions", "runtime.txt", "pyproject.toml"]) +def test_unrelated_python_version_files_are_not_inspected(tmp_path: Path, filename: str) -> None: + repo_path = tmp_path / "repo" + repo_path.mkdir() + (repo_path / filename).write_text("3.11.9\n") + py = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash")) + + with patch("cloudai._core.installables.python_executable.sys.executable", "/cloudai/bin/python"): + assert py._resolve_python_request(repo_path) == ("/cloudai/bin/python", False) + + +def test_venv_created_with_bundled_uv_and_selected_interpreter(installer: BaseInstaller) -> None: + git = GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9") + py = PythonExecutable(git, project_subpath=Path("package")) + repo_path = installer.system.install_path / git.repo_name + project_dir = repo_path / "package" + project_dir.mkdir(parents=True) + git.installed_path = repo_path venv_path = installer.system.install_path / py.venv_name + with ( + patch( + "cloudai._core.installables.python_executable.resolve_uv_bin", + return_value="/cloudai/bin/uv", + ) as resolve_uv, patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), - patch("subprocess.run") as mock_run, + patch("subprocess.run") as run, ): - mock_run.return_value = CompletedProcess(args=[], returncode=0) + + def create_venv(*args, **kwargs): + _create_python_file(venv_path) + return CompletedProcess(args=args, returncode=0, stdout="", stderr="") + + run.side_effect = create_venv res = py._create_venv(installer) + assert res.success - mock_run.assert_called_once_with(["python", "-m", "venv", str(venv_path)], capture_output=True, text=True) + resolve_uv.assert_called_once_with() + run.assert_called_once_with( + ["/cloudai/bin/uv", "venv", "--python", "3.11.9", "--seed", str(venv_path)], + cwd=str(project_dir), + capture_output=True, + text=True, + ) + assert (venv_path / ".cloudai-python-request").read_text().strip() == "3.11.9" -@pytest.mark.parametrize("failure_on_venv_creation,reqs_install_failure", [(True, False), (False, True)]) -def test_error_creating_venv( +@pytest.mark.parametrize("failure_stage", ["venv", "dependencies"]) +def test_failed_installation_removes_partial_venv( installer: BaseInstaller, - git: GitRepo, - failure_on_venv_creation: bool, - reqs_install_failure: bool, -): + failure_stage: str, +) -> None: + git = GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9") py = PythonExecutable(git) + repo_path = installer.system.install_path / git.repo_name + repo_path.mkdir() + git.installed_path = repo_path venv_path = installer.system.install_path / py.venv_name - def mock_run(*args, **kwargs): - venv_path.mkdir() - if failure_on_venv_creation and "venv" in args[0]: - return CompletedProcess(args=args, returncode=1, stderr="err") - return CompletedProcess(args=args, returncode=0) + def create_partial_venv(*args, **kwargs): + venv_path.mkdir(parents=True) + return CompletedProcess(args=args, returncode=1 if failure_stage == "venv" else 0, stderr="err") - dependencies_result = InstallStatusResult(False, "err") if reqs_install_failure else InstallStatusResult(True) + dependencies_result = ( + InstallStatusResult(False, "dependency error") if failure_stage == "dependencies" else InstallStatusResult(True) + ) with ( + patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), patch.object(PythonExecutable, "_install_dependencies", return_value=dependencies_result), - patch("subprocess.run", side_effect=mock_run), + patch("subprocess.run", side_effect=create_partial_venv), ): res = py._create_venv(installer) + assert not res.success - if failure_on_venv_creation: - assert res.message == "Failed to create venv:\nSTDOUT:\nNone\nSTDERR:\nerr" - else: - assert res.message == "err" - assert not venv_path.exists(), "venv folder wasn't removed after unsuccessful installation" + assert "err" in res.message + assert not venv_path.exists() + assert py.venv_path is None -def test_venv_already_exists(installer: BaseInstaller, git: GitRepo): +@pytest.mark.parametrize("marker_value", [None, "3.10.16"]) +def test_stale_pinned_legacy_venv_is_recreated( + installer: BaseInstaller, + git: GitRepo, + marker_value: str | None, +) -> None: py = PythonExecutable(git) + repo_path = installer.system.install_path / git.repo_name + repo_path.mkdir() + (repo_path / ".python-version").write_text("3.11.9\n") + git.installed_path = repo_path venv_path = installer.system.install_path / py.venv_name - venv_path.mkdir() - with patch("subprocess.run") as mock_run: - mock_run.return_value = CompletedProcess(args=[], returncode=1, stderr="err") + _create_python_file(venv_path) + stale_file = venv_path / "stale" + stale_file.touch() + marker = venv_path / ".cloudai-python-request" + if marker_value is not None: + marker.write_text(marker_value) + + def recreate_venv(*args, **kwargs): + assert not stale_file.exists() + _create_python_file(venv_path) + return CompletedProcess(args=args, returncode=0, stdout="", stderr="") + + with ( + patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), + patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), + patch("subprocess.run", side_effect=recreate_venv) as run, + ): + res = py._create_venv(installer) + + assert res.success + run.assert_called_once() + assert marker.read_text().strip() == "3.11.9" + assert not stale_file.exists() + + +def test_matching_marker_keeps_existing_pinned_venv(installer: BaseInstaller, git: GitRepo) -> None: + py = PythonExecutable(git) + repo_path = installer.system.install_path / git.repo_name + repo_path.mkdir() + (repo_path / ".python-version").write_text("3.11.9\n") + git.installed_path = repo_path + venv_path = installer.system.install_path / py.venv_name + _create_python_file(venv_path) + (venv_path / ".cloudai-python-request").write_text("3.11.9") + + with patch("subprocess.run") as run: res = py._create_venv(installer) - assert mock_run.call_count == 0 + assert res.success assert res.message == f"Virtual environment already exists at {venv_path}." + run.assert_not_called() -def test_requirements_no_file(installer: BaseInstaller, git: GitRepo): +@pytest.mark.parametrize("marker_value", [None, "3.10.16"]) +def test_is_installed_rejects_missing_or_mismatched_marker_for_pinned_environment( + installer: BaseInstaller, + git: GitRepo, + marker_value: str | None, +) -> None: py = PythonExecutable(git) + repo_path = installer.system.install_path / git.repo_name + repo_path.mkdir() + (repo_path / ".python-version").write_text("3.11.9\n") venv_path = installer.system.install_path / py.venv_name - venv_path.mkdir() - res = py._install_requirements(venv_path, installer.system.install_path / "requirements.txt") + _create_python_file(venv_path) + if marker_value is not None: + (venv_path / ".cloudai-python-request").write_text(marker_value) + + res = py.is_installed(installer) + assert not res.success - assert ( - res.message - == f"Requirements file is invalid or does not exist: {installer.system.install_path / 'requirements.txt'}" - ) + assert "Python interpreter request" in res.message + assert py.venv_path is None -def test_requirements_installed(installer: BaseInstaller): - requirements_file = installer.system.install_path / "requirements.txt" - venv_path = installer.system.install_path / "venv" - requirements_file.touch() - with patch("subprocess.run") as mock_run: - mock_run.return_value = CompletedProcess(args=[], returncode=0) - res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( - venv_path, requirements_file - ) +def test_is_installed_accepts_matching_marker_for_pinned_environment( + installer: BaseInstaller, + git: GitRepo, +) -> None: + py = PythonExecutable(git) + repo_path = installer.system.install_path / git.repo_name + repo_path.mkdir() + (repo_path / ".python-version").write_text("3.11.9\n") + venv_path = installer.system.install_path / py.venv_name + _create_python_file(venv_path) + (venv_path / ".cloudai-python-request").write_text("3.11.9") + + res = py.is_installed(installer) + assert res.success - mock_run.assert_called_once_with( - [str(venv_path / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_file)], - capture_output=True, - text=True, - ) + assert py.venv_path == venv_path -def test_requirements_not_installed(installer: BaseInstaller): - requirements_file = installer.system.install_path / "requirements.txt" - requirements_file.touch() - with patch("subprocess.run") as mock_run: - mock_run.return_value = CompletedProcess(args=[], returncode=1, stderr="err") - res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( - installer.system.install_path, requirements_file - ) +def test_is_installed_preserves_unpinned_legacy_venv_without_marker( + installer: BaseInstaller, + git: GitRepo, +) -> None: + py = PythonExecutable(git) + (installer.system.install_path / git.repo_name).mkdir() + venv_path = installer.system.install_path / py.venv_name + _create_python_file(venv_path) + + res = py.is_installed(installer) + + assert res.success + assert py.venv_path == venv_path + + +def test_is_installed_requires_python_executable(installer: BaseInstaller, git: GitRepo) -> None: + py = PythonExecutable(git) + (installer.system.install_path / git.repo_name).mkdir() + (installer.system.install_path / py.venv_name).mkdir() + + res = py.is_installed(installer) + assert not res.success - assert res.message == "Failed to install dependencies from requirements.txt: err" + assert "Python executable" in res.message + assert py.venv_path is None + + +def test_python_executable_identity_and_venv_name_include_environment_configuration() -> None: + default = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash")) + same = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash")) + py311 = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) + py311_same = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) + py312 = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.12.8")) + subproject = PythonExecutable( + GitRepo(url="./git_url", commit="commit_hash"), + project_subpath=Path("package"), + ) + requirements_first = PythonExecutable( + GitRepo(url="./git_url", commit="commit_hash"), + dependencies_from_pyproject=False, + ) + + assert default == same + assert hash(default) == hash(same) + assert default.venv_name == "git_url__commit_hash-venv" + assert py311 == py311_same + assert hash(py311) == hash(py311_same) + assert len({default, py311, py312, subproject, requirements_first}) == 5 + assert ( + len( + { + default.venv_name, + py311.venv_name, + py312.venv_name, + subproject.venv_name, + requirements_first.venv_name, + } + ) + == 5 + ) + assert py311.venv_name.startswith(f"{default.venv_name}-") + assert len(py311.venv_name.removeprefix(f"{default.venv_name}-")) == 12 -def test_all_good_flow(installer: BaseInstaller, git: GitRepo): +def test_repository_detected_pin_preserves_legacy_venv_name(git: GitRepo) -> None: py = PythonExecutable(git) - py.git_repo.installed_path = installer.system.install_path / py.git_repo.repo_name - repo_dir = py.git_repo.installed_path - repo_dir.mkdir(parents=True, exist_ok=True) - pyproject_file = repo_dir / "pyproject.toml" - pyproject_file.write_text("[tool.poetry]\nname = 'dummy_project'") + assert py.venv_name == f"{git.repo_name}-venv" + + +def test_string_project_subpath_remains_compatible(git: GitRepo) -> None: + py = PythonExecutable(git, project_subpath="package") # type: ignore[arg-type] + + assert py.venv_name.startswith(f"{git.repo_name}-venv-") - with patch("subprocess.run") as mock_run: - mock_run.return_value = CompletedProcess(args=[], returncode=0, stdout=f"{git.commit}\n", stderr="") - res = py.install(installer) + +def test_installer_creates_distinct_explicit_variants_while_serializing_shared_repo( + installer: BaseInstaller, +) -> None: + py311 = PythonExecutable(GitRepo(url="./shared_repo", commit="commit", python_version="3.11.9")) + py314 = PythonExecutable(GitRepo(url="./shared_repo", commit="commit", python_version="3.14.0")) + original_install = GitRepo._install + state_lock = threading.Lock() + active_repo_operations = 0 + max_active_repo_operations = 0 + clone_calls = 0 + + def track_repo_install(item: GitRepo, context: BaseInstaller, repo_path: Path) -> InstallStatusResult: + nonlocal active_repo_operations, max_active_repo_operations + with state_lock: + active_repo_operations += 1 + max_active_repo_operations = max(max_active_repo_operations, active_repo_operations) + try: + time.sleep(0.05) + return original_install(item, context, repo_path) + finally: + with state_lock: + active_repo_operations -= 1 + + def clone_repo(item: GitRepo, context: BaseInstaller, repo_path: Path) -> InstallStatusResult: + nonlocal clone_calls + clone_calls += 1 + repo_path.mkdir(parents=True) + return InstallStatusResult(True) + + def create_venv(item: PythonExecutable, context: BaseInstaller) -> InstallStatusResult: + item.venv_path = context.system.install_path / item.venv_name + _create_python_file(item.venv_path) + return InstallStatusResult(True) + + with ( + patch.object(GitRepo, "_install", autospec=True, side_effect=track_repo_install), + patch.object(GitRepo, "_clone_repository", autospec=True, side_effect=clone_repo), + patch.object(GitRepo, "_checkout_commit", return_value=InstallStatusResult(True)), + patch.object(GitRepo, "_verify_commit", return_value=InstallStatusResult(True)), + patch.object(GitRepo, "ensure_submodules_state", return_value=(True, "")), + patch.object(PythonExecutable, "_create_venv", autospec=True, side_effect=create_venv), + ): + res = installer.install([py311, py314]) + + assert res.success + assert max_active_repo_operations == 1 + assert clone_calls == 1 + assert py311.git_repo.installed_path == py314.git_repo.installed_path + assert py311.venv_path != py314.venv_path + assert py311.venv_path is not None and py311.venv_path.exists() + assert py314.venv_path is not None and py314.venv_path.exists() + + +def test_predictor_identity_matches_python_executable_identity() -> None: + predictor = PredictorConfig( + git_repo=GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9"), + bin_name="predict-a", + ) + same_environment = PredictorConfig( + git_repo=GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9"), + bin_name="predict-b", + ) + different_environment = PredictorConfig( + git_repo=GitRepo(url="./git_url", commit="commit_hash", python_version="3.12.8"), + bin_name="predict-a", + ) + + assert predictor == same_environment + assert hash(predictor) == hash(same_environment) + assert predictor != different_environment + + +def test_mark_as_installed_remains_path_only(installer: BaseInstaller) -> None: + py = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) + + res = py.mark_as_installed(installer) assert res.success assert py.git_repo.installed_path == installer.system.install_path / py.git_repo.repo_name assert py.venv_path == installer.system.install_path / py.venv_name + assert py.git_repo.installed_path is not None + assert py.venv_path is not None + assert not py.git_repo.installed_path.exists() + assert not py.venv_path.exists() -def test_is_installed_no_repo(installer: BaseInstaller, git: GitRepo): +def test_is_installed_no_repo(installer: BaseInstaller, git: GitRepo) -> None: py = PythonExecutable(git) + res = py.is_installed(installer) + assert not res.success assert res.message == f"Git repository {py.git_repo.url} not cloned" - assert not (installer.system.install_path / py.git_repo.repo_name).exists() - assert not py.git_repo.installed_path - assert not (installer.system.install_path / py.venv_name).exists() - assert not py.venv_path + assert py.git_repo.installed_path is None + assert py.venv_path is None -def test_is_installed_no_venv(installer: BaseInstaller, git: GitRepo): +def test_is_installed_no_venv(installer: BaseInstaller, git: GitRepo) -> None: py = PythonExecutable(git) (installer.system.install_path / py.git_repo.repo_name).mkdir() + res = py.is_installed(installer) + assert not res.success assert res.message == f"Virtual environment not created for {py.git_repo.url}" assert py.git_repo.installed_path == installer.system.install_path / py.git_repo.repo_name - assert (installer.system.install_path / py.git_repo.repo_name).exists() - assert not (installer.system.install_path / py.venv_name).exists() - assert not py.venv_path - - -def test_is_installed_ok(installer: BaseInstaller, git: GitRepo): - py = PythonExecutable(git) - (installer.system.install_path / py.git_repo.repo_name).mkdir() - (installer.system.install_path / py.venv_name).mkdir() - res = py.is_installed(installer) - assert res.success - assert res.message == "Python executable installed" - assert py.git_repo.installed_path == installer.system.install_path / py.git_repo.repo_name - assert (installer.system.install_path / py.git_repo.repo_name).exists() - assert py.venv_path == installer.system.install_path / py.venv_name - assert py.venv_path + assert py.venv_path is None -def test_uninstall_no_venv(installer: BaseInstaller, git: GitRepo): +def test_uninstall_no_venv(installer: BaseInstaller, git: GitRepo) -> None: py = PythonExecutable(git) py.venv_path = installer.system.install_path / py.venv_name + res = py.uninstall(installer) + assert res.success assert res.message == f"Virtual environment {py.venv_name} is not created." -def test_uninstall_venv_removed_ok(installer: BaseInstaller, git: GitRepo): +def test_uninstall_venv_removed_ok(installer: BaseInstaller, git: GitRepo) -> None: py = PythonExecutable(git) (installer.system.install_path / py.venv_name).mkdir() (installer.system.install_path / py.venv_name / "file").touch() py.venv_path = installer.system.install_path / py.venv_name + res = py.uninstall(installer) + assert res.success assert not (installer.system.install_path / py.venv_name).exists() - assert not py.venv_path + assert py.venv_path is None + + +def test_requirements_no_file(installer: BaseInstaller, git: GitRepo) -> None: + py = PythonExecutable(git) + venv_path = installer.system.install_path / py.venv_name + venv_path.mkdir() + + res = py._install_requirements(venv_path, installer.system.install_path / "requirements.txt") + + assert not res.success + assert ( + res.message + == f"Requirements file is invalid or does not exist: {installer.system.install_path / 'requirements.txt'}" + ) + + +def test_requirements_are_installed_with_venv_python(installer: BaseInstaller) -> None: + requirements_file = installer.system.install_path / "requirements.txt" + venv_path = installer.system.install_path / "venv" + requirements_file.touch() + + with patch("subprocess.run") as run: + run.return_value = CompletedProcess(args=[], returncode=0) + res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( + venv_path, requirements_file + ) + + assert res.success + run.assert_called_once_with( + [str(venv_path / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_file)], + capture_output=True, + text=True, + ) + + +def test_pyproject_is_installed_with_venv_python(installer: BaseInstaller) -> None: + project_dir = installer.system.install_path / "project" + venv_path = installer.system.install_path / "venv" + project_dir.mkdir() + + with patch("subprocess.run") as run: + run.return_value = CompletedProcess(args=[], returncode=0) + res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_pyproject( + venv_path, project_dir + ) + + assert res.success + run.assert_called_once_with( + [str(venv_path / "bin" / "python"), "-m", "pip", "install", str(project_dir)], + capture_output=True, + text=True, + ) + + +def test_requirements_installation_failure_is_reported(installer: BaseInstaller) -> None: + requirements_file = installer.system.install_path / "requirements.txt" + requirements_file.touch() + + with patch("subprocess.run") as run: + run.return_value = CompletedProcess(args=[], returncode=1, stderr="err") + res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( + installer.system.install_path, requirements_file + ) + + assert not res.success + assert res.message == "Failed to install dependencies from requirements.txt: err" def test_install_python_executable_prefers_pyproject_toml( installer: BaseInstaller, git: GitRepo, setup_repo, -): +) -> None: repo_dir, subdir, _, _ = setup_repo - py = PythonExecutable(git, project_subpath=Path("subdir"), dependencies_from_pyproject=True) py.git_repo.installed_path = repo_dir @@ -247,9 +577,8 @@ def test_install_python_executable_prefers_requirements_txt( installer: BaseInstaller, git: GitRepo, setup_repo, -): - repo_dir, *_ = setup_repo - +) -> None: + repo_dir, subdir, _, _ = setup_repo py = PythonExecutable(git, project_subpath=Path("subdir"), dependencies_from_pyproject=False) py.git_repo.installed_path = repo_dir @@ -261,4 +590,64 @@ def test_install_python_executable_prefers_requirements_txt( assert res.success pyproject.assert_not_called() - reqs.assert_called_once() + reqs.assert_called_once_with(installer.system.install_path / py.venv_name, subdir / "requirements.txt") + + +@pytest.mark.ci_only +def test_python_executable_installs_repository_pinned_python_3119( + installer: BaseInstaller, + tmp_path: Path, +) -> None: + if sys.version_info[:2] != (3, 14): + pytest.skip("This interpreter-independence integration test requires the Python 3.14 CI job.") + + source_repo = tmp_path / "source-repo" + source_repo.mkdir() + (source_repo / ".python-version").write_text("3.11.9\n") + (source_repo / "requirements.txt").touch() + subprocess.run( + ["git", "init", "--initial-branch=main", str(source_repo)], + check=True, + capture_output=True, + text=True, + ) + subprocess.run(["git", "add", "."], cwd=source_repo, check=True, capture_output=True, text=True) + subprocess.run( + [ + "git", + "-c", + "user.name=CloudAI Tests", + "-c", + "user.email=cloudai-tests@nvidia.com", + "commit", + "-m", + "Add pinned Python project", + ], + cwd=source_repo, + check=True, + capture_output=True, + text=True, + ) + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=source_repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + git = GitRepo(url=str(source_repo), commit=commit) + py = PythonExecutable(git) + + res = py.install(installer) + + assert res.success, res.message + assert git.installed_path == installer.system.install_path / git.repo_name + assert py.venv_path is not None + version = subprocess.run( + [str(py.venv_path / "bin" / "python"), "-c", "import platform; print(platform.python_version())"], + check=True, + capture_output=True, + text=True, + ) + assert version.stdout.strip() == "3.11.9" diff --git a/uv.lock b/uv.lock index 4405ede12..e60af1b2a 100644 --- a/uv.lock +++ b/uv.lock @@ -281,6 +281,7 @@ dependencies = [ { name = "rich" }, { name = "tbparse" }, { name = "toml" }, + { name = "uv" }, { name = "websockets" }, ] @@ -360,6 +361,7 @@ requires-dist = [ { name = "taplo", marker = "extra == 'dev'", specifier = "~=0.9.3" }, { name = "tbparse", specifier = "~=0.0.9" }, { name = "toml", specifier = "~=0.10.2" }, + { name = "uv", specifier = "~=0.12.10" }, { name = "vulture", marker = "extra == 'dev'", specifier = "==2.14" }, { name = "websockets", specifier = "~=16.0" }, ] @@ -2630,6 +2632,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uv" +version = "0.12.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/92/167ad4b0795530383a84560dc8bc8c0afab6b6fb10baba34b2a257fa6894/uv-0.12.10.tar.gz", hash = "sha256:27e8350faffb35d8ecaa5ed39236709809bb9a883f1d11d13c0964d6051b2384", size = 7144864, upload-time = "2026-09-04T23:14:21.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d1/b3752f00e67e5bf141adec0225c62a17c1cf3fb524db463c541c080f1237/uv-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:efbbc586d40014b9dd3bc37a2ad5803d12919458f0f9d16f122db5d91626f5ab", size = 22182923, upload-time = "2026-09-04T23:13:31.502Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4d/ef846ec0a4dfa8d4f47b96a87a1e331143be5a045c28c8b4b0374946455f/uv-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e3a70d9dff95481afccd7d41e7ec42fdc230d53487b3e7e633f9b35610401b5", size = 20494379, upload-time = "2026-09-04T23:13:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/5614e9a940fe1bf291342410b2cde9fe35a1430135ae7434ea8fd67033f8/uv-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5dc26c73826d2119292d49d71c5a9d5ba9dd97dd02459d816b8226f47f3dc6bd", size = 17238363, upload-time = "2026-09-04T23:13:36.905Z" }, + { url = "https://files.pythonhosted.org/packages/d1/03/e71936935b8ac26390b280e102b9db823e90adb1134f7e226538054fdece/uv-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bd0afae6918795c6a61a649e64d735e339f3d62f8310235da6f19eb3f66f323b", size = 21590025, upload-time = "2026-09-04T23:13:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c0/c6388a722fb9db8adf8ec8eca762547b5a06ccbd1176571dc584636af5f1/uv-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:c1fd0fcabc18dccaf541ca44a061c245045c937bec75c0491e94d59cd33ca97f", size = 21677222, upload-time = "2026-09-04T23:13:42.218Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c6/96698aed29f25ff289b28b6312c72e260c56f7e1de2e3f690788bddea19e/uv-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de4b06934bd025ceeeb48af5c6f9a82407a2fe2b5b35e456cfb3198ef5029de0", size = 21712683, upload-time = "2026-09-04T23:13:44.927Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/5871a2bff93bd6118ae877b874a09202d39e2831af37f56e2070d5c4bc87/uv-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b610e8cf7c6d51dc119d352e699b674534d0de327b4d2a67eb5d71ec364b8970", size = 22331081, upload-time = "2026-09-04T23:13:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/d690f34901bdc5094645cf75a00381cfb8f1f28ee87b7089af761094bb6f/uv-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f43c06b19137bb05877b0ec59c4932878db5d1aeb6f7b5c33259c22e0359c4fe", size = 23785643, upload-time = "2026-09-04T23:13:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/90/da/168a8c69f60b9a2c90afb0b9211911ca42a81300c098d545da911bd4ef8f/uv-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0334577671a65b621faddfdf5f9e9dd4eb08eaa7231c4405c4a7f4f17d224d9f", size = 23337759, upload-time = "2026-09-04T23:13:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/f0e14ba1f881f7126152dcaf04119b3a2bd7566ba1cac1d22834bd134cbf/uv-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7d6248ad9f2d282fea795f248da8fa666ab382df50d8385bd98e01049440a0c", size = 19978237, upload-time = "2026-09-04T23:13:56.994Z" }, + { url = "https://files.pythonhosted.org/packages/7a/20/acebfb5fc01b29cce5e68b4824fa451d5fb1e569bb2579bcc7233583a894/uv-0.12.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5c0bcf0598742eb417f1aa41ec82f0d89bd7b7f42d0a519bea495a9419d8d8ee", size = 19402684, upload-time = "2026-09-04T23:13:59.579Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/c40dc8b7b143a6f7843994b1b8a13aa8859d8404973704cd6bad7a26ac8d/uv-0.12.10-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:0ac914e9750bb1de2518d444cc1cff084f4eff3eefe9cdd5814b775443a16df1", size = 22450726, upload-time = "2026-09-04T23:14:02.316Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/b5df49f571b58e694ca8c3ddadd89939036ac0fe7ddaadc7600225ea8585/uv-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:46ca83d33bf87a67d0c5aa5b64f5576fc5c8e700a0ecf4900d409f8a1a1c9810", size = 22571602, upload-time = "2026-09-04T23:14:05.088Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3e/f0335142e8a4c6ddc5117dcc9b5fd55c83cdd8e20a7f3be9584282dbfb64/uv-0.12.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c49cd1f6e5ded1bd844119da8b184d5c044ae51441c476af2382fdb8af99edb8", size = 21499938, upload-time = "2026-09-04T23:14:08.109Z" }, + { url = "https://files.pythonhosted.org/packages/98/3e/991f08fcab1706175ae30eda6ef483223511679c56fe1032b919ca3ea391/uv-0.12.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a34e1be296e6e30fdbfee9b84a45d082ce7df4dd4e588ebcfc44249505c10f02", size = 22879435, upload-time = "2026-09-04T23:14:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/c4/80/8db01aabf9937cbc228003392ee9fe542c4dad5c3c5b26f2e1f136d8e463/uv-0.12.10-py3-none-win32.whl", hash = "sha256:f7f2bc56fbd367b1066e8461a82e51e8886bbab52b1f8d1793af8ce52232348c", size = 19873629, upload-time = "2026-09-04T23:14:13.755Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/e980985d93283c546374b6b9dfa486a28091c4c056226711e31bb0f722d1/uv-0.12.10-py3-none-win_amd64.whl", hash = "sha256:2ad71395c31b5db20c56327f62ee0299f75833308d4413284aba87324d092afa", size = 18084435, upload-time = "2026-09-04T23:14:16.323Z" }, + { url = "https://files.pythonhosted.org/packages/84/10/fa542546044f783060d09a624f9964b595c0a0713cf2d89ade69c6667d8b/uv-0.12.10-py3-none-win_arm64.whl", hash = "sha256:4f097c6b7f63eceb3faf5102009f9a8cc22fd7d8a341858d2fd540f46a592f7a", size = 19598033, upload-time = "2026-09-04T23:14:18.829Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0" From 3838881fe2cb3f23b5a7ed3529f802a5a83d5715 Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 14:22:59 +0200 Subject: [PATCH 2/9] docs: remove external uv requirement for Python environments --- doc/workloads/aiconfigurator.rst | 6 +++++- doc/workloads/dynamo_mocker.rst | 5 +++-- .../workloads_requirements_installation.rst | 13 +++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/doc/workloads/aiconfigurator.rst b/doc/workloads/aiconfigurator.rst index 64a8a231c..fa024acad 100644 --- a/doc/workloads/aiconfigurator.rst +++ b/doc/workloads/aiconfigurator.rst @@ -74,9 +74,13 @@ Test TOML example (Aggregated/IFB mode): Running ------- +CloudAI installs AIConfigurator into a managed Python environment on first use. +It uses the uv executable bundled with CloudAI, so ``uv`` does not need to be +installed separately or available on ``PATH``. + .. code-block:: bash - uv run cloudai run --system-config conf/common/system/standalone_system.toml \ + cloudai run --system-config conf/common/system/standalone_system.toml \ --tests-dir conf/experimental/aiconfigurator/test \ --test-scenario conf/experimental/aiconfigurator/test_scenario/aiconfigurator_disagg.toml diff --git a/doc/workloads/dynamo_mocker.rst b/doc/workloads/dynamo_mocker.rst index 39c2abf2c..1203b5568 100644 --- a/doc/workloads/dynamo_mocker.rst +++ b/doc/workloads/dynamo_mocker.rst @@ -12,7 +12,8 @@ Prerequisites ------------- CloudAI automatically installs ``ai-dynamo``, ``aiperf``, and ``genai-perf`` into a managed Python virtual -environment on first run — no manual pip install is needed. +environment on first run — no manual pip install is needed. CloudAI uses its bundled uv executable for this, +so ``uv`` does not need to be installed separately or available on ``PATH``. The one prerequisite is ``nats-server``. On many clusters ``nats-server`` is pre-installed by administrators and is already on ``PATH``. Check if it is already available: @@ -71,7 +72,7 @@ Run Using Standalone .. code-block:: bash - uv run cloudai run \ + cloudai run \ --system-config conf/experimental/dynamo_mocker/system/standalone_system.toml \ --tests-dir conf/experimental/dynamo_mocker/test \ --test-scenario conf/experimental/dynamo_mocker/test_scenario/dynamo_mocker.toml diff --git a/doc/workloads/workloads_requirements_installation.rst b/doc/workloads/workloads_requirements_installation.rst index e7711a287..085aa4b60 100644 --- a/doc/workloads/workloads_requirements_installation.rst +++ b/doc/workloads/workloads_requirements_installation.rst @@ -43,12 +43,13 @@ Other version declarations, including ``.python-versions``, ``.tool-versions``, ``runtime.txt``, global uv configuration, and ``requires-python`` in ``pyproject.toml``, are not used for this selection. -CloudAI ships the uv Python package and uses its bundled executable to create -these virtual environments; a separately installed ``uv`` command is not -required. If the selected interpreter is unavailable locally, uv can download -it during the first installation, so that installation requires network access -and can take longer than subsequent runs. See `uv Python version management`_ -for details. +CloudAI uses the uv executable bundled with its Python package for both +``PythonExecutable`` and ``PythonEnvironment`` installables. Neither installable +requires a separately installed ``uv`` command or ``uv`` on ``PATH``. If the +selected interpreter is unavailable locally, the bundled uv can download it +during the first installation, so that installation requires network access and +can take longer than subsequent runs. See `uv Python version management`_ for +details. The ``python_version`` field does not by itself make a generic ``GitRepo`` executable. Repositories used only as mounts are still cloned and mounted; the From 7f89052fe65c7f052fad9f2963d123c228c8e3cf Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 15:04:06 +0200 Subject: [PATCH 3/9] refactor: simplify Python executable version support --- .github/workflows/ci.yml | 1 - doc/workloads/aiconfigurator.rst | 6 +- doc/workloads/dynamo_mocker.rst | 5 +- .../workloads_requirements_installation.rst | 60 -- src/cloudai/_core/installables/_uv.py | 9 +- src/cloudai/_core/installables/git_repo.py | 27 +- .../_core/installables/python_environment.py | 2 +- .../_core/installables/python_executable.py | 253 ++------ src/cloudai/models/workload.py | 10 +- tests/core/installables/test_git_repo.py | 49 +- .../installables/test_python_environment.py | 15 +- .../installables/test_python_executable.py | 583 ++++-------------- 12 files changed, 195 insertions(+), 825 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cdbff7a5..f452349c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,6 @@ jobs: set -eEx set -o pipefail - .smoke-venv/bin/python -c "from pathlib import Path; from uv import find_uv_bin; assert Path(find_uv_bin()).is_file()" .smoke-venv/bin/cloudai --help # this checks that all TOMLs are valid, Test Scenarios are checked _only_ the tests in the specified directory diff --git a/doc/workloads/aiconfigurator.rst b/doc/workloads/aiconfigurator.rst index fa024acad..64a8a231c 100644 --- a/doc/workloads/aiconfigurator.rst +++ b/doc/workloads/aiconfigurator.rst @@ -74,13 +74,9 @@ Test TOML example (Aggregated/IFB mode): Running ------- -CloudAI installs AIConfigurator into a managed Python environment on first use. -It uses the uv executable bundled with CloudAI, so ``uv`` does not need to be -installed separately or available on ``PATH``. - .. code-block:: bash - cloudai run --system-config conf/common/system/standalone_system.toml \ + uv run cloudai run --system-config conf/common/system/standalone_system.toml \ --tests-dir conf/experimental/aiconfigurator/test \ --test-scenario conf/experimental/aiconfigurator/test_scenario/aiconfigurator_disagg.toml diff --git a/doc/workloads/dynamo_mocker.rst b/doc/workloads/dynamo_mocker.rst index 1203b5568..39c2abf2c 100644 --- a/doc/workloads/dynamo_mocker.rst +++ b/doc/workloads/dynamo_mocker.rst @@ -12,8 +12,7 @@ Prerequisites ------------- CloudAI automatically installs ``ai-dynamo``, ``aiperf``, and ``genai-perf`` into a managed Python virtual -environment on first run — no manual pip install is needed. CloudAI uses its bundled uv executable for this, -so ``uv`` does not need to be installed separately or available on ``PATH``. +environment on first run — no manual pip install is needed. The one prerequisite is ``nats-server``. On many clusters ``nats-server`` is pre-installed by administrators and is already on ``PATH``. Check if it is already available: @@ -72,7 +71,7 @@ Run Using Standalone .. code-block:: bash - cloudai run \ + uv run cloudai run \ --system-config conf/experimental/dynamo_mocker/system/standalone_system.toml \ --tests-dir conf/experimental/dynamo_mocker/test \ --test-scenario conf/experimental/dynamo_mocker/test_scenario/dynamo_mocker.toml diff --git a/doc/workloads/workloads_requirements_installation.rst b/doc/workloads/workloads_requirements_installation.rst index 085aa4b60..db3b5951f 100644 --- a/doc/workloads/workloads_requirements_installation.rst +++ b/doc/workloads/workloads_requirements_installation.rst @@ -4,66 +4,6 @@ Installation Requirements CloudAI workloads can define multiple installables as prerequisites. The installable can be a container image, git repository, HF model, etc. -Python Executables from Git Repositories ----------------------------------------- - -Some workloads wrap a git repository in a ``PythonExecutable`` and install the -repository in a dedicated virtual environment. Such an environment can use a -different Python interpreter from the one running CloudAI. Set ``python_version`` -on the repository to select that interpreter explicitly. - -In a test definition: - -.. code-block:: toml - - [[git_repos]] - url = "https://github.com/NVIDIA-NeMo/Run.git" - commit = "v0.10.0" - python_version = "3.11.9" - -In a test embedded in a scenario: - -.. code-block:: toml - - [[Tests.git_repos]] - url = "https://github.com/NVIDIA-NeMo/Run.git" - commit = "v0.10.0" - python_version = "3.11.9" - -CloudAI selects the interpreter for a ``PythonExecutable`` in this order: - -1. The repository's explicit ``python_version`` value. -2. The nearest ``.python-version`` file, searching from the executable's - project subdirectory towards the repository root. The search never leaves - the repository. -3. The interpreter running CloudAI (``sys.executable``), which preserves the - behavior of repositories without a Python version setting. - -Other version declarations, including ``.python-versions``, ``.tool-versions``, -``runtime.txt``, global uv configuration, and ``requires-python`` in -``pyproject.toml``, are not used for this selection. - -CloudAI uses the uv executable bundled with its Python package for both -``PythonExecutable`` and ``PythonEnvironment`` installables. Neither installable -requires a separately installed ``uv`` command or ``uv`` on ``PATH``. If the -selected interpreter is unavailable locally, the bundled uv can download it -during the first installation, so that installation requires network access and -can take longer than subsequent runs. See `uv Python version management`_ for -details. - -The ``python_version`` field does not by itself make a generic ``GitRepo`` -executable. Repositories used only as mounts are still cloned and mounted; the -field is consumed only by workloads that wrap the repository in a -``PythonExecutable``. - -After upgrading CloudAI, an existing virtual environment that uses a repository -``.python-version`` pin might be recreated once. CloudAI records the effective -interpreter request for future checks and rebuilds a pinned legacy environment -when that record is missing or no longer matches. - -.. _uv Python version management: https://docs.astral.sh/uv/concepts/python-versions/ - - Setting Up Access to the Private NGC Registry --------------------------------------------- diff --git a/src/cloudai/_core/installables/_uv.py b/src/cloudai/_core/installables/_uv.py index d7a5fa2f2..52f38b2fd 100644 --- a/src/cloudai/_core/installables/_uv.py +++ b/src/cloudai/_core/installables/_uv.py @@ -19,11 +19,4 @@ def resolve_uv_bin() -> str: """Return the uv executable shipped with the current CloudAI installation.""" - try: - uv_bin = uv.find_uv_bin() - except Exception as e: - raise RuntimeError("Cannot locate the uv executable shipped with CloudAI.") from e - - if not uv_bin: - raise RuntimeError("Cannot locate the uv executable shipped with CloudAI.") - return str(uv_bin) + return uv.find_uv_bin() diff --git a/src/cloudai/_core/installables/git_repo.py b/src/cloudai/_core/installables/git_repo.py index 37e6c1915..a219f3836 100644 --- a/src/cloudai/_core/installables/git_repo.py +++ b/src/cloudai/_core/installables/git_repo.py @@ -17,10 +17,8 @@ import logging import shutil import subprocess -import threading -from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Iterator, Optional +from typing import TYPE_CHECKING, Optional from pydantic import BaseModel, ConfigDict @@ -30,21 +28,6 @@ from ..base_installer import BaseInstaller -_REPO_LOCKS: dict[Path, threading.Lock] = {} -_REPO_LOCKS_GUARD = threading.Lock() - - -@contextmanager -def _repo_lock(repo_path: Path) -> Iterator[None]: - """Serialize operations on a checkout shared by multiple installables.""" - key = repo_path.resolve() - with _REPO_LOCKS_GUARD: - lock = _REPO_LOCKS.setdefault(key, threading.Lock()) - - with lock: - yield - - class GitRepo(Installable, BaseModel): """Git repository object.""" @@ -125,10 +108,6 @@ def ensure_submodules_state(self, repo_path: Path) -> tuple[bool, str]: def install(self, installer: "BaseInstaller") -> InstallStatusResult: repo_path = installer.system.install_path / self.repo_name - with _repo_lock(repo_path): - return self._install(installer, repo_path) - - def _install(self, installer: "BaseInstaller", repo_path: Path) -> InstallStatusResult: if repo_path.exists(): verify_res = self._verify_commit(self.commit, repo_path) if not verify_res.success: @@ -151,10 +130,6 @@ def _install(self, installer: "BaseInstaller", repo_path: Path) -> InstallStatus def uninstall(self, installer: "BaseInstaller") -> InstallStatusResult: logging.debug(f"Uninstalling git repository at {self.installed_path=}") repo_path = self.installed_path if self.installed_path else installer.system.install_path / self.repo_name - with _repo_lock(repo_path): - return self._uninstall(repo_path) - - def _uninstall(self, repo_path: Path) -> InstallStatusResult: if not repo_path.exists(): return InstallStatusResult(True, f"Repository {self.url} is not cloned.") diff --git a/src/cloudai/_core/installables/python_environment.py b/src/cloudai/_core/installables/python_environment.py index 9e31b93e8..be12933c8 100644 --- a/src/cloudai/_core/installables/python_environment.py +++ b/src/cloudai/_core/installables/python_environment.py @@ -73,7 +73,7 @@ def install(self, installer: "BaseInstaller") -> InstallStatusResult: try: uv = resolve_uv_bin() - except RuntimeError as e: + except OSError as e: return InstallStatusResult(False, f"Cannot install Python environment: {e}") res = self._ensure_python_version(uv) diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index 929415fc1..d6708864a 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import hashlib -import json import logging import shutil import subprocess @@ -32,9 +30,6 @@ from ..base_installer import BaseInstaller -_PYTHON_REQUEST_MARKER = ".cloudai-python-request" - - @dataclass class PythonExecutable(Installable): """Python executable object.""" @@ -46,11 +41,15 @@ class PythonExecutable(Installable): def __eq__(self, other: object) -> bool: """Check if two installable objects are equal.""" - return isinstance(other, PythonExecutable) and other._identity() == self._identity() + return ( + isinstance(other, PythonExecutable) + and other.git_repo.url == self.git_repo.url + and other.git_repo.commit == self.git_repo.commit + ) def __hash__(self) -> int: """Hash the installable object.""" - return hash(self._identity()) + return self.git_repo.__hash__() def __str__(self) -> str: """Return the string representation of the python executable.""" @@ -58,13 +57,7 @@ def __str__(self) -> str: @property def venv_name(self) -> str: - base_name = f"{self.git_repo.repo_name}-venv" - if self._uses_default_environment_config(): - return base_name - - payload = json.dumps(self._identity(), separators=(",", ":"), ensure_ascii=True) - config_hash = hashlib.sha256(payload.encode()).hexdigest()[:12] - return f"{base_name}-{config_hash}" + return f"{self.git_repo.repo_name}-venv" def install(self, installer: "BaseInstaller") -> InstallStatusResult: res = self.git_repo.install(installer) @@ -102,22 +95,8 @@ 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_path = self._python_path(venv_path) - if not python_path.is_file(): - return InstallStatusResult(False, f"Python executable does not exist at {python_path}") - - request_res = self._get_python_request(repo_path) - if isinstance(request_res, InstallStatusResult): - return request_res - python_request, is_pinned = request_res - if is_pinned and not self._request_marker_matches(venv_path, python_request): - return InstallStatusResult( - False, - f"Python interpreter request marker is missing or does not match {python_request!r}", - ) - self.venv_path = venv_path + return InstallStatusResult(True, "Python executable installed") def mark_as_installed(self, installer: "BaseInstaller") -> InstallStatusResult: @@ -127,51 +106,63 @@ def mark_as_installed(self, installer: "BaseInstaller") -> InstallStatusResult: def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: venv_path = installer.system.install_path / self.venv_name - repo_path = self.git_repo.installed_path or installer.system.install_path / self.git_repo.repo_name - project_dir = self._project_dir(repo_path) - request_res = self._get_python_request(repo_path) - if isinstance(request_res, InstallStatusResult): - return request_res - python_request, is_pinned = request_res - logging.debug(f"Creating virtual environment in {venv_path}") - existing_res = self._prepare_existing_venv(venv_path, python_request, is_pinned) - if existing_res is not None: - return existing_res - - if not project_dir.is_dir(): - return InstallStatusResult(False, f"Python project directory does not exist: {project_dir}") + if venv_path.exists(): + msg = f"Virtual environment already exists at {venv_path}." + logging.debug(msg) + return InstallStatusResult(True, msg) + repo_path = self.git_repo.installed_path or installer.system.install_path / self.git_repo.repo_name + project_dir = repo_path / self.project_subpath if self.project_subpath else repo_path + python_version = self._resolve_python_version(repo_path) try: - uv = resolve_uv_bin() - except RuntimeError as e: + uv_bin = resolve_uv_bin() + except OSError as e: return InstallStatusResult(False, f"Cannot create virtual environment: {e}") - cmd = [uv, "venv", "--python", python_request, "--seed", str(venv_path)] + cmd = [uv_bin, "venv", "--python", python_version, "--seed", str(venv_path)] logging.debug(f"Creating venv using cmd: {' '.join(cmd)}") - try: - result = subprocess.run(cmd, cwd=str(project_dir), capture_output=True, text=True) - except OSError as e: - return self._failure_with_cleanup(venv_path, f"Failed to create venv using uv: {e}") + 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: - return self._failure_with_cleanup( - venv_path, - f"Failed to create venv using uv:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + if venv_path.exists(): + shutil.rmtree(venv_path) + return InstallStatusResult( + False, f"Failed to create venv using uv:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" ) res = self._install_dependencies(installer) if not res.success: - return self._failure_with_cleanup(venv_path, res.message) - - marker_res = self._write_request_marker(venv_path, python_request, is_pinned) - if marker_res is not None: - return marker_res + if venv_path.exists(): + shutil.rmtree(venv_path) + return res - self.venv_path = venv_path + 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() + + 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 + def _install_dependencies(self, installer: "BaseInstaller") -> InstallStatusResult: venv_path = installer.system.install_path / self.venv_name @@ -198,12 +189,9 @@ def _install_dependencies(self, installer: "BaseInstaller") -> InstallStatusResu return InstallStatusResult(False, "No pyproject.toml or requirements.txt found for installation.") def _install_pyproject(self, venv_dir: Path, project_dir: Path) -> InstallStatusResult: - install_cmd = [str(self._python_path(venv_dir)), "-m", "pip", "install", str(project_dir)] + install_cmd = [str(venv_dir / "bin" / "python"), "-m", "pip", "install", str(project_dir)] logging.debug(f"Installing dependencies using: {' '.join(install_cmd)}") - try: - result = subprocess.run(install_cmd, capture_output=True, text=True) - except OSError as e: - return InstallStatusResult(False, f"Failed to install {project_dir} using pip: {e}") + result = subprocess.run(install_cmd, capture_output=True, text=True) if result.returncode != 0: return InstallStatusResult(False, f"Failed to install {project_dir} using pip: {result.stderr}") @@ -214,146 +202,11 @@ def _install_requirements(self, venv_dir: Path, requirements_txt: Path) -> Insta if not requirements_txt.is_file(): return InstallStatusResult(False, f"Requirements file is invalid or does not exist: {requirements_txt}") - install_cmd = [ - str(self._python_path(venv_dir)), - "-m", - "pip", - "install", - "-r", - str(requirements_txt), - ] + install_cmd = [str(venv_dir / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_txt)] logging.debug(f"Installing dependencies using: {' '.join(install_cmd)}") - try: - result = subprocess.run(install_cmd, capture_output=True, text=True) - except OSError as e: - return InstallStatusResult(False, f"Failed to install dependencies from requirements.txt: {e}") + result = subprocess.run(install_cmd, capture_output=True, text=True) if result.returncode != 0: return InstallStatusResult(False, f"Failed to install dependencies from requirements.txt: {result.stderr}") return InstallStatusResult(True) - - def _identity(self) -> tuple[str, str, Optional[str], Optional[str], bool]: - python_version = self.git_repo.python_version - normalized_python_version = python_version.strip() if python_version is not None else None - project_subpath = Path(self.project_subpath).as_posix() if self.project_subpath is not None else None - return ( - self.git_repo.url, - self.git_repo.commit, - normalized_python_version, - project_subpath, - self.dependencies_from_pyproject, - ) - - def _uses_default_environment_config(self) -> bool: - return ( - self.git_repo.python_version is None and self.project_subpath is None and self.dependencies_from_pyproject - ) - - def _project_dir(self, repo_path: Path) -> Path: - return repo_path / self.project_subpath if self.project_subpath is not None else repo_path - - def _resolve_python_request(self, repo_path: Path) -> tuple[str, bool]: - """Resolve the uv Python request and whether it came from an explicit pin.""" - if self.git_repo.python_version is not None: - request = self.git_repo.python_version.strip() - if not request: - raise ValueError("Git repository python_version must not be empty.") - return request, True - - repo_root = repo_path.resolve() - current = self._project_dir(repo_path).resolve() - try: - current.relative_to(repo_root) - except ValueError: - return sys.executable, False - - while True: - version_file = current / ".python-version" - if version_file.is_file(): - try: - request = version_file.read_text(encoding="utf-8").strip() - except OSError as e: - raise RuntimeError(f"Failed to read Python version from {version_file}: {e}") from e - if not request: - raise ValueError(f"Python version file is empty: {version_file}") - return request, True - - if current == repo_root: - break - current = current.parent - - return sys.executable, False - - def _get_python_request(self, repo_path: Path) -> tuple[str, bool] | InstallStatusResult: - try: - return self._resolve_python_request(repo_path) - except (OSError, RuntimeError, ValueError) as e: - return InstallStatusResult(False, f"Failed to resolve Python interpreter request: {e}") - - @staticmethod - def _python_path(venv_path: Path) -> Path: - if sys.platform == "win32": - return venv_path / "Scripts" / "python.exe" - return venv_path / "bin" / "python" - - @staticmethod - def _request_marker_matches(venv_path: Path, python_request: str) -> bool: - marker = venv_path / _PYTHON_REQUEST_MARKER - try: - return marker.is_file() and marker.read_text(encoding="utf-8").strip() == python_request - except OSError: - return False - - def _prepare_existing_venv( - self, venv_path: Path, python_request: str, is_pinned: bool - ) -> Optional[InstallStatusResult]: - if not venv_path.exists(): - return None - - has_python = self._python_path(venv_path).is_file() - has_matching_request = not is_pinned or self._request_marker_matches(venv_path, python_request) - if has_python and has_matching_request: - self.venv_path = venv_path - msg = f"Virtual environment already exists at {venv_path}." - logging.debug(msg) - return InstallStatusResult(True, msg) - - logging.info(f"Recreating stale virtual environment at {venv_path}") - try: - self._cleanup_venv(venv_path) - except OSError as e: - return InstallStatusResult(False, f"Failed to remove stale virtual environment {venv_path}: {e}") - return None - - @classmethod - def _write_request_marker( - cls, venv_path: Path, python_request: str, is_pinned: bool - ) -> Optional[InstallStatusResult]: - if not is_pinned: - return None - - marker = venv_path / _PYTHON_REQUEST_MARKER - try: - marker.write_text(f"{python_request}\n", encoding="utf-8") - except OSError as e: - return cls._failure_with_cleanup( - venv_path, - f"Failed to record Python interpreter request {python_request!r} in {marker}: {e}", - ) - return None - - @staticmethod - def _cleanup_venv(venv_path: Path) -> None: - if venv_path.is_symlink() or venv_path.is_file(): - venv_path.unlink() - elif venv_path.exists(): - shutil.rmtree(venv_path) - - @classmethod - def _failure_with_cleanup(cls, venv_path: Path, message: str) -> InstallStatusResult: - try: - cls._cleanup_venv(venv_path) - except OSError as e: - message = f"{message}\nFailed to clean up partial virtual environment {venv_path}: {e}" - return InstallStatusResult(False, message) diff --git a/src/cloudai/models/workload.py b/src/cloudai/models/workload.py index efbffe259..22c3c04ad 100644 --- a/src/cloudai/models/workload.py +++ b/src/cloudai/models/workload.py @@ -75,12 +75,20 @@ def cmd_args(self) -> list[str]: return parts -@dataclass(eq=False) +@dataclass class PredictorConfig(PythonExecutable): """Predictor configuration.""" bin_name: Optional[str] = None + def __hash__(self) -> int: + """ + Hash the PredictorConfig. + + It is based on git repo on purpose to avoid re-downloading the same repo for multiple scripts. + """ + return self.git_repo.__hash__() + class TrainingReportConfig(BaseModel): """Training-report aggregation window: steps excluded before computing per-metric stats.""" diff --git a/tests/core/installables/test_git_repo.py b/tests/core/installables/test_git_repo.py index 9b5e2ab91..4c31cf7cf 100644 --- a/tests/core/installables/test_git_repo.py +++ b/tests/core/installables/test_git_repo.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import threading -from concurrent.futures import ThreadPoolExecutor from pathlib import Path from subprocess import CompletedProcess from typing import Iterator @@ -77,10 +75,10 @@ def test_git_repo_name(url: str, expected: str): def test_python_version_is_optional_and_round_trips() -> None: - legacy = GitRepo.model_validate({"url": "./repo", "commit": "main"}) + default = GitRepo.model_validate({"url": "./repo", "commit": "main"}) pinned = GitRepo.model_validate({"url": "./repo", "commit": "main", "python_version": "3.11.9"}) - assert legacy.python_version is None + assert default.python_version is None assert pinned.python_version == "3.11.9" assert pinned.model_dump()["python_version"] == "3.11.9" @@ -138,7 +136,7 @@ def test_scenario_git_repo_accepts_and_preserves_python_version() -> None: assert model.tdef_model_dump(by_alias=True)["git_repos"][0]["python_version"] == "3.11.9" -def test_legacy_git_repo_toml_without_python_version_remains_valid() -> None: +def test_git_repo_toml_without_python_version_remains_valid() -> None: data = toml.loads( """ [[git_repos]] @@ -346,47 +344,6 @@ def test_repo_exists_with_wrong_commit(installer: BaseInstaller, git: GitRepo): assert res.message == "wrong commit" -def test_concurrent_python_variants_clone_shared_repo_once(installer: BaseInstaller) -> None: - py311 = GitRepo(url="./shared_repo", commit="commit_hash", python_version="3.11.9") - py314 = GitRepo(url="./shared_repo", commit="commit_hash", python_version="3.14.0") - first_clone_started = threading.Event() - second_clone_started = threading.Event() - release_clone = threading.Event() - calls_lock = threading.Lock() - clone_calls = 0 - - def clone_repository(item: GitRepo, installer: BaseInstaller, path: Path) -> InstallStatusResult: - nonlocal clone_calls - with calls_lock: - clone_calls += 1 - call_number = clone_calls - if call_number == 1: - first_clone_started.set() - else: - second_clone_started.set() - assert release_clone.wait(timeout=2) - path.mkdir(parents=True, exist_ok=True) - return InstallStatusResult(True) - - with ( - patch.object(GitRepo, "_clone_repository", autospec=True, side_effect=clone_repository), - patch.object(GitRepo, "_checkout_commit", return_value=InstallStatusResult(True)), - patch.object(GitRepo, "_verify_commit", return_value=InstallStatusResult(True)), - patch.object(GitRepo, "ensure_submodules_state", return_value=(True, "")), - ThreadPoolExecutor(max_workers=2) as executor, - ): - first = executor.submit(py311.install, installer) - assert first_clone_started.wait(timeout=2) - second = executor.submit(py314.install, installer) - assert not second_clone_started.wait(timeout=0.1), "second clone was not serialized by repository path" - release_clone.set() - results = [first.result(timeout=2), second.result(timeout=2)] - - assert all(result.success for result in results) - assert clone_calls == 1 - assert py311.installed_path == py314.installed_path == installer.system.install_path / py311.repo_name - - def test_repo_cloned(installer: BaseInstaller, git: GitRepo): repo_path = installer.system.install_path / git.repo_name with patch("subprocess.run") as mock_run: diff --git a/tests/core/installables/test_python_environment.py b/tests/core/installables/test_python_environment.py index aaf4b55f3..1c259144c 100644 --- a/tests/core/installables/test_python_environment.py +++ b/tests/core/installables/test_python_environment.py @@ -75,26 +75,17 @@ def test_python_environment_install_uses_uv(installer: BaseInstaller) -> None: ] -def test_python_environment_reports_bundled_uv_resolution_failure(installer: BaseInstaller) -> None: +def test_python_environment_reports_uv_resolution_failure(installer: BaseInstaller) -> None: env = PythonEnvironment(name="aiconfigurator", python_version="3.10") with patch( "cloudai._core.installables.python_environment.resolve_uv_bin", - side_effect=RuntimeError("bundled uv is unavailable"), + side_effect=OSError("uv is unavailable"), ): res = env.install(installer) assert not res.success - assert res.message == "Cannot install Python environment: bundled uv is unavailable" - - -def test_packaged_uv_resolver_uses_public_uv_api() -> None: - from cloudai._core.installables._uv import resolve_uv_bin - - with patch("cloudai._core.installables._uv.uv.find_uv_bin", return_value="/cloudai/bin/uv") as find_uv_bin: - assert resolve_uv_bin() == "/cloudai/bin/uv" - - find_uv_bin.assert_called_once_with() + assert res.message == "Cannot install Python environment: uv is unavailable" def test_python_environment_is_installed_checks_python_executable(installer: BaseInstaller) -> None: diff --git a/tests/core/installables/test_python_executable.py b/tests/core/installables/test_python_executable.py index 4c49c3df1..6c3ef17ac 100644 --- a/tests/core/installables/test_python_executable.py +++ b/tests/core/installables/test_python_executable.py @@ -14,17 +14,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import subprocess -import sys -import threading -import time from pathlib import Path from subprocess import CompletedProcess from unittest.mock import patch import pytest -from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, PredictorConfig, PythonExecutable +from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, PythonExecutable @pytest.fixture @@ -33,7 +29,7 @@ def git() -> GitRepo: @pytest.fixture -def installer(slurm_system) -> BaseInstaller: +def installer(slurm_system): installer = BaseInstaller(slurm_system) installer.system.install_path.mkdir(parents=True) installer._check_low_thread_environment = lambda threshold=None: False @@ -57,508 +53,230 @@ def setup_repo(installer: BaseInstaller, git: GitRepo): return repo_dir, subdir, pyproject_file, requirements_file -def _create_python_file(venv_path: Path) -> Path: - python_path = venv_path / "bin" / "python" - python_path.parent.mkdir(parents=True, exist_ok=True) - python_path.touch() - return python_path - - -def test_explicit_python_version_overrides_repository_pin(tmp_path: Path) -> None: +def test_explicit_python_version_overrides_repo_version(tmp_path: Path): repo_path = tmp_path / "repo" - project_dir = repo_path / "package" - project_dir.mkdir(parents=True) - (project_dir / ".python-version").write_text("3.10.16\n") - py = PythonExecutable( - GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9"), - project_subpath=Path("package"), - ) + repo_path.mkdir() + (repo_path / ".python-version").write_text("3.10.16\n") + py = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) - assert py._resolve_python_request(repo_path) == ("3.11.9", True) + assert py._resolve_python_version(repo_path) == "3.11.9" + py.git_repo.python_version = "" + assert py._resolve_python_version(repo_path) == "3.10.16" -def test_nearest_python_version_is_used_from_project_subpath(tmp_path: Path) -> None: + +def test_nearest_repo_python_version_is_used(tmp_path: Path): repo_path = tmp_path / "repo" - project_dir = repo_path / "packages" / "nested" / "project" - project_dir.mkdir(parents=True) + project_path = repo_path / "packages" / "project" + project_path.mkdir(parents=True) (repo_path / ".python-version").write_text("3.10.16\n") (repo_path / "packages" / ".python-version").write_text("3.11.9\n") py = PythonExecutable( GitRepo(url="./git_url", commit="commit_hash"), - project_subpath=Path("packages/nested/project"), + project_subpath=Path("packages/project"), ) - assert py._resolve_python_request(repo_path) == ("3.11.9", True) + assert py._resolve_python_version(repo_path) == "3.11.9" -def test_python_version_lookup_is_bounded_by_repository_root(tmp_path: Path) -> None: - repo_path = tmp_path / "repo" - project_dir = repo_path / "package" - project_dir.mkdir(parents=True) - (tmp_path / ".python-version").write_text("9.9.9\n") - py = PythonExecutable( - GitRepo(url="./git_url", commit="commit_hash"), - project_subpath=Path("package"), - ) - - with patch("cloudai._core.installables.python_executable.sys.executable", "/cloudai/bin/python"): - assert py._resolve_python_request(repo_path) == ("/cloudai/bin/python", False) - - -@pytest.mark.parametrize("filename", [".python-versions", ".tool-versions", "runtime.txt", "pyproject.toml"]) -def test_unrelated_python_version_files_are_not_inspected(tmp_path: Path, filename: str) -> None: +def test_python_version_falls_back_to_cloudai_interpreter(tmp_path: Path): repo_path = tmp_path / "repo" repo_path.mkdir() - (repo_path / filename).write_text("3.11.9\n") + (tmp_path / ".python-version").write_text("3.11.9\n") py = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash")) with patch("cloudai._core.installables.python_executable.sys.executable", "/cloudai/bin/python"): - assert py._resolve_python_request(repo_path) == ("/cloudai/bin/python", False) + assert py._resolve_python_version(repo_path) == "/cloudai/bin/python" -def test_venv_created_with_bundled_uv_and_selected_interpreter(installer: BaseInstaller) -> None: - git = GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9") - py = PythonExecutable(git, project_subpath=Path("package")) - repo_path = installer.system.install_path / git.repo_name - project_dir = repo_path / "package" - project_dir.mkdir(parents=True) - git.installed_path = repo_path +def test_venv_created(installer: BaseInstaller, git: GitRepo): + git.python_version = "3.11.9" + git.installed_path = installer.system.install_path / git.repo_name + py = PythonExecutable(git) venv_path = installer.system.install_path / py.venv_name - with ( - patch( - "cloudai._core.installables.python_executable.resolve_uv_bin", - return_value="/cloudai/bin/uv", - ) as resolve_uv, + patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), - patch("subprocess.run") as run, + patch("subprocess.run") as mock_run, ): - - def create_venv(*args, **kwargs): - _create_python_file(venv_path) - return CompletedProcess(args=args, returncode=0, stdout="", stderr="") - - run.side_effect = create_venv + mock_run.return_value = CompletedProcess(args=[], returncode=0) res = py._create_venv(installer) - assert res.success - resolve_uv.assert_called_once_with() - run.assert_called_once_with( + mock_run.assert_called_once_with( ["/cloudai/bin/uv", "venv", "--python", "3.11.9", "--seed", str(venv_path)], - cwd=str(project_dir), + cwd=str(git.installed_path), capture_output=True, text=True, ) - assert (venv_path / ".cloudai-python-request").read_text().strip() == "3.11.9" -@pytest.mark.parametrize("failure_stage", ["venv", "dependencies"]) -def test_failed_installation_removes_partial_venv( +@pytest.mark.parametrize("failure_on_venv_creation,reqs_install_failure", [(True, False), (False, True)]) +def test_error_creating_venv( installer: BaseInstaller, - failure_stage: str, -) -> None: - git = GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9") + git: GitRepo, + failure_on_venv_creation: bool, + reqs_install_failure: bool, +): py = PythonExecutable(git) - repo_path = installer.system.install_path / git.repo_name - repo_path.mkdir() - git.installed_path = repo_path venv_path = installer.system.install_path / py.venv_name - def create_partial_venv(*args, **kwargs): - venv_path.mkdir(parents=True) - return CompletedProcess(args=args, returncode=1 if failure_stage == "venv" else 0, stderr="err") + def mock_run(*args, **kwargs): + venv_path.mkdir() + if failure_on_venv_creation and "venv" in args[0]: + return CompletedProcess(args=args, returncode=1, stderr="err") + return CompletedProcess(args=args, returncode=0) - dependencies_result = ( - InstallStatusResult(False, "dependency error") if failure_stage == "dependencies" else InstallStatusResult(True) - ) + dependencies_result = InstallStatusResult(False, "err") if reqs_install_failure else InstallStatusResult(True) with ( patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), patch.object(PythonExecutable, "_install_dependencies", return_value=dependencies_result), - patch("subprocess.run", side_effect=create_partial_venv), + patch("subprocess.run", side_effect=mock_run), ): res = py._create_venv(installer) - assert not res.success - assert "err" in res.message - assert not venv_path.exists() - assert py.venv_path is None - - -@pytest.mark.parametrize("marker_value", [None, "3.10.16"]) -def test_stale_pinned_legacy_venv_is_recreated( - installer: BaseInstaller, - git: GitRepo, - marker_value: str | None, -) -> None: - py = PythonExecutable(git) - repo_path = installer.system.install_path / git.repo_name - repo_path.mkdir() - (repo_path / ".python-version").write_text("3.11.9\n") - git.installed_path = repo_path - venv_path = installer.system.install_path / py.venv_name - _create_python_file(venv_path) - stale_file = venv_path / "stale" - stale_file.touch() - marker = venv_path / ".cloudai-python-request" - if marker_value is not None: - marker.write_text(marker_value) - - def recreate_venv(*args, **kwargs): - assert not stale_file.exists() - _create_python_file(venv_path) - return CompletedProcess(args=args, returncode=0, stdout="", stderr="") - - with ( - patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), - patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), - patch("subprocess.run", side_effect=recreate_venv) as run, - ): - res = py._create_venv(installer) - - assert res.success - run.assert_called_once() - assert marker.read_text().strip() == "3.11.9" - assert not stale_file.exists() + if failure_on_venv_creation: + assert res.message == "Failed to create venv using uv:\nSTDOUT:\nNone\nSTDERR:\nerr" + else: + assert res.message == "err" + assert not venv_path.exists(), "venv folder wasn't removed after unsuccessful installation" -def test_matching_marker_keeps_existing_pinned_venv(installer: BaseInstaller, git: GitRepo) -> None: +def test_venv_already_exists(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) - repo_path = installer.system.install_path / git.repo_name - repo_path.mkdir() - (repo_path / ".python-version").write_text("3.11.9\n") - git.installed_path = repo_path venv_path = installer.system.install_path / py.venv_name - _create_python_file(venv_path) - (venv_path / ".cloudai-python-request").write_text("3.11.9") - - with patch("subprocess.run") as run: + venv_path.mkdir() + with patch("subprocess.run") as mock_run: + mock_run.return_value = CompletedProcess(args=[], returncode=1, stderr="err") res = py._create_venv(installer) - + assert mock_run.call_count == 0 assert res.success assert res.message == f"Virtual environment already exists at {venv_path}." - run.assert_not_called() -@pytest.mark.parametrize("marker_value", [None, "3.10.16"]) -def test_is_installed_rejects_missing_or_mismatched_marker_for_pinned_environment( - installer: BaseInstaller, - git: GitRepo, - marker_value: str | None, -) -> None: +def test_requirements_no_file(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) - repo_path = installer.system.install_path / git.repo_name - repo_path.mkdir() - (repo_path / ".python-version").write_text("3.11.9\n") venv_path = installer.system.install_path / py.venv_name - _create_python_file(venv_path) - if marker_value is not None: - (venv_path / ".cloudai-python-request").write_text(marker_value) - - res = py.is_installed(installer) - + venv_path.mkdir() + res = py._install_requirements(venv_path, installer.system.install_path / "requirements.txt") assert not res.success - assert "Python interpreter request" in res.message - assert py.venv_path is None - - -def test_is_installed_accepts_matching_marker_for_pinned_environment( - installer: BaseInstaller, - git: GitRepo, -) -> None: - py = PythonExecutable(git) - repo_path = installer.system.install_path / git.repo_name - repo_path.mkdir() - (repo_path / ".python-version").write_text("3.11.9\n") - venv_path = installer.system.install_path / py.venv_name - _create_python_file(venv_path) - (venv_path / ".cloudai-python-request").write_text("3.11.9") - - res = py.is_installed(installer) - - assert res.success - assert py.venv_path == venv_path - - -def test_is_installed_preserves_unpinned_legacy_venv_without_marker( - installer: BaseInstaller, - git: GitRepo, -) -> None: - py = PythonExecutable(git) - (installer.system.install_path / git.repo_name).mkdir() - venv_path = installer.system.install_path / py.venv_name - _create_python_file(venv_path) + assert ( + res.message + == f"Requirements file is invalid or does not exist: {installer.system.install_path / 'requirements.txt'}" + ) - res = py.is_installed(installer) +def test_requirements_installed(installer: BaseInstaller): + requirements_file = installer.system.install_path / "requirements.txt" + venv_path = installer.system.install_path / "venv" + requirements_file.touch() + with patch("subprocess.run") as mock_run: + mock_run.return_value = CompletedProcess(args=[], returncode=0) + res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( + venv_path, requirements_file + ) assert res.success - assert py.venv_path == venv_path - - -def test_is_installed_requires_python_executable(installer: BaseInstaller, git: GitRepo) -> None: - py = PythonExecutable(git) - (installer.system.install_path / git.repo_name).mkdir() - (installer.system.install_path / py.venv_name).mkdir() - - res = py.is_installed(installer) - - assert not res.success - assert "Python executable" in res.message - assert py.venv_path is None - - -def test_python_executable_identity_and_venv_name_include_environment_configuration() -> None: - default = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash")) - same = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash")) - py311 = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) - py311_same = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) - py312 = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.12.8")) - subproject = PythonExecutable( - GitRepo(url="./git_url", commit="commit_hash"), - project_subpath=Path("package"), - ) - requirements_first = PythonExecutable( - GitRepo(url="./git_url", commit="commit_hash"), - dependencies_from_pyproject=False, + mock_run.assert_called_once_with( + [str(venv_path / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_file)], + capture_output=True, + text=True, ) - assert default == same - assert hash(default) == hash(same) - assert default.venv_name == "git_url__commit_hash-venv" - assert py311 == py311_same - assert hash(py311) == hash(py311_same) - assert len({default, py311, py312, subproject, requirements_first}) == 5 - assert ( - len( - { - default.venv_name, - py311.venv_name, - py312.venv_name, - subproject.venv_name, - requirements_first.venv_name, - } + +def test_requirements_not_installed(installer: BaseInstaller): + requirements_file = installer.system.install_path / "requirements.txt" + requirements_file.touch() + with patch("subprocess.run") as mock_run: + mock_run.return_value = CompletedProcess(args=[], returncode=1, stderr="err") + res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( + installer.system.install_path, requirements_file ) - == 5 - ) - assert py311.venv_name.startswith(f"{default.venv_name}-") - assert len(py311.venv_name.removeprefix(f"{default.venv_name}-")) == 12 + assert not res.success + assert res.message == "Failed to install dependencies from requirements.txt: err" -def test_repository_detected_pin_preserves_legacy_venv_name(git: GitRepo) -> None: +def test_all_good_flow(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) + py.git_repo.installed_path = installer.system.install_path / py.git_repo.repo_name - assert py.venv_name == f"{git.repo_name}-venv" - - -def test_string_project_subpath_remains_compatible(git: GitRepo) -> None: - py = PythonExecutable(git, project_subpath="package") # type: ignore[arg-type] - - assert py.venv_name.startswith(f"{git.repo_name}-venv-") - - -def test_installer_creates_distinct_explicit_variants_while_serializing_shared_repo( - installer: BaseInstaller, -) -> None: - py311 = PythonExecutable(GitRepo(url="./shared_repo", commit="commit", python_version="3.11.9")) - py314 = PythonExecutable(GitRepo(url="./shared_repo", commit="commit", python_version="3.14.0")) - original_install = GitRepo._install - state_lock = threading.Lock() - active_repo_operations = 0 - max_active_repo_operations = 0 - clone_calls = 0 - - def track_repo_install(item: GitRepo, context: BaseInstaller, repo_path: Path) -> InstallStatusResult: - nonlocal active_repo_operations, max_active_repo_operations - with state_lock: - active_repo_operations += 1 - max_active_repo_operations = max(max_active_repo_operations, active_repo_operations) - try: - time.sleep(0.05) - return original_install(item, context, repo_path) - finally: - with state_lock: - active_repo_operations -= 1 - - def clone_repo(item: GitRepo, context: BaseInstaller, repo_path: Path) -> InstallStatusResult: - nonlocal clone_calls - clone_calls += 1 - repo_path.mkdir(parents=True) - return InstallStatusResult(True) - - def create_venv(item: PythonExecutable, context: BaseInstaller) -> InstallStatusResult: - item.venv_path = context.system.install_path / item.venv_name - _create_python_file(item.venv_path) - return InstallStatusResult(True) + repo_dir = py.git_repo.installed_path + repo_dir.mkdir(parents=True, exist_ok=True) + pyproject_file = repo_dir / "pyproject.toml" + pyproject_file.write_text("[tool.poetry]\nname = 'dummy_project'") with ( - patch.object(GitRepo, "_install", autospec=True, side_effect=track_repo_install), - patch.object(GitRepo, "_clone_repository", autospec=True, side_effect=clone_repo), - patch.object(GitRepo, "_checkout_commit", return_value=InstallStatusResult(True)), - patch.object(GitRepo, "_verify_commit", return_value=InstallStatusResult(True)), - patch.object(GitRepo, "ensure_submodules_state", return_value=(True, "")), - patch.object(PythonExecutable, "_create_venv", autospec=True, side_effect=create_venv), + patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), + patch("subprocess.run") as mock_run, ): - res = installer.install([py311, py314]) - - assert res.success - assert max_active_repo_operations == 1 - assert clone_calls == 1 - assert py311.git_repo.installed_path == py314.git_repo.installed_path - assert py311.venv_path != py314.venv_path - assert py311.venv_path is not None and py311.venv_path.exists() - assert py314.venv_path is not None and py314.venv_path.exists() - - -def test_predictor_identity_matches_python_executable_identity() -> None: - predictor = PredictorConfig( - git_repo=GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9"), - bin_name="predict-a", - ) - same_environment = PredictorConfig( - git_repo=GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9"), - bin_name="predict-b", - ) - different_environment = PredictorConfig( - git_repo=GitRepo(url="./git_url", commit="commit_hash", python_version="3.12.8"), - bin_name="predict-a", - ) - - assert predictor == same_environment - assert hash(predictor) == hash(same_environment) - assert predictor != different_environment - - -def test_mark_as_installed_remains_path_only(installer: BaseInstaller) -> None: - py = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash", python_version="3.11.9")) - - res = py.mark_as_installed(installer) + mock_run.return_value = CompletedProcess(args=[], returncode=0, stdout=f"{git.commit}\n", stderr="") + res = py.install(installer) assert res.success assert py.git_repo.installed_path == installer.system.install_path / py.git_repo.repo_name assert py.venv_path == installer.system.install_path / py.venv_name - assert py.git_repo.installed_path is not None - assert py.venv_path is not None - assert not py.git_repo.installed_path.exists() - assert not py.venv_path.exists() -def test_is_installed_no_repo(installer: BaseInstaller, git: GitRepo) -> None: +def test_is_installed_no_repo(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) - res = py.is_installed(installer) - assert not res.success assert res.message == f"Git repository {py.git_repo.url} not cloned" - assert py.git_repo.installed_path is None - assert py.venv_path is None + assert not (installer.system.install_path / py.git_repo.repo_name).exists() + assert not py.git_repo.installed_path + assert not (installer.system.install_path / py.venv_name).exists() + assert not py.venv_path -def test_is_installed_no_venv(installer: BaseInstaller, git: GitRepo) -> None: +def test_is_installed_no_venv(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) (installer.system.install_path / py.git_repo.repo_name).mkdir() - res = py.is_installed(installer) - assert not res.success assert res.message == f"Virtual environment not created for {py.git_repo.url}" assert py.git_repo.installed_path == installer.system.install_path / py.git_repo.repo_name - assert py.venv_path is None + assert (installer.system.install_path / py.git_repo.repo_name).exists() + assert not (installer.system.install_path / py.venv_name).exists() + assert not py.venv_path -def test_uninstall_no_venv(installer: BaseInstaller, git: GitRepo) -> None: +def test_is_installed_ok(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) - py.venv_path = installer.system.install_path / py.venv_name + (installer.system.install_path / py.git_repo.repo_name).mkdir() + (installer.system.install_path / py.venv_name).mkdir() + res = py.is_installed(installer) + assert res.success + assert res.message == "Python executable installed" + assert py.git_repo.installed_path == installer.system.install_path / py.git_repo.repo_name + assert (installer.system.install_path / py.git_repo.repo_name).exists() + assert py.venv_path == installer.system.install_path / py.venv_name + assert py.venv_path - res = py.uninstall(installer) +def test_uninstall_no_venv(installer: BaseInstaller, git: GitRepo): + py = PythonExecutable(git) + py.venv_path = installer.system.install_path / py.venv_name + res = py.uninstall(installer) assert res.success assert res.message == f"Virtual environment {py.venv_name} is not created." -def test_uninstall_venv_removed_ok(installer: BaseInstaller, git: GitRepo) -> None: +def test_uninstall_venv_removed_ok(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) (installer.system.install_path / py.venv_name).mkdir() (installer.system.install_path / py.venv_name / "file").touch() py.venv_path = installer.system.install_path / py.venv_name - res = py.uninstall(installer) - assert res.success assert not (installer.system.install_path / py.venv_name).exists() - assert py.venv_path is None - - -def test_requirements_no_file(installer: BaseInstaller, git: GitRepo) -> None: - py = PythonExecutable(git) - venv_path = installer.system.install_path / py.venv_name - venv_path.mkdir() - - res = py._install_requirements(venv_path, installer.system.install_path / "requirements.txt") - - assert not res.success - assert ( - res.message - == f"Requirements file is invalid or does not exist: {installer.system.install_path / 'requirements.txt'}" - ) - - -def test_requirements_are_installed_with_venv_python(installer: BaseInstaller) -> None: - requirements_file = installer.system.install_path / "requirements.txt" - venv_path = installer.system.install_path / "venv" - requirements_file.touch() - - with patch("subprocess.run") as run: - run.return_value = CompletedProcess(args=[], returncode=0) - res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( - venv_path, requirements_file - ) - - assert res.success - run.assert_called_once_with( - [str(venv_path / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_file)], - capture_output=True, - text=True, - ) - - -def test_pyproject_is_installed_with_venv_python(installer: BaseInstaller) -> None: - project_dir = installer.system.install_path / "project" - venv_path = installer.system.install_path / "venv" - project_dir.mkdir() - - with patch("subprocess.run") as run: - run.return_value = CompletedProcess(args=[], returncode=0) - res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_pyproject( - venv_path, project_dir - ) - - assert res.success - run.assert_called_once_with( - [str(venv_path / "bin" / "python"), "-m", "pip", "install", str(project_dir)], - capture_output=True, - text=True, - ) - - -def test_requirements_installation_failure_is_reported(installer: BaseInstaller) -> None: - requirements_file = installer.system.install_path / "requirements.txt" - requirements_file.touch() - - with patch("subprocess.run") as run: - run.return_value = CompletedProcess(args=[], returncode=1, stderr="err") - res = PythonExecutable(GitRepo(url="./git_url", commit="commit_hash"))._install_requirements( - installer.system.install_path, requirements_file - ) - - assert not res.success - assert res.message == "Failed to install dependencies from requirements.txt: err" + assert not py.venv_path def test_install_python_executable_prefers_pyproject_toml( installer: BaseInstaller, git: GitRepo, setup_repo, -) -> None: +): repo_dir, subdir, _, _ = setup_repo + py = PythonExecutable(git, project_subpath=Path("subdir"), dependencies_from_pyproject=True) py.git_repo.installed_path = repo_dir @@ -577,8 +295,9 @@ def test_install_python_executable_prefers_requirements_txt( installer: BaseInstaller, git: GitRepo, setup_repo, -) -> None: - repo_dir, subdir, _, _ = setup_repo +): + repo_dir, *_ = setup_repo + py = PythonExecutable(git, project_subpath=Path("subdir"), dependencies_from_pyproject=False) py.git_repo.installed_path = repo_dir @@ -590,64 +309,4 @@ def test_install_python_executable_prefers_requirements_txt( assert res.success pyproject.assert_not_called() - reqs.assert_called_once_with(installer.system.install_path / py.venv_name, subdir / "requirements.txt") - - -@pytest.mark.ci_only -def test_python_executable_installs_repository_pinned_python_3119( - installer: BaseInstaller, - tmp_path: Path, -) -> None: - if sys.version_info[:2] != (3, 14): - pytest.skip("This interpreter-independence integration test requires the Python 3.14 CI job.") - - source_repo = tmp_path / "source-repo" - source_repo.mkdir() - (source_repo / ".python-version").write_text("3.11.9\n") - (source_repo / "requirements.txt").touch() - subprocess.run( - ["git", "init", "--initial-branch=main", str(source_repo)], - check=True, - capture_output=True, - text=True, - ) - subprocess.run(["git", "add", "."], cwd=source_repo, check=True, capture_output=True, text=True) - subprocess.run( - [ - "git", - "-c", - "user.name=CloudAI Tests", - "-c", - "user.email=cloudai-tests@nvidia.com", - "commit", - "-m", - "Add pinned Python project", - ], - cwd=source_repo, - check=True, - capture_output=True, - text=True, - ) - commit = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=source_repo, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - - git = GitRepo(url=str(source_repo), commit=commit) - py = PythonExecutable(git) - - res = py.install(installer) - - assert res.success, res.message - assert git.installed_path == installer.system.install_path / git.repo_name - assert py.venv_path is not None - version = subprocess.run( - [str(py.venv_path / "bin" / "python"), "-c", "import platform; print(platform.python_version())"], - check=True, - capture_output=True, - text=True, - ) - assert version.stdout.strip() == "3.11.9" + reqs.assert_called_once() From 8a0c3ec62c15cf9ca3ae4b21a1e9ecd03f08796f Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 15:06:06 +0200 Subject: [PATCH 4/9] test: trim GitRepo Python version coverage --- tests/core/installables/test_git_repo.py | 64 ++++++------------------ 1 file changed, 15 insertions(+), 49 deletions(-) diff --git a/tests/core/installables/test_git_repo.py b/tests/core/installables/test_git_repo.py index 4c31cf7cf..12aabef1b 100644 --- a/tests/core/installables/test_git_repo.py +++ b/tests/core/installables/test_git_repo.py @@ -20,7 +20,6 @@ from unittest.mock import MagicMock, patch import pytest -import toml from cloudai.core import BaseInstaller, GitRepo, InstallStatusResult, TestDefinition from cloudai.models.scenario import TestRunModel @@ -74,15 +73,6 @@ def test_git_repo_name(url: str, expected: str): assert GitRepo(url=url, commit="commit").repo_name == expected -def test_python_version_is_optional_and_round_trips() -> None: - default = GitRepo.model_validate({"url": "./repo", "commit": "main"}) - pinned = GitRepo.model_validate({"url": "./repo", "commit": "main", "python_version": "3.11.9"}) - - assert default.python_version is None - assert pinned.python_version == "3.11.9" - assert pinned.model_dump()["python_version"] == "3.11.9" - - 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") @@ -94,58 +84,34 @@ def test_python_version_does_not_change_git_clone_identity() -> None: def test_test_definition_git_repo_accepts_python_version() -> None: - data = toml.loads( - """ -name = "test" -description = "description" -test_template_name = "Example" - -[cmd_args] - -[[git_repos]] -url = "./repo" -commit = "main" -python_version = "3.11.9" -""" + 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"}], + } ) - tdef = TestDefinition.model_validate(data) assert tdef.git_repos[0].python_version == "3.11.9" - assert tdef.model_dump()["git_repos"][0]["python_version"] == "3.11.9" def test_scenario_git_repo_accepts_and_preserves_python_version() -> None: - data = toml.loads( - """ -name = "scenario" - -[[Tests]] -id = "case" -test_name = "base-test" - -[[Tests.git_repos]] -url = "./repo" -commit = "main" -python_version = "3.11.9" -""" + model = TestRunModel.model_validate( + { + "id": "case", + "test_name": "base-test", + "git_repos": [{"url": "./repo", "commit": "main", "python_version": "3.11.9"}], + } ) - model = TestRunModel.model_validate(data["Tests"][0]) assert model.git_repos is not None assert model.git_repos[0].python_version == "3.11.9" - assert model.tdef_model_dump(by_alias=True)["git_repos"][0]["python_version"] == "3.11.9" def test_git_repo_toml_without_python_version_remains_valid() -> None: - data = toml.loads( - """ -[[git_repos]] -url = "./repo" -commit = "main" -""" - ) - - repo = GitRepo.model_validate(data["git_repos"][0]) + repo = GitRepo.model_validate({"url": "./repo", "commit": "main"}) assert repo.python_version is None From 05fa741962eacba4b2c9b66865fd0e24c12b12e5 Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 16:11:48 +0200 Subject: [PATCH 5/9] refactor: call uv binary lookup directly --- src/cloudai/_core/installables/_uv.py | 22 ------------------- .../_core/installables/python_environment.py | 11 +++++----- .../_core/installables/python_executable.py | 5 +++-- .../installables/test_python_environment.py | 4 ++-- .../installables/test_python_executable.py | 6 ++--- 5 files changed, 14 insertions(+), 34 deletions(-) delete mode 100644 src/cloudai/_core/installables/_uv.py diff --git a/src/cloudai/_core/installables/_uv.py b/src/cloudai/_core/installables/_uv.py deleted file mode 100644 index 52f38b2fd..000000000 --- a/src/cloudai/_core/installables/_uv.py +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-FileCopyrightText: NVIDIA CORPORATION & AFFILIATES -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import uv - - -def resolve_uv_bin() -> str: - """Return the uv executable shipped with the current CloudAI installation.""" - return uv.find_uv_bin() diff --git a/src/cloudai/_core/installables/python_environment.py b/src/cloudai/_core/installables/python_environment.py index be12933c8..4c7a01c6b 100644 --- a/src/cloudai/_core/installables/python_environment.py +++ b/src/cloudai/_core/installables/python_environment.py @@ -24,7 +24,8 @@ import sys from typing import TYPE_CHECKING -from ._uv import resolve_uv_bin +import uv + from .base import Installable, InstallStatusResult if TYPE_CHECKING: @@ -72,22 +73,22 @@ def install(self, installer: "BaseInstaller") -> InstallStatusResult: return installed try: - uv = resolve_uv_bin() + 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 diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index d6708864a..bb590484a 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -22,7 +22,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Optional -from ._uv import resolve_uv_bin +import uv + from .base import Installable, InstallStatusResult from .git_repo import GitRepo @@ -116,7 +117,7 @@ def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: project_dir = repo_path / self.project_subpath if self.project_subpath else repo_path python_version = self._resolve_python_version(repo_path) try: - uv_bin = resolve_uv_bin() + uv_bin = uv.find_uv_bin() except OSError as e: return InstallStatusResult(False, f"Cannot create virtual environment: {e}") diff --git a/tests/core/installables/test_python_environment.py b/tests/core/installables/test_python_environment.py index 1c259144c..ccc43b888 100644 --- a/tests/core/installables/test_python_environment.py +++ b/tests/core/installables/test_python_environment.py @@ -45,7 +45,7 @@ def test_python_environment_install_uses_uv(installer: BaseInstaller) -> None: with ( patch( - "cloudai._core.installables.python_environment.resolve_uv_bin", + "cloudai._core.installables.python_environment.uv.find_uv_bin", return_value="/cloudai/bin/uv", ) as resolve_uv, patch("subprocess.run") as run, @@ -79,7 +79,7 @@ def test_python_environment_reports_uv_resolution_failure(installer: BaseInstall env = PythonEnvironment(name="aiconfigurator", python_version="3.10") with patch( - "cloudai._core.installables.python_environment.resolve_uv_bin", + "cloudai._core.installables.python_environment.uv.find_uv_bin", side_effect=OSError("uv is unavailable"), ): res = env.install(installer) diff --git a/tests/core/installables/test_python_executable.py b/tests/core/installables/test_python_executable.py index 6c3ef17ac..c2b22a4bc 100644 --- a/tests/core/installables/test_python_executable.py +++ b/tests/core/installables/test_python_executable.py @@ -95,7 +95,7 @@ def test_venv_created(installer: BaseInstaller, git: GitRepo): py = PythonExecutable(git) venv_path = installer.system.install_path / py.venv_name with ( - patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), + patch("cloudai._core.installables.python_executable.uv.find_uv_bin", return_value="/cloudai/bin/uv"), patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), patch("subprocess.run") as mock_run, ): @@ -128,7 +128,7 @@ def mock_run(*args, **kwargs): dependencies_result = InstallStatusResult(False, "err") if reqs_install_failure else InstallStatusResult(True) with ( - patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), + patch("cloudai._core.installables.python_executable.uv.find_uv_bin", return_value="/cloudai/bin/uv"), patch.object(PythonExecutable, "_install_dependencies", return_value=dependencies_result), patch("subprocess.run", side_effect=mock_run), ): @@ -204,7 +204,7 @@ def test_all_good_flow(installer: BaseInstaller, git: GitRepo): pyproject_file.write_text("[tool.poetry]\nname = 'dummy_project'") with ( - patch("cloudai._core.installables.python_executable.resolve_uv_bin", return_value="/cloudai/bin/uv"), + patch("cloudai._core.installables.python_executable.uv.find_uv_bin", return_value="/cloudai/bin/uv"), patch("subprocess.run") as mock_run, ): mock_run.return_value = CompletedProcess(args=[], returncode=0, stdout=f"{git.commit}\n", stderr="") From 48ce4254b7a22d1c405033fe6b6e13236d15e2ff Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 16:16:33 +0200 Subject: [PATCH 6/9] refactor: trust installed repository path --- src/cloudai/_core/installables/python_executable.py | 8 +++++--- tests/core/installables/test_python_executable.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index bb590484a..47bd67c31 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -18,6 +18,7 @@ import shutil import subprocess import sys +import typing from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -113,9 +114,10 @@ def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: logging.debug(msg) return InstallStatusResult(True, msg) - repo_path = self.git_repo.installed_path or installer.system.install_path / self.git_repo.repo_name - project_dir = repo_path / self.project_subpath if self.project_subpath else repo_path - python_version = self._resolve_python_version(repo_path) + project_dir = typing.cast(Path, self.git_repo.installed_path) + python_version = self._resolve_python_version(project_dir) + if self.project_subpath: + project_dir /= self.project_subpath try: uv_bin = uv.find_uv_bin() except OSError as e: diff --git a/tests/core/installables/test_python_executable.py b/tests/core/installables/test_python_executable.py index c2b22a4bc..668f95e30 100644 --- a/tests/core/installables/test_python_executable.py +++ b/tests/core/installables/test_python_executable.py @@ -117,6 +117,7 @@ def test_error_creating_venv( failure_on_venv_creation: bool, reqs_install_failure: bool, ): + git.installed_path = installer.system.install_path / git.repo_name py = PythonExecutable(git) venv_path = installer.system.install_path / py.venv_name From fa098e565219ac60572ab8613888c63fb39a504b Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Mon, 7 Sep 2026 16:50:45 +0200 Subject: [PATCH 7/9] fix: rebuild stale pinned Python environments --- .../_core/installables/python_executable.py | 28 ++++++++++++++-- .../installables/test_python_executable.py | 33 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index 47bd67c31..6862f6606 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -32,6 +32,9 @@ from ..base_installer import BaseInstaller +_PYTHON_VERSION_MARKER = ".cloudai-python-version" + + @dataclass class PythonExecutable(Installable): """Python executable object.""" @@ -97,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") @@ -108,14 +116,14 @@ 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) - project_dir = typing.cast(Path, self.git_repo.installed_path) - python_version = self._resolve_python_version(project_dir) if self.project_subpath: project_dir /= self.project_subpath try: @@ -123,6 +131,10 @@ def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: except OSError as e: return InstallStatusResult(False, f"Cannot create virtual environment: {e}") + if venv_path.exists(): + logging.info(f"Recreating virtual environment at {venv_path} for Python {python_version}") + shutil.rmtree(venv_path) + cmd = [uv_bin, "venv", "--python", python_version, "--seed", str(venv_path)] logging.debug(f"Creating venv using cmd: {' '.join(cmd)}") result = subprocess.run(cmd, cwd=str(project_dir), capture_output=True, text=True) @@ -140,6 +152,9 @@ 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) @@ -166,6 +181,13 @@ def _resolve_python_version(self, repo_path: Path) -> str: 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 diff --git a/tests/core/installables/test_python_executable.py b/tests/core/installables/test_python_executable.py index 668f95e30..62d177f37 100644 --- a/tests/core/installables/test_python_executable.py +++ b/tests/core/installables/test_python_executable.py @@ -99,7 +99,7 @@ def test_venv_created(installer: BaseInstaller, git: GitRepo): patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), patch("subprocess.run") as mock_run, ): - mock_run.return_value = CompletedProcess(args=[], returncode=0) + mock_run.side_effect = lambda *args, **kwargs: venv_path.mkdir() or CompletedProcess(args=[], returncode=0) res = py._create_venv(installer) assert res.success mock_run.assert_called_once_with( @@ -108,6 +108,36 @@ def test_venv_created(installer: BaseInstaller, git: GitRepo): capture_output=True, text=True, ) + assert (venv_path / ".cloudai-python-version").read_text().strip() == "3.11.9" + + +def test_existing_pinned_venv_without_version_marker_is_recreated(installer: BaseInstaller, git: GitRepo): + repo_path = installer.system.install_path / git.repo_name + repo_path.mkdir() + (repo_path / ".python-version").write_text("3.11.9\n") + git.installed_path = repo_path + py = PythonExecutable(git) + venv_path = installer.system.install_path / py.venv_name + venv_path.mkdir() + stale_file = venv_path / "stale" + stale_file.touch() + + assert not py.is_installed(installer).success + + def create_venv(*args, **kwargs): + assert not stale_file.exists() + venv_path.mkdir() + return CompletedProcess(args=[], returncode=0) + + with ( + patch("cloudai._core.installables.python_executable.uv.find_uv_bin", return_value="/cloudai/bin/uv"), + patch.object(PythonExecutable, "_install_dependencies", return_value=InstallStatusResult(True)), + patch("subprocess.run", side_effect=create_venv), + ): + res = py._create_venv(installer) + + assert res.success + assert (venv_path / ".cloudai-python-version").read_text().strip() == "3.11.9" @pytest.mark.parametrize("failure_on_venv_creation,reqs_install_failure", [(True, False), (False, True)]) @@ -143,6 +173,7 @@ def mock_run(*args, **kwargs): def test_venv_already_exists(installer: BaseInstaller, git: GitRepo): + git.installed_path = installer.system.install_path / git.repo_name py = PythonExecutable(git) venv_path = installer.system.install_path / py.venv_name venv_path.mkdir() From 8aa72a57a78c2b30bd78314f3c8c52ecce5948bf Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Tue, 8 Sep 2026 11:38:46 +0200 Subject: [PATCH 8/9] refactor: remove venv recreation log --- src/cloudai/_core/installables/python_executable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index 6862f6606..ec5addecd 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -132,7 +132,6 @@ def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: return InstallStatusResult(False, f"Cannot create virtual environment: {e}") if venv_path.exists(): - logging.info(f"Recreating virtual environment at {venv_path} for Python {python_version}") shutil.rmtree(venv_path) cmd = [uv_bin, "venv", "--python", python_version, "--seed", str(venv_path)] From c0ce3bdac7e1c0ac067ee88a13992fca1d636696 Mon Sep 17 00:00:00 2001 From: Ivan Podkidyshev Date: Wed, 9 Sep 2026 16:35:57 +0200 Subject: [PATCH 9/9] fix: validate Python project before venv cleanup --- src/cloudai/_core/installables/python_executable.py | 6 ++++-- tests/core/installables/test_python_executable.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cloudai/_core/installables/python_executable.py b/src/cloudai/_core/installables/python_executable.py index ec5addecd..1c6f8342b 100644 --- a/src/cloudai/_core/installables/python_executable.py +++ b/src/cloudai/_core/installables/python_executable.py @@ -124,8 +124,10 @@ def _create_venv(self, installer: "BaseInstaller") -> InstallStatusResult: logging.debug(msg) return InstallStatusResult(True, msg) - if self.project_subpath: - project_dir /= self.project_subpath + 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: diff --git a/tests/core/installables/test_python_executable.py b/tests/core/installables/test_python_executable.py index 62d177f37..9fad2279c 100644 --- a/tests/core/installables/test_python_executable.py +++ b/tests/core/installables/test_python_executable.py @@ -92,6 +92,7 @@ def test_python_version_falls_back_to_cloudai_interpreter(tmp_path: Path): def test_venv_created(installer: BaseInstaller, git: GitRepo): git.python_version = "3.11.9" git.installed_path = installer.system.install_path / git.repo_name + git.installed_path.mkdir() py = PythonExecutable(git) venv_path = installer.system.install_path / py.venv_name with ( @@ -148,6 +149,7 @@ def test_error_creating_venv( reqs_install_failure: bool, ): git.installed_path = installer.system.install_path / git.repo_name + git.installed_path.mkdir() py = PythonExecutable(git) venv_path = installer.system.install_path / py.venv_name