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"