From b0db50d5b252f6492e5369dad6c71192b4442c2a Mon Sep 17 00:00:00 2001 From: Yang Song Date: Mon, 26 Oct 2026 10:29:35 -0400 Subject: [PATCH] feat(cli): support Python versions other than 3.11 in adk deploy images The generated Dockerfile hard-coded `FROM python:3.11-slim`, so an agent whose dependencies need a newer interpreter could not be deployed with `adk deploy` at all. Every target now takes `--python_version`: adk deploy cloud_run --python_version=3.12 path/to/my_agent `agent_engine` also reads `python_version` from `.agent_engine_config.json`, the key the Agent Engine SDK already defines. The SDK ignores that key when the image is built from a Dockerfile, which is how ADK deploys, so the value was previously a silent no-op; ADK now renders it into the Dockerfile and drops it from the API request. An explicit flag overrides the file, and the default stays 3.11 when neither is set. Values are validated against a strict `3.[.]` pattern, 3.11 and up, before being interpolated into the Dockerfile, and the flag is checked before any agent source is staged. Fixes #4784 --- src/google/adk/cli/cli_deploy.py | 68 ++++ src/google/adk/cli/cli_tools_click.py | 39 +++ .../adk/cli/deployers/_dockerfile_template.py | 32 +- tests/unittests/cli/utils/test_cli_deploy.py | 305 ++++++++++++++++++ 4 files changed, 443 insertions(+), 1 deletion(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 2a2166c107f..510e651c084 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -41,9 +41,11 @@ from ..version import __version__ from .deployers import DeployerFactory +from .deployers._dockerfile_template import _DEFAULT_PYTHON_VERSION from .deployers._dockerfile_template import _render_dockerfile from .deployers._dockerfile_template import _render_install_agent_deps from .deployers._dockerfile_template import _validate_app_name +from .deployers._dockerfile_template import _validate_python_version from .utils import _onboarding logger = logging.getLogger('google_adk.' + __name__) @@ -254,6 +256,50 @@ def _apply_service_account_to_agent_config( agent_config['service_account'] = _validate_service_account(existing) +def _resolve_python_version_from_agent_config( + agent_config: dict[str, Any], + python_version: Optional[str], +) -> str: + """Resolves the container Python version for an Agent Engine deploy. + + Supports three sources, in increasing precedence: + + 1. The default, ``3.11``. + 2. Top-level ``python_version`` in ``.agent_engine_config.json`` (popped so + it is not forwarded to the Agent Engine API, which ignores it when the + image is built from a Dockerfile). + 3. Explicit ``python_version`` argument (CLI flag), which overrides both. + + Args: + agent_config: The parsed agent platform config; ``python_version`` is + removed from it. + python_version: The CLI value, or None when the flag was omitted. + + Returns: + The validated Python version to render into the Dockerfile. + + Raises: + click.ClickException: If either value is not a valid Python 3.11+ version + string. + """ + config_python_version = agent_config.pop('python_version', None) + if config_python_version is not None: + if not isinstance(config_python_version, str): + raise click.ClickException( + 'python_version in agent platform config must be a string.' + ) + config_python_version = _validate_python_version(config_python_version) + if python_version is not None: + python_version = _validate_python_version(python_version) + if config_python_version and config_python_version != python_version: + click.echo( + 'Overriding python_version in agent platform config with' + f' {python_version}' + ) + return python_version + return config_python_version or _DEFAULT_PYTHON_VERSION + + def _on_rm_error(func: Callable[..., Any], path: str, exc_info: Any) -> None: """Error handler for shutil.rmtree to handle read-only files on Windows.""" os.chmod(path, stat.S_IWRITE) @@ -1143,6 +1189,7 @@ def run( extra_gcloud_args: tuple[str, ...] | None = None, with_cloud_run_sandbox: bool = False, extra_packages: list[str] | None = None, + python_version: str = '3.11', ) -> None: """Deploys an agent to Google Cloud Run. @@ -1184,6 +1231,7 @@ def run( extra_packages: Additional local file or directory paths to stage alongside the agent and make importable in the image. A relative path is resolved against the current working directory. + python_version: The Python version for the container base image. """ _validate_trigger_options( trigger_sources, trigger_oidc_audience, trigger_oidc_service_accounts @@ -1191,6 +1239,7 @@ def run( app_name = _validate_app_name( app_name or os.path.basename(os.path.normpath(agent_folder)) ) + python_version = _validate_python_version(python_version) if parse(adk_version) >= parse('1.3.0') and not use_local_storage: session_service_uri = session_service_uri or 'memory://' artifact_service_uri = artifact_service_uri or 'memory://' @@ -1267,6 +1316,7 @@ def run( staged_extra_packages, temp_folder ), extra_env_vars='', + python_version=python_version, ) dockerfile_path = os.path.join(temp_folder, 'Dockerfile') os.makedirs(temp_folder, exist_ok=True) @@ -1326,6 +1376,7 @@ def to_cloud_run( extra_gcloud_args: tuple[str, ...] | None = None, with_cloud_run_sandbox: bool = False, extra_packages: list[str] | None = None, + python_version: str = '3.11', ) -> None: """Deploys an agent to Google Cloud Run (deprecated).""" warnings.warn( @@ -1362,6 +1413,7 @@ def to_cloud_run( extra_gcloud_args=extra_gcloud_args, with_cloud_run_sandbox=with_cloud_run_sandbox, extra_packages=extra_packages, + python_version=python_version, ) @@ -1422,6 +1474,7 @@ def to_agent_engine( extra_packages: Optional[list[str]] = None, worker_pool: Optional[str] = None, service_account: Optional[str] = None, + python_version: Optional[str] = None, ) -> None: """Deploys an agent to Gemini Enterprise Agent Platform. @@ -1500,12 +1553,19 @@ def to_agent_engine( ``GOOGLE_CLOUD_SERVICE_ACCOUNT`` in the ``.env`` file and ``service_account`` in ``.agent_engine_config.json`` when both are present. When omitted, Agent Engine uses its default service agent. + python_version (str): Optional. The Python version for the container base + image. Overrides ``python_version`` in ``.agent_engine_config.json`` + when both are present. When neither is set, ``3.11`` is used. """ _validate_trigger_options( trigger_sources, trigger_oidc_audience, trigger_oidc_service_accounts ) app_name = os.path.basename(os.path.normpath(agent_folder)) _validate_app_name(app_name) + # Fail on a bad flag value before the temp folder is replaced and the agent + # source copied; the config-file value is resolved once the file is read. + if python_version is not None: + python_version = _validate_python_version(python_version) display_name = display_name or app_name parent_folder = os.path.dirname(os.path.normpath(agent_folder)) if adk_app_object: @@ -1602,6 +1662,9 @@ def to_agent_engine( agent_config['description'] = description _apply_worker_pool_to_agent_config(agent_config, worker_pool) + python_version = _resolve_python_version_from_agent_config( + agent_config, python_version + ) config_extra_packages = agent_config.pop('extra_packages', None) or [] # CLI entries resolve against the invocation dir; config-file entries @@ -1868,6 +1931,7 @@ def create_dockerfile_for_agent_engine(resource_name: str) -> None: staged_extra_packages, temp_folder_path ), extra_env_vars=extra_env_vars, + python_version=python_version, ) with open('Dockerfile', 'w', encoding='utf-8') as f: f.write(dockerfile_content) @@ -1946,6 +2010,7 @@ def to_gke( 'ClusterIP', 'NodePort', 'LoadBalancer' ] = 'ClusterIP', extra_packages: Optional[list[str]] = None, + python_version: str = '3.11', ) -> None: """Deploys an agent to Google Kubernetes Engine(GKE). @@ -1977,6 +2042,7 @@ def to_gke( extra_packages: Additional local file or directory paths to stage alongside the agent and make importable in the image. A relative path is resolved against the current working directory. + python_version: The Python version for the container base image. """ _validate_trigger_options( trigger_sources, trigger_oidc_audience, trigger_oidc_service_accounts @@ -1994,6 +2060,7 @@ def to_gke( app_name = app_name or os.path.basename(os.path.normpath(agent_folder)) _validate_app_name(app_name) + python_version = _validate_python_version(python_version) if parse(adk_version) >= parse('1.3.0') and not use_local_storage: session_service_uri = session_service_uri or 'memory://' artifact_service_uri = artifact_service_uri or 'memory://' @@ -2070,6 +2137,7 @@ def to_gke( staged_extra_packages, temp_folder ), extra_env_vars='', + python_version=python_version, ) dockerfile_path = os.path.join(temp_folder, 'Dockerfile') os.makedirs(temp_folder, exist_ok=True) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 165f8cb400a..198dc6903ba 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -588,6 +588,16 @@ def deploy_options(command: _F) -> _F: " version in the dev environment)" ), ), + click.option( + "--python_version", + type=str, + default="3.11", + show_default=True, + help=( + "Optional. The Python version used in the container image" + " (default: 3.11)." + ), + ), click.argument( "agent", type=click.Path( @@ -2703,6 +2713,7 @@ def cli_deploy_cloud_run( provider_args: tuple[str, ...] = (), env: tuple[str, ...] = (), extra_packages: tuple[str, ...] = (), + python_version: str = "3.11", ) -> None: """Deploys an agent to Cloud Run. @@ -2754,6 +2765,7 @@ def cli_deploy_cloud_run( extra_gcloud_args=tuple(gcloud_args), with_cloud_run_sandbox=with_cloud_run_sandbox, extra_packages=list(extra_packages), + python_version=python_version, ) except (click.ClickException, click.Abort): raise @@ -2785,6 +2797,7 @@ def cli_deploy_docker( a2a: bool = False, provider_args: tuple[str, ...] = (), env: tuple[str, ...] = (), + python_version: str = "3.11", ) -> None: """Deploys an agent to a local Docker container.""" _warn_if_with_ui(with_ui) @@ -2813,6 +2826,7 @@ def cli_deploy_docker( trigger_sources=None, provider_args=provider_args, env=env, + python_version=python_version, ) except (click.ClickException, click.Abort): raise @@ -3078,6 +3092,17 @@ def cli_migrate_session( " the version in the dev environment)" ), ) +@click.option( + "--python_version", + type=str, + default=None, + help=( + "Optional. The Python version used in the container image. Overrides" + " `python_version` in `.agent_engine_config.json` when both are set." + " (default: `python_version` from `.agent_engine_config.json`, or" + " 3.11)" + ), +) @click.option( "--extra_packages", multiple=True, @@ -3155,6 +3180,7 @@ def cli_deploy_agent_engine( trigger_oidc_audience: str | None = None, trigger_oidc_service_accounts: str | None = None, service_account: str | None = None, + python_version: str | None = None, ) -> None: """Deploys an agent to Agent Engine. @@ -3220,6 +3246,7 @@ def cli_deploy_agent_engine( extra_packages=list(extra_packages), worker_pool=worker_pool, service_account=service_account, + python_version=python_version, ) except (click.ClickException, click.Abort): raise @@ -3339,6 +3366,16 @@ def cli_deploy_agent_engine( " version in the dev environment)" ), ) +@click.option( + "--python_version", + type=str, + default="3.11", + show_default=True, + help=( + "Optional. The Python version used in the container image" + " (default: 3.11)." + ), +) # Kept as raw str (not parsed to list) — interpolated directly into Dockerfile CMD. @click.option( "--trigger_sources", @@ -3413,6 +3450,7 @@ def cli_deploy_gke( trigger_oidc_audience: str | None = None, trigger_oidc_service_accounts: str | None = None, extra_packages: tuple[str, ...] = (), + python_version: str = "3.11", ) -> None: """Deploys an agent to GKE. @@ -3452,6 +3490,7 @@ def cli_deploy_gke( trigger_oidc_audience=trigger_oidc_audience, trigger_oidc_service_accounts=trigger_oidc_service_accounts, extra_packages=list(extra_packages), + python_version=python_version, ) except (click.ClickException, click.Abort): raise diff --git a/src/google/adk/cli/deployers/_dockerfile_template.py b/src/google/adk/cli/deployers/_dockerfile_template.py index ed261a82b6e..c0a7cd1a32e 100644 --- a/src/google/adk/cli/deployers/_dockerfile_template.py +++ b/src/google/adk/cli/deployers/_dockerfile_template.py @@ -31,6 +31,12 @@ r'^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,62}$' ) +_DEFAULT_PYTHON_VERSION: Final[str] = '3.11' + +_PYTHON_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile( + r'^3\.(1[1-9]|[2-9][0-9])(\.[0-9]+)?$' +) + def _validate_app_name(app_name: str) -> str: """Validates the deploy app name before it is written into a Dockerfile. @@ -55,6 +61,27 @@ def _validate_app_name(app_name: str) -> str: return app_name +def _validate_python_version(python_version: str) -> str: + """Validates the Python version before it is written into a Dockerfile. + + Args: + python_version: The Python version (e.g. '3.11', '3.12', '3.12.1'). + + Returns: + The validated Python version, unchanged. + + Raises: + click.ClickException: If the Python version is not a valid 3.11+ version + string. + """ + if not _PYTHON_VERSION_PATTERN.fullmatch(python_version): + raise click.ClickException( + f'Invalid python_version {python_version!r}. Expected a Python 3.11+' + ' version string such as "3.11", "3.12", or "3.12.1".' + ) + return python_version + + def _validate_dockerfile_literal(name: str, value: Any) -> str: """Validates a value interpolated into a single-line Dockerfile instruction. @@ -98,7 +125,7 @@ def _to_exec_form(instruction: str, args: list[str]) -> str: _DOCKERFILE_TEMPLATE: Final[str] = """ -FROM python:3.11-slim +FROM python:{python_version}-slim WORKDIR /app # Create a non-root user @@ -191,6 +218,7 @@ def _render_dockerfile( express_mode_option: str = '', extra_packages_copy: str = '', extra_env_vars: str = '', + python_version: str = _DEFAULT_PYTHON_VERSION, ) -> str: """Renders the Dockerfile used by every `adk deploy` target. @@ -222,6 +250,7 @@ def _render_dockerfile( express_mode_option: `--express_mode` or an empty string. extra_packages_copy: Extra `COPY`/`ENV` instructions, or an empty string. extra_env_vars: Extra `ENV` lines, or an empty string. + python_version: The Python version for the base image (default: '3.11'). Returns: The Dockerfile contents. @@ -263,6 +292,7 @@ def _render_dockerfile( ) return _DOCKERFILE_TEMPLATE.format( + python_version=_validate_python_version(python_version), app_name=app_name, port=port, install_adk=install_adk, diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index 07a2b46850f..54ee536c843 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -3053,3 +3053,308 @@ def test_cli_deploy_agent_engine_passes_service_account( mock_to_agent_engine.assert_called_once() _, kwargs = mock_to_agent_engine.call_args assert kwargs["service_account"] == _VALID_SERVICE_ACCOUNT + + +@pytest.mark.parametrize( + "python_version,expected_from", + [ + ("3.11", "FROM python:3.11-slim"), + ("3.12", "FROM python:3.12-slim"), + ("3.13", "FROM python:3.13-slim"), + ("3.12.1", "FROM python:3.12.1-slim"), + ], +) +def test_render_dockerfile_with_python_version( + python_version: str, expected_from: str +) -> None: + """_render_dockerfile renders FROM python:-slim for valid versions.""" + rendered = cli_deploy._render_dockerfile( + app_name="my_agent", + port=8080, + command="api_server", + install_agent_deps="# No requirements.txt found.", + service_options=[], + trace_to_cloud_option="", + otel_to_cloud_option="", + allow_origins_option="", + adk_version="1.2.0", + host_option="--host=0.0.0.0", + a2a_option="", + trigger_sources_option="", + python_version=python_version, + ) + assert expected_from in rendered + + +@pytest.mark.parametrize( + "bad_version", + [ + "3.12\nRUN echo pwned", + "3.12\r\nRUN echo pwned", + "3.10", + "2.7", + "", + "3.12-alpine", + "3.12; echo pwned", + ], +) +def test_render_dockerfile_rejects_invalid_python_version( + bad_version: str, +) -> None: + """_render_dockerfile raises ClickException on invalid python_version.""" + with pytest.raises(click.ClickException): + cli_deploy._render_dockerfile( + app_name="my_agent", + port=8080, + command="api_server", + install_agent_deps="# No requirements.txt found.", + service_options=[], + trace_to_cloud_option="", + otel_to_cloud_option="", + allow_origins_option="", + adk_version="1.2.0", + host_option="--host=0.0.0.0", + a2a_option="", + trigger_sources_option="", + python_version=bad_version, + ) + + +def test_to_agent_engine_renders_python_version_and_pops_config_key( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], +) -> None: + """to_agent_engine supports python_version from .agent_engine_config.json.""" + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + captured: List[Dict[str, Any]] = [] + recording_agentplatform = _make_recording_agentplatform(captured) + monkeypatch.setitem(sys.modules, "agentplatform", recording_agentplatform) + monkeypatch.setattr( + "src.google.adk.dependencies._agentplatform.agentplatform", + recording_agentplatform, + ) + src_dir = agent_dir(False, False) + tmp_dir = src_dir.parent / "tmp" + (src_dir / ".agent_engine_config.json").write_text( + json.dumps({"python_version": "3.12"}) + ) + + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder="tmp", + project="my-gcp-project", + region="us-central1", + adk_version="1.2.0", + ) + + dockerfile_content = (tmp_dir / "Dockerfile").read_text() + assert "FROM python:3.12-slim" in dockerfile_content + assert "python_version" not in captured[0] + + +@pytest.mark.parametrize("cli_python_version", ["3.11", "3.13"]) +def test_to_agent_engine_cli_python_version_overrides_config_file( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + cli_python_version: str, +) -> None: + """An explicit python_version wins over .agent_engine_config.json. + + Covers both the value that equals the built-in default (3.11) and a + different one, so the override cannot depend on a default sentinel. + """ + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + captured: List[Dict[str, Any]] = [] + recording_agentplatform = _make_recording_agentplatform(captured) + monkeypatch.setitem(sys.modules, "agentplatform", recording_agentplatform) + monkeypatch.setattr( + "src.google.adk.dependencies._agentplatform.agentplatform", + recording_agentplatform, + ) + src_dir = agent_dir(False, False) + tmp_dir = src_dir.parent / "tmp" + (src_dir / ".agent_engine_config.json").write_text( + json.dumps({"python_version": "3.12"}) + ) + + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder="tmp", + project="my-gcp-project", + region="us-central1", + adk_version="1.2.0", + python_version=cli_python_version, + ) + + dockerfile_content = (tmp_dir / "Dockerfile").read_text() + assert f"FROM python:{cli_python_version}-slim" in dockerfile_content + + +def test_to_agent_engine_defaults_python_version_without_flag_or_config( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], +) -> None: + """With neither the flag nor a config key, the image is built on 3.11.""" + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + captured: List[Dict[str, Any]] = [] + recording_agentplatform = _make_recording_agentplatform(captured) + monkeypatch.setitem(sys.modules, "agentplatform", recording_agentplatform) + monkeypatch.setattr( + "src.google.adk.dependencies._agentplatform.agentplatform", + recording_agentplatform, + ) + src_dir = agent_dir(False, False) + tmp_dir = src_dir.parent / "tmp" + + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder="tmp", + project="my-gcp-project", + region="us-central1", + adk_version="1.2.0", + ) + + dockerfile_content = (tmp_dir / "Dockerfile").read_text() + assert "FROM python:3.11-slim" in dockerfile_content + + +def test_to_agent_engine_rejects_non_string_python_version_in_config( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], +) -> None: + """A numeric python_version in .agent_engine_config.json is rejected.""" + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + src_dir = agent_dir(False, False) + (src_dir / ".agent_engine_config.json").write_text( + json.dumps({"python_version": 3.12}) + ) + + with pytest.raises(click.ClickException, match="must be a string"): + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder="tmp", + project="my-gcp-project", + region="us-central1", + adk_version="1.2.0", + ) + + +def test_to_agent_engine_reports_python_version_override( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], +) -> None: + """Overriding the config file's python_version prints a notice naming the winner.""" + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + captured: List[Dict[str, Any]] = [] + recording_agentplatform = _make_recording_agentplatform(captured) + monkeypatch.setitem(sys.modules, "agentplatform", recording_agentplatform) + monkeypatch.setattr( + "src.google.adk.dependencies._agentplatform.agentplatform", + recording_agentplatform, + ) + src_dir = agent_dir(False, False) + (src_dir / ".agent_engine_config.json").write_text( + json.dumps({"python_version": "3.12"}) + ) + + with mock.patch("click.echo") as mocked_echo: + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder="tmp", + project="my-gcp-project", + region="us-central1", + adk_version="1.2.0", + python_version="3.13", + ) + + messages = [str(c[0][0]) for c in mocked_echo.call_args_list if c[0]] + override_messages = [m for m in messages if "Overriding python_version" in m] + assert len(override_messages) == 1 + assert "3.13" in override_messages[0] + + +def test_to_agent_engine_rejects_invalid_python_version_before_copying( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], +) -> None: + """An invalid python_version fails before any agent source is staged.""" + copytree = _Recorder() + monkeypatch.setattr(shutil, "copytree", copytree) + src_dir = agent_dir(False, False) + + with pytest.raises(click.ClickException, match="Invalid python_version"): + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder="tmp", + project="my-gcp-project", + region="us-central1", + adk_version="1.2.0", + python_version="3.10", + ) + + assert not copytree.calls + assert not (src_dir.parent / "tmp").exists() + + +def test_to_gke_renders_python_version( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, +) -> None: + """to_gke renders FROM python:3.12-slim when python_version='3.12'.""" + src_dir = agent_dir(False, False) + temp_dir = tmp_path / "gke_temp" + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **k: types.SimpleNamespace(stdout="deployment created"), + ) + monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: None) + + cli_deploy.to_gke( + agent_folder=str(src_dir), + temp_folder=str(temp_dir), + project="my-project", + region="us-central1", + cluster_name="my-cluster", + service_name="my-service", + app_name="my-app", + port=8000, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + adk_version="1.2.0", + log_level="INFO", + python_version="3.12", + ) + + dockerfile_content = (temp_dir / "Dockerfile").read_text() + assert "FROM python:3.12-slim" in dockerfile_content + + +@pytest.mark.parametrize( + "target,deploy_fn", + [ + ("cloud_run", "run"), + ("docker", "run"), + ("agent_engine", "to_agent_engine"), + ("gke", "to_gke"), + ], +) +def test_cli_deploy_passes_python_version( + tmp_path: Path, target: str, deploy_fn: str +) -> None: + """--python_version on each deploy target reaches its deploy function.""" + agent_dir = tmp_path / "my_agent" + agent_dir.mkdir() + runner = CliRunner() + + with mock.patch(f"src.google.adk.cli.cli_deploy.{deploy_fn}") as mock_deploy: + result = runner.invoke( + cli_tools_click.main, + ["deploy", target, "--python_version=3.12", str(agent_dir)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert mock_deploy.call_args.kwargs["python_version"] == "3.12"