Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/google/adk/cli/cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import json
import os
import shutil
import stat
import subprocess
import sys
import traceback
Expand All @@ -36,6 +37,23 @@
)


def _on_rm_error(func, path, exc_info):
"""Error handler for shutil.rmtree to handle read-only files on Windows."""
os.chmod(path, stat.S_IWRITE)
func(path)


def _robust_rmtree(path: str) -> None:
"""Remove a directory tree, handling read-only files on Windows."""
if _IS_WINDOWS:
if sys.version_info >= (3, 12):
shutil.rmtree(path, onexc=lambda fn, p, exc: _on_rm_error(fn, p, None))
else:
shutil.rmtree(path, onerror=_on_rm_error)
else:
shutil.rmtree(path)


def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None:
"""Ensures staged requirements include Agent Engine dependencies."""
if not os.path.exists(requirements_txt_path):
Expand Down Expand Up @@ -692,7 +710,7 @@ def to_cloud_run(
# remove temp_folder if exists
if os.path.exists(temp_folder):
click.echo('Removing existing files')
shutil.rmtree(temp_folder)
_robust_rmtree(temp_folder)

try:
# copy agent source code
Expand Down Expand Up @@ -799,7 +817,7 @@ def to_cloud_run(
subprocess.run(gcloud_cmd, check=True)
finally:
click.echo(f'Cleaning up the temp folder: {temp_folder}')
shutil.rmtree(temp_folder)
_robust_rmtree(temp_folder)


def to_agent_engine(
Expand Down Expand Up @@ -924,7 +942,7 @@ def to_agent_engine(
# remove agent_src_path if it exists
if os.path.exists(agent_src_path):
click.echo('Removing existing files')
shutil.rmtree(agent_src_path)
_robust_rmtree(agent_src_path)

try:
click.echo(f'Staging all files in: {agent_src_path}')
Expand Down Expand Up @@ -1136,7 +1154,7 @@ def to_agent_engine(
click.secho(f'✅ Updated agent engine: {agent_engine_id}', fg='green')
finally:
click.echo(f'Cleaning up the temp folder: {temp_folder}')
shutil.rmtree(agent_src_path)
_robust_rmtree(agent_src_path)
if did_change_cwd:
os.chdir(original_cwd)

Expand Down Expand Up @@ -1212,7 +1230,7 @@ def to_gke(
# remove temp_folder if exists
if os.path.exists(temp_folder):
click.echo(' - Removing existing temporary directory...')
shutil.rmtree(temp_folder)
_robust_rmtree(temp_folder)

try:
# copy agent source code
Expand Down Expand Up @@ -1376,7 +1394,7 @@ def to_gke(
finally:
click.secho('\nSTEP 5: Cleaning up...', bold=True)
click.echo(f' - Removing temporary directory: {temp_folder}')
shutil.rmtree(temp_folder)
_robust_rmtree(temp_folder)
click.secho(
'\n🎉 Deployment to GKE finished successfully!', fg='cyan', bold=True
)
42 changes: 42 additions & 0 deletions tests/unittests/cli/utils/test_cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,45 @@ def test_restores_sys_path(self, tmp_path: Path) -> None:
)

assert sys.path == original_path


# _robust_rmtree / _on_rm_error tests


class TestRobustRmtree:
"""Tests for the _robust_rmtree helper."""

def test_removes_directory_tree(self, tmp_path: Path) -> None:
"""It should remove a normal directory tree."""
d = tmp_path / "subdir"
d.mkdir()
(d / "file.txt").write_text("hello")
cli_deploy._robust_rmtree(str(d))
assert not d.exists()

def test_removes_readonly_files(self, tmp_path: Path) -> None:
"""It should remove a tree containing read-only files."""
import os
import stat

d = tmp_path / "ro_dir"
d.mkdir()
ro_file = d / "readonly.txt"
ro_file.write_text("locked")
ro_file.chmod(stat.S_IREAD)
cli_deploy._robust_rmtree(str(d))
assert not d.exists()

def test_on_rm_error_clears_readonly_and_retries(
self, tmp_path: Path
) -> None:
"""_on_rm_error should chmod the file and call the removal function."""
import os
import stat

ro_file = tmp_path / "locked.txt"
ro_file.write_text("data")
ro_file.chmod(stat.S_IREAD)

cli_deploy._on_rm_error(os.remove, str(ro_file), None)
assert not ro_file.exists()