From 4dfdd53dbb77d87a968a8e7cef0c0c205a207add Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sat, 10 Oct 2026 20:57:16 +0200 Subject: [PATCH 1/2] fix(cli): validate Python identifier for code-based agent creation Code-based agents created via adk create require dynamic module imports (e.g. importlib.import_module), which fail with ModuleNotFoundError when given kebab-case names or Python keywords. Add validation in cli_create.run_cmd ensuring code-based agent names are valid Python identifiers and not reserved keywords, aborting before any interactive dialogs. Leaves validate_app_name and config-based YAML agents unchanged to avoid breaking changes. --- src/google/adk/cli/cli_create.py | 19 +++- tests/unittests/cli/utils/test_cli_create.py | 100 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/google/adk/cli/cli_create.py b/src/google/adk/cli/cli_create.py index 42bffe29ada..f6f0f0b8abc 100644 --- a/src/google/adk/cli/cli_create.py +++ b/src/google/adk/cli/cli_create.py @@ -14,6 +14,7 @@ from __future__ import annotations +import keyword import os from typing import Optional @@ -227,6 +228,19 @@ def run_cmd( except ValueError as exc: raise click.BadParameter(str(exc)) from exc + if not type: + type = _prompt_to_choose_type() + + resolved_type = type.lower() + if resolved_type == "code" and ( + not app_name.isidentifier() or keyword.iskeyword(app_name) + ): + raise click.BadParameter( + f"Invalid agent name '{app_name}': code-based agents must be a valid" + " Python identifier (e.g. 'my_agent', not 'my-agent') and cannot be a" + " Python keyword." + ) + agent_folder = os.path.join(os.getcwd(), agent_name) # check folder doesn't exist or it's empty. Otherwise, throw if os.path.exists(agent_folder) and os.listdir(agent_folder): @@ -256,16 +270,13 @@ def run_cmd( google_cloud_project = auth_info.project_id google_cloud_region = auth_info.region - if not type: - type = _prompt_to_choose_type() - _generate_files( agent_folder, google_api_key=google_api_key, google_cloud_project=google_cloud_project, google_cloud_region=google_cloud_region, model=model, - type=type.lower(), + type=resolved_type, ) agents_dir = os.path.dirname(os.path.normpath(agent_name)) click.echo( diff --git a/tests/unittests/cli/utils/test_cli_create.py b/tests/unittests/cli/utils/test_cli_create.py index f1d7ed06b9f..f94e4cfbd0b 100644 --- a/tests/unittests/cli/utils/test_cli_create.py +++ b/tests/unittests/cli/utils/test_cli_create.py @@ -256,6 +256,106 @@ def test_run_cmd_invalid_app_name( ) +@pytest.mark.parametrize( + "invalid_name", + [ + "my-agent", + "class", + ], +) +def test_run_cmd_rejects_non_identifier_or_keyword_agent_name( + invalid_name: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Kebab-case and Python keywords should be rejected before any dialogs.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + model_prompt_called = False + + def _fail_if_prompted(): + nonlocal model_prompt_called + model_prompt_called = True + return "gemini-2.5-flash" + + monkeypatch.setattr(cli_create, "_prompt_for_model", _fail_if_prompted) + + with pytest.raises( + click.BadParameter, match="must be a valid Python identifier" + ): + cli_create.run_cmd( + invalid_name, + model=None, + google_api_key=None, + google_cloud_project=None, + google_cloud_region=None, + type="code", + ) + assert not model_prompt_called + + +@pytest.mark.parametrize( + "valid_name", + [ + "my_agent", + "MyAgent", + "myAgent", + ], +) +def test_run_cmd_accepts_valid_identifier_names( + valid_name: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """snake_case, PascalCase, and camelCase should be accepted.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + cli_create.run_cmd( + valid_name, + model="gemini-2.5-flash", + google_api_key="test-key", + google_cloud_project=None, + google_cloud_region=None, + type="code", + ) + assert (tmp_path / valid_name / "agent.py").exists() + + +def test_run_cmd_allows_kebab_case_for_config_agents( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Config-based agents do not rely on Python imports and allow kebab-case.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + cli_create.run_cmd( + "my-config-agent", + model="gemini-2.5-flash", + google_api_key="test-key", + google_cloud_project=None, + google_cloud_region=None, + type="config", + ) + config_file = tmp_path / "my-config-agent" / "root_agent.yaml" + assert config_file.exists() + content = config_file.read_text(encoding="utf-8") + assert "model: gemini-2.5-flash" in content + + +def test_run_cmd_rejects_kebab_case_when_prompted_as_code( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """If type is not passed, choosing CODE in prompt still validates identifier.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + monkeypatch.setattr(cli_create, "_prompt_to_choose_type", lambda: "CODE") + + with pytest.raises( + click.BadParameter, match="must be a valid Python identifier" + ): + cli_create.run_cmd( + "kebab-agent", + model="gemini-2.5-flash", + google_api_key="test-key", + google_cloud_project=None, + google_cloud_region=None, + type=None, + ) + + def test_run_cmd_with_type_config( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From 33f80ca9f2930d2ee43550421cf6f3599b7f1571 Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 11 Oct 2026 01:42:29 +0200 Subject: [PATCH 2/2] fix(cli): preserve keyword-named agent creation while validating identifiers Remove keyword.iskeyword check in cli_create to allow applications named after Python keywords, which AgentLoader and dynamic imports support. Retain isidentifier check to reject hyphenated kebab-case folder names. --- src/google/adk/cli/cli_create.py | 8 +--- tests/unittests/cli/utils/test_cli_create.py | 41 ++++++++++++++------ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/google/adk/cli/cli_create.py b/src/google/adk/cli/cli_create.py index f6f0f0b8abc..e759df9da62 100644 --- a/src/google/adk/cli/cli_create.py +++ b/src/google/adk/cli/cli_create.py @@ -14,7 +14,6 @@ from __future__ import annotations -import keyword import os from typing import Optional @@ -232,13 +231,10 @@ def run_cmd( type = _prompt_to_choose_type() resolved_type = type.lower() - if resolved_type == "code" and ( - not app_name.isidentifier() or keyword.iskeyword(app_name) - ): + if resolved_type == "code" and not app_name.isidentifier(): raise click.BadParameter( f"Invalid agent name '{app_name}': code-based agents must be a valid" - " Python identifier (e.g. 'my_agent', not 'my-agent') and cannot be a" - " Python keyword." + " Python identifier (e.g. 'my_agent', not 'my-agent')." ) agent_folder = os.path.join(os.getcwd(), agent_name) diff --git a/tests/unittests/cli/utils/test_cli_create.py b/tests/unittests/cli/utils/test_cli_create.py index f94e4cfbd0b..a1588a96d35 100644 --- a/tests/unittests/cli/utils/test_cli_create.py +++ b/tests/unittests/cli/utils/test_cli_create.py @@ -256,17 +256,10 @@ def test_run_cmd_invalid_app_name( ) -@pytest.mark.parametrize( - "invalid_name", - [ - "my-agent", - "class", - ], -) -def test_run_cmd_rejects_non_identifier_or_keyword_agent_name( - invalid_name: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +def test_run_cmd_rejects_non_identifier_agent_name( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Kebab-case and Python keywords should be rejected before any dialogs.""" + """Kebab-case should be rejected before any dialogs.""" monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) model_prompt_called = False @@ -281,7 +274,7 @@ def _fail_if_prompted(): click.BadParameter, match="must be a valid Python identifier" ): cli_create.run_cmd( - invalid_name, + "my-agent", model=None, google_api_key=None, google_cloud_project=None, @@ -297,12 +290,13 @@ def _fail_if_prompted(): "my_agent", "MyAgent", "myAgent", + "class", ], ) def test_run_cmd_accepts_valid_identifier_names( valid_name: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """snake_case, PascalCase, and camelCase should be accepted.""" + """snake_case, PascalCase, camelCase, and keywords should be accepted.""" monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) cli_create.run_cmd( @@ -316,6 +310,29 @@ def test_run_cmd_accepts_valid_identifier_names( assert (tmp_path / valid_name / "agent.py").exists() +def test_run_cmd_and_load_keyword_agent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Creating a keyword-named application succeeds and AgentLoader can load it.""" + from google.adk.agents.llm_agent import LlmAgent + from google.adk.cli.utils.agent_loader import AgentLoader + + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + cli_create.run_cmd( + "class", + model="gemini-2.5-flash", + google_api_key="offline-placeholder", + google_cloud_project=None, + google_cloud_region=None, + type="code", + ) + loader = AgentLoader(str(tmp_path)) + agent = loader.load_agent("class") + assert isinstance(agent, LlmAgent) + assert agent.name == "root_agent" + + def test_run_cmd_allows_kebab_case_for_config_agents( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: