diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 12edb32993d..31d7cca26eb 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -20,6 +20,7 @@ from datetime import datetime import functools import hashlib +import importlib.util import json import logging import os @@ -41,6 +42,7 @@ from ..agents._streaming_mode import StreamingMode from ..features import FeatureName from ..features import override_feature_enabled +from ..utils._dependency import missing_extra from ..utils._telemetry_config import read_telemetry_consent from ..utils._telemetry_config import write_telemetry_consent from ._telemetry._metrics_collector import MetricsCollector @@ -1059,6 +1061,9 @@ def cli_test(ctx, folder: str, rebuild: bool): ) sys.exit(1) + if importlib.util.find_spec("pytest") is None: + raise click.ClickException(str(missing_extra("pytest", "test"))) + click.echo(f"Running tests in {folder} using runner {test_runner_path}...") result = subprocess.run([ diff --git a/src/google/adk/cli/dev_server.py b/src/google/adk/cli/dev_server.py index a3daaf7271e..14bcfa68124 100644 --- a/src/google/adk/cli/dev_server.py +++ b/src/google/adk/cli/dev_server.py @@ -31,6 +31,7 @@ from __future__ import annotations import asyncio +import importlib.util import json import logging import os @@ -72,6 +73,7 @@ from ..evaluation.eval_metrics import MetricInfo from ..evaluation.eval_result import EvalSetResult from ..evaluation.eval_set import EvalSet +from ..utils._dependency import missing_extra from ..utils._telemetry_config import read_telemetry_consent from ..utils._telemetry_config import write_telemetry_consent from .api_server import ApiServer @@ -826,6 +828,10 @@ async def run_pytest_subprocess(): env["ADK_TEST_FOLDER"] = agent_dir try: + if importlib.util.find_spec("pytest") is None: + await queue.put(str(missing_extra("pytest", "test")) + "\n") + return + process = await asyncio.create_subprocess_exec( *cmd_args, stdout=subprocess.PIPE, diff --git a/tests/unittests/cli/test_adk_web_server_tests.py b/tests/unittests/cli/test_adk_web_server_tests.py index 6dc13ed9e91..89f7f7dadf6 100644 --- a/tests/unittests/cli/test_adk_web_server_tests.py +++ b/tests/unittests/cli/test_adk_web_server_tests.py @@ -15,8 +15,10 @@ from __future__ import annotations import asyncio +import importlib.util import json import os +from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch @@ -155,3 +157,35 @@ def test_run_tests(test_client): content = response.content assert b"line1\n" in content assert b"line2\n" in content + + +def test_run_tests_without_pytest_streams_the_test_extra(test_client): + """Without pytest the endpoint streams the extra and spawns no child.""" + real_find_spec = importlib.util.find_spec + + def _fake_find_spec(name: str, *args: Any, **kwargs: Any): + if name == "pytest": + return None + return real_find_spec(name, *args, **kwargs) + + # The fake child exits immediately, so a regression that spawns it fails the + # assertions below instead of blocking the stream. + mock_process = MagicMock() + mock_process.stdout.readline = AsyncMock(return_value=b"") + mock_process.wait = AsyncMock(return_value=0) + + with ( + patch.object(importlib.util, "find_spec", _fake_find_spec), + patch( + "google.adk.cli.dev_server.asyncio.create_subprocess_exec", + new_callable=AsyncMock, + ) as mock_create_subprocess, + ): + mock_create_subprocess.return_value = mock_process + + response = test_client.post("/dev/apps/test_app/tests/run", json={}) + + assert response.status_code == 200 + assert b"pip install google-adk[test]" in response.content + assert b"No module named pytest" not in response.content + mock_create_subprocess.assert_not_called() diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 354962ce1a8..306974217d0 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -18,6 +18,7 @@ import builtins import hashlib +import importlib.util import json import logging import os @@ -2638,6 +2639,27 @@ def test_cli_test_exits_with_the_pytest_return_code( assert result.exit_code == 3 +def test_cli_test_names_the_test_extra_when_pytest_is_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fake_pytest_run +) -> None: + """Without pytest the command names the extra instead of spawning a child.""" + real_find_spec = importlib.util.find_spec + + def _fake_find_spec(name: str, *args: Any, **kwargs: Any): + if name == "pytest": + return None + return real_find_spec(name, *args, **kwargs) + + monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec) + + result = CliRunner().invoke(cli_tools_click.main, ["test", str(tmp_path)]) + + assert result.exit_code == 1 + assert "pip install google-adk[test]" in result.output + assert "No module named pytest" not in result.output + assert fake_pytest_run.runs == [] + + def test_cli_test_rebuild_skips_the_pytest_subprocess( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fake_pytest_run ) -> None: