Skip to content
32 changes: 31 additions & 1 deletion src/google/adk/cli/cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,36 @@ def _validate_gcloud_extra_args(
)


def _validate_agent_folder_name(agent_folder: str) -> str:
"""Validates that the agent directory name is a valid Python identifier.

Agent Engine requires the agent folder to be importable as a Python module.
Directory names containing hyphens (-) or other non-identifier characters
lead to syntax and import errors at deployment runtime.

Args:
agent_folder: Path to the agent directory.

Returns:
The validated folder name.

Raises:
click.ClickException: If the folder name is not a valid Python identifier.
"""
folder_name = os.path.basename(os.path.normpath(agent_folder))
if not folder_name.isidentifier():
suggested = re.sub(r'\W|^(?=\d)', '_', folder_name)
if not suggested.isidentifier():
suggested = f'_{suggested}'
raise click.ClickException(
f"Agent directory name '{folder_name}' is not a valid Python"
" identifier (cannot contain dashes '-' or special characters)."
" Agent Engine requires the directory name to be a valid Python"
f" module name. Please rename your agent folder (e.g., '{suggested}')."
)
return folder_name


def _validate_agent_import(
agent_src_path: str,
adk_app_object: str,
Expand Down Expand Up @@ -1107,7 +1137,7 @@ def to_agent_engine(
Overrides `worker_pool` / `build_config.worker_pool` from
`.agent_engine_config.json` when both are present.
"""
app_name = os.path.basename(os.path.normpath(agent_folder))
app_name = _validate_agent_folder_name(agent_folder)
_validate_app_name(app_name)
display_name = display_name or app_name
parent_folder = os.path.dirname(os.path.normpath(agent_folder))
Expand Down
52 changes: 51 additions & 1 deletion tests/unittests/cli/utils/test_cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1483,7 +1483,6 @@ def test_removes_directory_tree(self, tmp_path: Path) -> None:

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"
Expand All @@ -1509,6 +1508,57 @@ def test_on_rm_error_clears_readonly_and_retries(
assert not ro_file.exists()


# _validate_agent_folder_name tests


class TestValidateAgentFolderName:
"""Tests for the _validate_agent_folder_name helper."""

def test_valid_identifier_succeeds(self) -> None:
"""A valid identifier folder name should succeed and return basename."""
assert (
cli_deploy._validate_agent_folder_name("/path/to/my_agent")
== "my_agent"
)
assert cli_deploy._validate_agent_folder_name("agent123") == "agent123"
assert (
cli_deploy._validate_agent_folder_name("/path/to/my_agent/")
== "my_agent"
)

def test_invalid_folder_name_with_dashes_raises(self) -> None:
"""A folder name with dashes should raise ClickException with suggestions."""
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_folder_name("my-agent-app")
assert "not a valid Python identifier" in str(exc_info.value)
assert "my_agent_app" in str(exc_info.value)

def test_invalid_folder_name_starting_with_number_raises(self) -> None:
"""A folder name starting with a number should raise ClickException."""
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_folder_name("123agent")
assert "not a valid Python identifier" in str(exc_info.value)
assert "_123agent" in str(exc_info.value)

def test_to_agent_engine_raises_on_dashed_folder_name(
self, tmp_path: Path
) -> None:
"""to_agent_engine should fail early when agent directory contains dashes."""
dashed_agent = tmp_path / "dashed-agent-name"
dashed_agent.mkdir()
(dashed_agent / "agent.py").write_text("root_agent = 'test'\n")
(dashed_agent / "__init__.py").touch()

with pytest.raises(click.ClickException) as exc_info:
cli_deploy.to_agent_engine(
agent_folder=str(dashed_agent),
project="test-project",
region="us-central1",
)
assert "not a valid Python identifier" in str(exc_info.value)
assert "dashed_agent_name" in str(exc_info.value)


_VALID_WORKER_POOL = (
"projects/my-gcp-project/locations/us-central1/workerPools/my-private-pool"
)
Expand Down