Skip to content

Commit c91ef1e

Browse files
authored
Support installing PythonExecutable in a subpath (#404)
1 parent a6c6566 commit c91ef1e

File tree

3 files changed

+115
-24
lines changed

3 files changed

+115
-24
lines changed

src/cloudai/_core/installables.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ class PythonExecutable(Installable):
118118

119119
git_repo: GitRepo
120120
venv_path: Optional[Path] = None
121+
project_subpath: Optional[Path] = None
122+
dependencies_from_pyproject: bool = True
121123

122124
def __eq__(self, other: object) -> bool:
123125
"""Check if two installable objects are equal."""

src/cloudai/installer/slurm_installer.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,30 @@ def _install_python_executable(self, item: PythonExecutable) -> InstallStatusRes
236236
return res
237237

238238
assert item.git_repo.installed_path, "Git repository must be installed before creating virtual environment."
239-
requirements_txt = item.git_repo.installed_path / "requirements.txt"
240-
res = self._install_requirements(venv_path, requirements_txt)
239+
240+
project_dir = item.git_repo.installed_path
241+
if item.project_subpath:
242+
project_dir = project_dir / item.project_subpath
243+
244+
pyproject_toml = project_dir / "pyproject.toml"
245+
requirements_txt = project_dir / "requirements.txt"
246+
247+
if pyproject_toml.exists() and requirements_txt.exists():
248+
if item.dependencies_from_pyproject:
249+
res = self._install_pyproject(venv_path, project_dir)
250+
else:
251+
res = self._install_requirements(venv_path, requirements_txt)
252+
elif pyproject_toml.exists():
253+
res = self._install_pyproject(venv_path, project_dir)
254+
elif requirements_txt.exists():
255+
res = self._install_requirements(venv_path, requirements_txt)
256+
else:
257+
return InstallStatusResult(False, "No pyproject.toml or requirements.txt found for installation.")
258+
241259
if not res.success:
242260
return res
243261

244262
item.venv_path = venv_path
245-
246263
return InstallStatusResult(True)
247264

248265
def _clone_repository(self, git_url: str, path: Path) -> InstallStatusResult:
@@ -273,17 +290,24 @@ def _create_venv(self, venv_dir: Path) -> InstallStatusResult:
273290
return InstallStatusResult(False, f"Failed to create venv: {result.stderr}")
274291
return InstallStatusResult(True)
275292

293+
def _install_pyproject(self, venv_dir: Path, project_dir: Path) -> InstallStatusResult:
294+
install_cmd = [str(venv_dir / "bin" / "python"), "-m", "pip", "install", str(project_dir)]
295+
result = subprocess.run(install_cmd, capture_output=True, text=True)
296+
297+
if result.returncode != 0:
298+
return InstallStatusResult(False, f"Failed to install {project_dir} using pip: {result.stderr}")
299+
300+
return InstallStatusResult(True)
301+
276302
def _install_requirements(self, venv_dir: Path, requirements_txt: Path) -> InstallStatusResult:
277-
if not requirements_txt.is_file() or not requirements_txt.exists():
278-
msg = f"Requirements file is invalid or does not exist: {requirements_txt}"
279-
logging.warning(msg)
280-
return InstallStatusResult(False, msg)
303+
if not requirements_txt.is_file():
304+
return InstallStatusResult(False, f"Requirements file is invalid or does not exist: {requirements_txt}")
281305

282-
install_cmd = [(venv_dir / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_txt)]
283-
logging.debug(f"Installing requirements from {requirements_txt} using command: {install_cmd}")
306+
install_cmd = [str(venv_dir / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_txt)]
284307
result = subprocess.run(install_cmd, capture_output=True, text=True)
308+
285309
if result.returncode != 0:
286-
return InstallStatusResult(False, f"Failed to install requirements: {result.stderr}")
310+
return InstallStatusResult(False, f"Failed to install dependencies from requirements.txt: {result.stderr}")
287311

288312
return InstallStatusResult(True)
289313

tests/test_slurm_installer.py

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,22 @@ class TestInstallOnePythonExecutable:
155155
def git(self):
156156
return GitRepo(url="./git_url", commit="commit_hash")
157157

158+
@pytest.fixture
159+
def setup_repo(self, installer: SlurmInstaller, git: GitRepo):
160+
repo_dir = installer.system.install_path / git.repo_name
161+
subdir = repo_dir / "subdir"
162+
163+
repo_dir.mkdir(parents=True, exist_ok=True)
164+
subdir.mkdir(parents=True, exist_ok=True)
165+
166+
pyproject_file = subdir / "pyproject.toml"
167+
requirements_file = subdir / "requirements.txt"
168+
169+
pyproject_file.touch()
170+
requirements_file.touch()
171+
172+
return repo_dir, subdir, pyproject_file, requirements_file
173+
158174
def test_venv_created(self, installer: SlurmInstaller, git: GitRepo):
159175
py = PythonExecutable(git)
160176
venv_path = installer.system.install_path / py.venv_name
@@ -184,10 +200,14 @@ def test_venv_already_exists(self, installer: SlurmInstaller, git: GitRepo):
184200
assert res.success
185201
assert res.message == f"Virtual environment already exists at {venv_path}."
186202

187-
def test_requiretements_no_file(self, installer: SlurmInstaller):
188-
res = installer._install_requirements(
189-
installer.system.install_path, installer.system.install_path / "requirements.txt"
190-
)
203+
def test_requirements_no_file(self, installer: SlurmInstaller, git: GitRepo):
204+
py = PythonExecutable(git)
205+
venv_path = installer.system.install_path / py.venv_name
206+
with patch("subprocess.run") as mock_run:
207+
mock_run.return_value = CompletedProcess(args=[], returncode=0)
208+
res = installer._create_venv(venv_path)
209+
assert res.success
210+
res = installer._install_requirements(venv_path, installer.system.install_path / "requirements.txt")
191211
assert not res.success
192212
assert (
193213
res.message
@@ -196,20 +216,14 @@ def test_requiretements_no_file(self, installer: SlurmInstaller):
196216

197217
def test_requirements_installed(self, installer: SlurmInstaller):
198218
requirements_file = installer.system.install_path / "requirements.txt"
219+
venv_path = installer.system.install_path / "venv"
199220
requirements_file.touch()
200221
with patch("subprocess.run") as mock_run:
201222
mock_run.return_value = CompletedProcess(args=[], returncode=0)
202-
res = installer._install_requirements(installer.system.install_path, requirements_file)
223+
res = installer._install_requirements(venv_path, requirements_file)
203224
assert res.success
204225
mock_run.assert_called_once_with(
205-
[
206-
installer.system.install_path / "bin" / "python",
207-
"-m",
208-
"pip",
209-
"install",
210-
"-r",
211-
str(requirements_file),
212-
],
226+
[str(venv_path / "bin" / "python"), "-m", "pip", "install", "-r", str(requirements_file)],
213227
capture_output=True,
214228
text=True,
215229
)
@@ -221,15 +235,22 @@ def test_requirements_not_installed(self, installer: SlurmInstaller):
221235
mock_run.return_value = CompletedProcess(args=[], returncode=1, stderr="err")
222236
res = installer._install_requirements(installer.system.install_path, requirements_file)
223237
assert not res.success
224-
assert res.message == "Failed to install requirements: err"
238+
assert res.message == "Failed to install dependencies from requirements.txt: err"
225239

226240
def test_all_good_flow(self, installer: SlurmInstaller, git: GitRepo):
227241
py = PythonExecutable(git)
228242
py.git_repo.installed_path = installer.system.install_path / py.git_repo.repo_name
243+
244+
repo_dir = py.git_repo.installed_path
245+
repo_dir.mkdir(parents=True, exist_ok=True)
246+
pyproject_file = repo_dir / "pyproject.toml"
247+
pyproject_file.write_text("[tool.poetry]\nname = 'dummy_project'")
248+
229249
installer._install_one_git_repo = Mock(return_value=InstallStatusResult(True))
230250
installer._clone_repository = Mock(return_value=InstallStatusResult(True))
231251
installer._checkout_commit = Mock(return_value=InstallStatusResult(True))
232252
installer._create_venv = Mock(return_value=InstallStatusResult(True))
253+
installer._install_pyproject = Mock(return_value=InstallStatusResult(True))
233254
installer._install_requirements = Mock(return_value=InstallStatusResult(True))
234255
res = installer._install_python_executable(py)
235256
assert res.success
@@ -286,6 +307,50 @@ def test_uninstall_venv_removed_ok(self, installer: SlurmInstaller, git: GitRepo
286307
assert not (installer.system.install_path / py.venv_name).exists()
287308
assert not py.venv_path
288309

310+
def test_install_python_executable_prefers_pyproject_toml(
311+
self, installer: SlurmInstaller, git: GitRepo, setup_repo
312+
):
313+
repo_dir, subdir, _, _ = setup_repo
314+
315+
py = PythonExecutable(git, project_subpath=Path("subdir"), dependencies_from_pyproject=True)
316+
317+
installer._install_one_git_repo = Mock(return_value=InstallStatusResult(True))
318+
installer._create_venv = Mock(return_value=InstallStatusResult(True))
319+
installer._install_requirements = Mock(return_value=InstallStatusResult(True))
320+
installer._install_pyproject = Mock(return_value=InstallStatusResult(True))
321+
322+
py.git_repo.installed_path = repo_dir
323+
324+
res = installer._install_python_executable(py)
325+
326+
assert res.success
327+
installer._install_pyproject.assert_called_once_with(installer.system.install_path / py.venv_name, subdir)
328+
installer._install_requirements.assert_not_called()
329+
assert py.venv_path == installer.system.install_path / py.venv_name
330+
331+
def test_install_python_executable_prefers_requirements_txt(
332+
self, installer: SlurmInstaller, git: GitRepo, setup_repo
333+
):
334+
repo_dir, subdir, _, requirements_file = setup_repo
335+
336+
py = PythonExecutable(git, project_subpath=Path("subdir"), dependencies_from_pyproject=False)
337+
338+
installer._install_one_git_repo = Mock(return_value=InstallStatusResult(True))
339+
installer._create_venv = Mock(return_value=InstallStatusResult(True))
340+
installer._install_requirements = Mock(return_value=InstallStatusResult(True))
341+
installer._install_pyproject = Mock(return_value=InstallStatusResult(True))
342+
343+
py.git_repo.installed_path = repo_dir
344+
345+
res = installer._install_python_executable(py)
346+
347+
assert res.success
348+
installer._install_requirements.assert_called_once_with(
349+
installer.system.install_path / py.venv_name, requirements_file
350+
)
351+
installer._install_pyproject.assert_not_called()
352+
assert py.venv_path == installer.system.install_path / py.venv_name
353+
289354

290355
class TestGitRepo:
291356
@pytest.fixture

0 commit comments

Comments
 (0)