From 89e07b63249f3c40f4094c4e13118b9ecad50e4c Mon Sep 17 00:00:00 2001 From: Tyler Gillam Date: Mon, 26 Jan 2026 14:32:49 -0600 Subject: [PATCH] Add support for more Python versions and package managers --- .../cli/agent/deployment/deploy_service.py | 15 + .../deployment/python_environment_detector.py | 269 ++++++++ .../cli/agent/deployment/utils/zip_utils.py | 25 +- .../cli/agent/deployment/validation.py | 84 ++- gradient_adk/digital_ocean_api/models.py | 54 ++ .../deploy/test_adk_agents_deploy.py | 583 ++++++++++++++++++ .../python_environment_detector_test.py | 431 +++++++++++++ tests/cli/agent/deployment/validation_test.py | 10 +- 8 files changed, 1442 insertions(+), 29 deletions(-) create mode 100644 gradient_adk/cli/agent/deployment/python_environment_detector.py create mode 100644 tests/cli/agent/deployment/python_environment_detector_test.py diff --git a/gradient_adk/cli/agent/deployment/deploy_service.py b/gradient_adk/cli/agent/deployment/deploy_service.py index dec40b0..70d29a5 100644 --- a/gradient_adk/cli/agent/deployment/deploy_service.py +++ b/gradient_adk/cli/agent/deployment/deploy_service.py @@ -16,12 +16,17 @@ GetAgentWorkspaceDeploymentOutput, GetAgentWorkspaceOutput, PresignedUrlFile, + PythonEnvironmentConfig, ReleaseStatus, ) from gradient_adk.digital_ocean_api.errors import DOAPIClientError from .utils.zip_utils import ZipCreator, DirectoryZipCreator from .utils.s3_utils import S3Uploader, HttpxS3Uploader +from .python_environment_detector import ( + PythonEnvironmentDetector, + PythonEnvironmentDetectionError, +) logger = get_logger(__name__) @@ -115,6 +120,10 @@ async def deploy_agent( if not self.quiet: print("Starting agent deployment...") + # Detect Python environment configuration + env_detector = PythonEnvironmentDetector() + python_env_config = env_detector.detect(source_dir) + #: Check if workspace and deployment exist workspace_exists, deployment_exists = await self._check_existing_resources( agent_workspace_name, agent_deployment_name @@ -139,6 +148,7 @@ async def deploy_agent( code_artifact=code_artifact, project_id=project_id, description=description, + python_environment_config=python_env_config, ) # Poll for deployment completion @@ -319,6 +329,7 @@ async def _create_or_update_deployment( code_artifact: AgentDeploymentCodeArtifact, project_id: str, description: str | None = None, + python_environment_config: PythonEnvironmentConfig | None = None, ) -> str: """Create or update the deployment based on what exists. @@ -330,6 +341,7 @@ async def _create_or_update_deployment( code_artifact: Code artifact metadata project_id: Project ID description: Optional description for the deployment + python_environment_config: Optional Python environment configuration Returns: UUID of the created release @@ -344,6 +356,7 @@ async def _create_or_update_deployment( project_id=project_id, library_version=_get_adk_version(), description=description, + python_environment_config=python_environment_config, ) workspace_output = await self.client.create_agent_workspace(workspace_input) @@ -371,6 +384,7 @@ async def _create_or_update_deployment( agent_deployment_code_artifact=code_artifact, library_version=_get_adk_version(), description=description, + python_environment_config=python_environment_config, ) deployment_output = await self.client.create_agent_workspace_deployment( deployment_input @@ -389,6 +403,7 @@ async def _create_or_update_deployment( agent_deployment_name=agent_deployment_name, agent_deployment_code_artifact=code_artifact, library_version=_get_adk_version(), + python_environment_config=python_environment_config, ) release_output = await self.client.create_agent_deployment_release( release_input diff --git a/gradient_adk/cli/agent/deployment/python_environment_detector.py b/gradient_adk/cli/agent/deployment/python_environment_detector.py new file mode 100644 index 0000000..5020ad0 --- /dev/null +++ b/gradient_adk/cli/agent/deployment/python_environment_detector.py @@ -0,0 +1,269 @@ +"""Python environment detection for agent deployments.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Optional, Tuple + +from gradient_adk.logging import get_logger +from gradient_adk.digital_ocean_api.models import ( + PythonDependencyFile, + PythonEnvironmentConfig, + PythonPackageManager, + PythonVersion, +) + +logger = get_logger(__name__) + +# Supported Python versions mapping +SUPPORTED_PYTHON_VERSIONS = { + (3, 10): PythonVersion.PYTHON_VERSION_3_10, + (3, 11): PythonVersion.PYTHON_VERSION_3_11, + (3, 12): PythonVersion.PYTHON_VERSION_3_12, + (3, 13): PythonVersion.PYTHON_VERSION_3_13, + (3, 14): PythonVersion.PYTHON_VERSION_3_14, +} + + +class PythonEnvironmentDetectionError(Exception): + """Raised when Python environment detection fails.""" + + pass + + +class PythonEnvironmentDetector: + """Detects Python environment configuration from a source directory.""" + + def detect(self, source_dir: Path) -> PythonEnvironmentConfig: + """Detect Python environment configuration from the source directory. + + Args: + source_dir: The source directory to analyze + + Returns: + PythonEnvironmentConfig with detected settings + + Raises: + PythonEnvironmentDetectionError: If detection fails due to missing + dependency files or unsupported Python version + """ + dependency_file = self._detect_dependency_file(source_dir) + python_version = self._detect_python_version(source_dir) + package_manager = self._detect_package_manager(source_dir) + + return PythonEnvironmentConfig( + python_version=python_version, + package_manager=package_manager, + dependency_file=dependency_file, + ) + + def _detect_dependency_file(self, source_dir: Path) -> PythonDependencyFile: + """Detect the dependency file type. + + Args: + source_dir: The source directory to analyze + + Returns: + PythonDependencyFile enum value + + Raises: + PythonEnvironmentDetectionError: If no dependency file is found + """ + requirements_txt = source_dir / "requirements.txt" + pyproject_toml = source_dir / "pyproject.toml" + + has_requirements = requirements_txt.exists() + has_pyproject = pyproject_toml.exists() + + if has_requirements and has_pyproject: + logger.warning( + "Both requirements.txt and pyproject.toml found. " + "Using requirements.txt as the dependency file." + ) + return PythonDependencyFile.PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT + + if has_requirements: + logger.debug("Detected dependency file: requirements.txt") + return PythonDependencyFile.PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT + + if has_pyproject: + logger.debug("Detected dependency file: pyproject.toml") + return PythonDependencyFile.PYTHON_DEPENDENCY_FILE_PYPROJECT_TOML + + raise PythonEnvironmentDetectionError( + "No dependency file found. Please create either requirements.txt or pyproject.toml " + "in your project directory." + ) + + def _detect_python_version(self, source_dir: Path) -> PythonVersion: + """Detect the Python version. + + Priority: + 1. .python-version file + 2. pyproject.toml requires-python + 3. Current runtime Python version + + Args: + source_dir: The source directory to analyze + + Returns: + PythonVersion enum value + + Raises: + PythonEnvironmentDetectionError: If Python version is not supported + """ + # Try .python-version file first + version = self._parse_python_version_file(source_dir) + if version: + return self._validate_and_return_version(version, ".python-version file") + + # Try pyproject.toml requires-python + version = self._parse_pyproject_python_version(source_dir) + if version: + return self._validate_and_return_version(version, "pyproject.toml") + + # Fall back to current runtime + version = (sys.version_info.major, sys.version_info.minor) + return self._validate_and_return_version(version, "current runtime") + + def _parse_python_version_file( + self, source_dir: Path + ) -> Optional[Tuple[int, int]]: + """Parse Python version from .python-version file. + + Args: + source_dir: The source directory to analyze + + Returns: + Tuple of (major, minor) version or None if not found + """ + python_version_file = source_dir / ".python-version" + if not python_version_file.exists(): + return None + + try: + content = python_version_file.read_text().strip() + # Handle formats like "3.12", "3.12.1", "python-3.12" + match = re.search(r"(\d+)\.(\d+)", content) + if match: + return (int(match.group(1)), int(match.group(2))) + except Exception as e: + logger.debug(f"Failed to parse .python-version file: {e}") + + return None + + def _parse_pyproject_python_version( + self, source_dir: Path + ) -> Optional[Tuple[int, int]]: + """Parse Python version from pyproject.toml requires-python. + + Args: + source_dir: The source directory to analyze + + Returns: + Tuple of (major, minor) version or None if not found + """ + pyproject_toml = source_dir / "pyproject.toml" + if not pyproject_toml.exists(): + return None + + try: + content = pyproject_toml.read_text() + + # Look for requires-python in various formats + # e.g., requires-python = ">=3.12" or requires-python = "^3.12" + match = re.search( + r'requires-python\s*=\s*["\']([^"\']+)["\']', content + ) + if match: + version_spec = match.group(1) + # Extract the version number from specs like ">=3.12", "^3.12", "~=3.12", "==3.12" + version_match = re.search(r"(\d+)\.(\d+)", version_spec) + if version_match: + return (int(version_match.group(1)), int(version_match.group(2))) + + # Also check for python_requires in [project] section (PEP 621) + match = re.search( + r'python_requires\s*=\s*["\']([^"\']+)["\']', content + ) + if match: + version_spec = match.group(1) + version_match = re.search(r"(\d+)\.(\d+)", version_spec) + if version_match: + return (int(version_match.group(1)), int(version_match.group(2))) + + except Exception as e: + logger.debug(f"Failed to parse pyproject.toml for Python version: {e}") + + return None + + def _validate_and_return_version( + self, version: Tuple[int, int], source: str + ) -> PythonVersion: + """Validate Python version and return the enum value. + + Args: + version: Tuple of (major, minor) version + source: Description of where the version was detected from + + Returns: + PythonVersion enum value + + Raises: + PythonEnvironmentDetectionError: If version is not supported + """ + if version in SUPPORTED_PYTHON_VERSIONS: + logger.debug( + f"Detected Python version {version[0]}.{version[1]} from {source}" + ) + return SUPPORTED_PYTHON_VERSIONS[version] + + supported_versions = ", ".join( + f"{v[0]}.{v[1]}" for v in sorted(SUPPORTED_PYTHON_VERSIONS.keys()) + ) + raise PythonEnvironmentDetectionError( + f"Python version {version[0]}.{version[1]} is not supported. " + f"Supported versions: {supported_versions}" + ) + + def _detect_package_manager(self, source_dir: Path) -> PythonPackageManager: + """Detect the package manager to use. + + Priority: + 1. uv.lock file present -> UV + 2. pyproject.toml with [tool.uv] section -> UV + 3. Default to PIP + + Args: + source_dir: The source directory to analyze + + Returns: + PythonPackageManager enum value + """ + # Check for uv.lock file + uv_lock = source_dir / "uv.lock" + if uv_lock.exists(): + logger.debug("Detected package manager: uv (uv.lock file found)") + return PythonPackageManager.PYTHON_PACKAGE_MANAGER_UV + + # Check for [tool.uv] in pyproject.toml + pyproject_toml = source_dir / "pyproject.toml" + if pyproject_toml.exists(): + try: + content = pyproject_toml.read_text() + if "[tool.uv]" in content: + logger.debug( + "Detected package manager: uv ([tool.uv] section found)" + ) + return PythonPackageManager.PYTHON_PACKAGE_MANAGER_UV + except Exception as e: + logger.debug(f"Failed to read pyproject.toml for UV detection: {e}") + + # Default to pip + logger.warning( + "Could not determine package manager, defaulting to pip. " + "To use uv, add a uv.lock file or [tool.uv] section to pyproject.toml." + ) + return PythonPackageManager.PYTHON_PACKAGE_MANAGER_PIP \ No newline at end of file diff --git a/gradient_adk/cli/agent/deployment/utils/zip_utils.py b/gradient_adk/cli/agent/deployment/utils/zip_utils.py index dcbccca..5eb2748 100644 --- a/gradient_adk/cli/agent/deployment/utils/zip_utils.py +++ b/gradient_adk/cli/agent/deployment/utils/zip_utils.py @@ -28,10 +28,33 @@ def __init__(self, exclude_patterns: list[str] | None = None): exclude_patterns: List of patterns to exclude (e.g., ['*.zip', 'env/', '__pycache__/']) """ self.exclude_patterns = exclude_patterns or [ + # Archive files "*.zip", + # Virtual environments "env/", + "venv/", + ".venv/", + # Python cache "__pycache__/", + "*.pyc", + # Package build artifacts + "*.egg-info/", + "dist/", + "build/", + # Version control ".git/", + # UV/package manager artifacts + ".uv/", + # IDE/Editor files + ".idea/", + ".vscode/", + # Node.js (in case of mixed projects) + "node_modules/", + # Test/coverage artifacts + ".pytest_cache/", + ".mypy_cache/", + "htmlcov/", + ".coverage", ] def create_zip(self, source_dir: Path, output_path: Path) -> Path: @@ -131,4 +154,4 @@ def _should_exclude(self, file_path: Path, source_dir: Path) -> bool: if pattern == path_str or pattern in relative_path.parts: return True - return False + return False \ No newline at end of file diff --git a/gradient_adk/cli/agent/deployment/validation.py b/gradient_adk/cli/agent/deployment/validation.py index 65e9457..a6a5789 100644 --- a/gradient_adk/cli/agent/deployment/validation.py +++ b/gradient_adk/cli/agent/deployment/validation.py @@ -50,14 +50,16 @@ def validate_agent_entrypoint( f"Expected at: {entrypoint_path}" ) - # Check if requirements.txt exists + # Check if a dependency file exists (requirements.txt or pyproject.toml) requirements_path = source_dir / "requirements.txt" - if not requirements_path.exists(): + pyproject_path = source_dir / "pyproject.toml" + if not requirements_path.exists() and not pyproject_path.exists(): raise ValidationError( - f"No requirements.txt found in {source_dir}\n" - f"A requirements.txt file is required for deployment.\n\n" + f"No requirements.txt or pyproject.toml found in {source_dir}\n" + f"A dependency file is required for deployment.\n\n" f"Create a requirements.txt with at minimum:\n" - f" gradient-adk\n" + f" gradient-adk\n\n" + f"Or create a pyproject.toml with gradient-adk in dependencies." ) # Check for config file @@ -108,22 +110,43 @@ def validate_agent_entrypoint( pip_path = venv_path / "bin" / "pip" python_path = venv_path / "bin" / "python" - # Install requirements - if verbose: - print(f"📦 Installing dependencies from requirements.txt...") + # Install dependencies + # Prefer requirements.txt, fall back to pyproject.toml + temp_requirements = temp_dir / "requirements.txt" + temp_pyproject = temp_dir / "pyproject.toml" - result = subprocess.run( - [str(pip_path), "install", "-r", "requirements.txt"], - cwd=temp_dir, - capture_output=True, - text=True, - timeout=300, # 5 minutes for dependency installation - ) + if temp_requirements.exists(): + if verbose: + print(f"📦 Installing dependencies from requirements.txt...") + result = subprocess.run( + [str(pip_path), "install", "-r", "requirements.txt"], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=300, # 5 minutes for dependency installation + ) + dep_file = "requirements.txt" + elif temp_pyproject.exists(): + if verbose: + print(f"📦 Installing dependencies from pyproject.toml...") + result = subprocess.run( + [str(pip_path), "install", "."], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=300, # 5 minutes for dependency installation + ) + dep_file = "pyproject.toml" + else: + raise ValidationError( + "No dependency file found in validation environment.\n" + "This should not happen - please report this bug." + ) if result.returncode != 0: raise ValidationError( f"Failed to install dependencies:\n{result.stderr}\n\n" - f"Fix your requirements.txt and try again." + f"Fix your {dep_file} and try again." ) if verbose: @@ -231,20 +254,35 @@ def validate_agent_entrypoint( def _copy_source_files(source_dir: Path, dest_dir: Path, verbose: bool = False) -> None: """Copy source files to destination, excluding common patterns.""" - # Common exclusions + # Common exclusions - kept in sync with zip_utils.py exclude_patterns = { + # Archive files + "*.zip", + # Virtual environments + "env", + "venv", + ".venv", + # Python cache "__pycache__", "*.pyc", + # Package build artifacts + "*.egg-info", + "dist", + "build", + # Version control ".git", - ".venv", - "venv", - "env", + # UV/package manager artifacts + ".uv", + # IDE/Editor files + ".idea", + ".vscode", + # Node.js (in case of mixed projects) "node_modules", + # Test/coverage artifacts ".pytest_cache", ".mypy_cache", - "*.egg-info", - "dist", - "build", + "htmlcov", + ".coverage", } def should_exclude(path: Path) -> bool: diff --git a/gradient_adk/digital_ocean_api/models.py b/gradient_adk/digital_ocean_api/models.py index 180d87f..ca29111 100644 --- a/gradient_adk/digital_ocean_api/models.py +++ b/gradient_adk/digital_ocean_api/models.py @@ -449,6 +449,9 @@ class CreateAgentWorkspaceDeploymentInput(BaseModel): description="Description of the agent deployment (max 1000 characters)", max_length=1000, ) + python_environment_config: Optional["PythonEnvironmentConfig"] = Field( + None, description="Optional Python environment configuration" + ) class CreateAgentWorkspaceDeploymentOutput(BaseModel): @@ -478,6 +481,9 @@ class CreateAgentDeploymentReleaseInput(BaseModel): library_version: Optional[str] = Field( None, description="Version of the ADK library used to create this release" ) + python_environment_config: Optional["PythonEnvironmentConfig"] = Field( + None, description="Optional Python environment configuration" + ) class CreateAgentDeploymentReleaseOutput(BaseModel): @@ -535,6 +541,9 @@ class CreateAgentWorkspaceInput(BaseModel): description="Description of the agent workspace deployment (max 1000 characters)", max_length=1000, ) + python_environment_config: Optional["PythonEnvironmentConfig"] = Field( + None, description="Optional Python environment configuration" + ) class CreateAgentWorkspaceOutput(BaseModel): @@ -1028,4 +1037,49 @@ class DeleteAgentWorkspaceOutput(BaseModel): agent_workspace_name: str = Field( ..., description="The name of the deleted agent workspace" + ) + + +class PythonVersion(str, Enum): + """Python version for agent deployments.""" + + PYTHON_VERSION_UNKNOWN = "PYTHON_VERSION_UNKNOWN" + PYTHON_VERSION_3_10 = "PYTHON_VERSION_3_10" + PYTHON_VERSION_3_11 = "PYTHON_VERSION_3_11" + PYTHON_VERSION_3_12 = "PYTHON_VERSION_3_12" + PYTHON_VERSION_3_13 = "PYTHON_VERSION_3_13" + PYTHON_VERSION_3_14 = "PYTHON_VERSION_3_14" + + +class PythonPackageManager(str, Enum): + """Package manager for Python dependencies.""" + + PYTHON_PACKAGE_MANAGER_UNKNOWN = "PYTHON_PACKAGE_MANAGER_UNKNOWN" + PYTHON_PACKAGE_MANAGER_PIP = "PYTHON_PACKAGE_MANAGER_PIP" + PYTHON_PACKAGE_MANAGER_UV = "PYTHON_PACKAGE_MANAGER_UV" + + +class PythonDependencyFile(str, Enum): + """Dependency file type for Python projects.""" + + PYTHON_DEPENDENCY_FILE_UNKNOWN = "PYTHON_DEPENDENCY_FILE_UNKNOWN" + PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT = "PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT" + PYTHON_DEPENDENCY_FILE_PYPROJECT_TOML = "PYTHON_DEPENDENCY_FILE_PYPROJECT_TOML" + + +class PythonEnvironmentConfig(BaseModel): + """ + Python environment configuration for agent deployments. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + python_version: PythonVersion = Field( + ..., description="The Python version to use" + ) + package_manager: PythonPackageManager = Field( + ..., description="The package manager to use for installing dependencies" + ) + dependency_file: PythonDependencyFile = Field( + ..., description="The dependency file type" ) \ No newline at end of file diff --git a/integration_tests/deploy/test_adk_agents_deploy.py b/integration_tests/deploy/test_adk_agents_deploy.py index 87af258..12703c5 100644 --- a/integration_tests/deploy/test_adk_agents_deploy.py +++ b/integration_tests/deploy/test_adk_agents_deploy.py @@ -711,6 +711,348 @@ def test_deploy_json_output_missing_api_token(self, setup_valid_agent): pytest.fail(f"stderr should be valid JSON, got: {result.stderr}") +class TestADKAgentsDeployPythonEnvironment: + """Tests for Python environment detection during deploy.""" + + @pytest.fixture + def echo_agent_dir(self): + """Get the path to the echo agent directory.""" + return Path(__file__).parent.parent / "example_agents" / "echo_agent" + + @pytest.fixture + def setup_valid_agent(self, echo_agent_dir): + """ + Setup a temporary directory with a valid agent structure. + Yields the temp directory path and cleans up after. + """ + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": "test-echo-agent", + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Create requirements.txt + requirements_path = temp_path / "requirements.txt" + requirements_path.write_text("gradient-adk\n") + + yield temp_path + + @pytest.mark.cli + def test_deploy_missing_dependency_file(self, echo_agent_dir): + """ + Test that deploy fails when neither requirements.txt nor pyproject.toml exists. + """ + logger = logging.getLogger(__name__) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": "test-echo-agent", + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Don't create requirements.txt or pyproject.toml + + logger.info(f"Testing deploy without dependency file in {temp_dir}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--skip-validation"], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + assert result.returncode != 0, "Deploy should have failed without dependency file" + + combined_output = result.stdout + result.stderr + assert any( + term in combined_output.lower() + for term in ["no dependency file", "requirements.txt", "pyproject.toml"] + ), f"Expected error about missing dependency file, got: {combined_output}" + + logger.info("Correctly failed without dependency file") + + @pytest.mark.cli + def test_deploy_unsupported_python_version(self, setup_valid_agent): + """ + Test that deploy fails when Python version is not supported (3.10-3.14). + """ + logger = logging.getLogger(__name__) + temp_path = setup_valid_agent + + # Create .python-version with unsupported version + (temp_path / ".python-version").write_text("3.9\n") + + logger.info(f"Testing deploy with unsupported Python version in {temp_path}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--skip-validation"], + cwd=temp_path, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + assert result.returncode != 0, "Deploy should have failed with unsupported Python version" + + combined_output = result.stdout + result.stderr + assert any( + term in combined_output.lower() + for term in ["not supported", "3.9", "supported versions"] + ), f"Expected error about unsupported Python version, got: {combined_output}" + + logger.info("Correctly failed with unsupported Python version") + + @pytest.mark.cli + def test_deploy_both_dependency_files_warning(self, setup_valid_agent, caplog): + """ + Test that deploy warns when both requirements.txt and pyproject.toml exist. + This test just verifies the warning appears, not full deployment success. + """ + logger = logging.getLogger(__name__) + temp_path = setup_valid_agent + + # Both requirements.txt (already exists from fixture) and pyproject.toml + (temp_path / "pyproject.toml").write_text('[project]\nname = "test"\n') + + logger.info(f"Testing deploy with both dependency files in {temp_path}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--skip-validation", "--verbose"], + cwd=temp_path, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + combined_output = result.stdout + result.stderr + # Should show warning about both files (may fail at API call, but warning should appear) + assert "both requirements.txt and pyproject.toml" in combined_output.lower() or \ + "using requirements.txt" in combined_output.lower(), \ + f"Expected warning about both dependency files, got: {combined_output}" + + logger.info("Correctly warned about both dependency files") + + @pytest.mark.cli + def test_deploy_with_pyproject_toml_only(self, echo_agent_dir): + """ + Test that deploy works with only pyproject.toml (no requirements.txt). + This test verifies the environment detection works, not full deployment. + """ + logger = logging.getLogger(__name__) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": "test-echo-agent", + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Create pyproject.toml instead of requirements.txt + pyproject_content = '''[project] +name = "test-agent" +requires-python = ">=3.12" +dependencies = ["gradient-adk"] +''' + (temp_path / "pyproject.toml").write_text(pyproject_content) + + logger.info(f"Testing deploy with pyproject.toml only in {temp_dir}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--skip-validation", "--verbose"], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + combined_output = result.stdout + result.stderr + # Should not fail with "no dependency file" error + assert "no dependency file found" not in combined_output.lower(), \ + f"Should have detected pyproject.toml, got: {combined_output}" + + logger.info("Correctly detected pyproject.toml as dependency file") + + @pytest.mark.cli + def test_deploy_with_uv_lock(self, setup_valid_agent): + """ + Test that deploy detects UV package manager from uv.lock file. + """ + logger = logging.getLogger(__name__) + temp_path = setup_valid_agent + + # Create uv.lock file + (temp_path / "uv.lock").write_text("# uv lock file\n") + + logger.info(f"Testing deploy with uv.lock in {temp_path}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--skip-validation", "--verbose"], + cwd=temp_path, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + combined_output = result.stdout + result.stderr + # Should detect UV (debug message or no pip warning) + # The test just verifies it doesn't crash and processes uv.lock + assert "no dependency file found" not in combined_output.lower(), \ + f"Should have processed with uv.lock present, got: {combined_output}" + + logger.info("Correctly processed with uv.lock file") + + @pytest.mark.cli + def test_deploy_with_tool_uv_config(self, echo_agent_dir): + """ + Test that deploy detects UV package manager from [tool.uv] in pyproject.toml. + """ + logger = logging.getLogger(__name__) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": "test-echo-agent", + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Create pyproject.toml with [tool.uv] section + pyproject_content = '''[project] +name = "test-agent" +requires-python = ">=3.12" +dependencies = ["gradient-adk"] + +[tool.uv] +dev-dependencies = [] +''' + (temp_path / "pyproject.toml").write_text(pyproject_content) + + logger.info(f"Testing deploy with [tool.uv] in pyproject.toml in {temp_dir}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--skip-validation", "--verbose"], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + combined_output = result.stdout + result.stderr + # Should detect UV from [tool.uv] + assert "no dependency file found" not in combined_output.lower(), \ + f"Should have processed with [tool.uv] config, got: {combined_output}" + + logger.info("Correctly processed with [tool.uv] config") + + @pytest.mark.cli + def test_deploy_json_output_python_env_error(self, echo_agent_dir): + """ + Test that deploy with --output json returns valid JSON for Python env errors. + """ + import json + logger = logging.getLogger(__name__) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": "test-echo-agent", + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Don't create any dependency file + + logger.info(f"Testing deploy --output json without dependency file in {temp_dir}") + + result = subprocess.run( + ["gradient", "agent", "deploy", "--output", "json", "--skip-validation"], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "DIGITALOCEAN_API_TOKEN": "test-token"}, + ) + + assert result.returncode != 0, "Deploy should have failed" + + # stderr should contain valid JSON error + try: + parsed = json.loads(result.stderr) + assert parsed["status"] == "error", f"JSON should have status: error, got: {parsed}" + assert "dependency" in parsed["error"].lower() or "requirements" in parsed["error"].lower(), \ + f"Error should mention dependency file, got: {parsed['error']}" + logger.info("JSON error output is valid for missing dependency file") + except json.JSONDecodeError: + pytest.fail(f"stderr should be valid JSON, got: {result.stderr}") + + class TestADKAgentsDeployE2E: """End-to-end tests for successful agent deployment.""" @@ -1019,6 +1361,247 @@ def test_deploy_json_can_pipe_to_jq(self, echo_agent_dir): f"invoke_url should contain agents.do-ai.run, got: {invoke_url}" logger.info(f"Successfully extracted invoke_url via jq: {invoke_url}") + finally: + # Cleanup the deployed agent workspace + asyncio.run(self._cleanup_agent_workspace(api_token, agent_name)) + + @pytest.mark.cli + @pytest.mark.e2e + def test_deploy_with_pyproject_toml_e2e(self, echo_agent_dir): + """ + Test successful agent deployment with pyproject.toml instead of requirements.txt. + + Requires: + - DIGITALOCEAN_API_TOKEN or TEST_DIGITALOCEAN_API_TOKEN env var + + Note: This test will deploy an agent named 'e2e-test-pyproject-{timestamp}' + to avoid conflicts with other tests. + """ + import asyncio + import time + logger = logging.getLogger(__name__) + + # Get API token + api_token = os.getenv("DIGITALOCEAN_API_TOKEN") or os.getenv("TEST_DIGITALOCEAN_API_TOKEN") + + if not api_token: + pytest.skip("DIGITALOCEAN_API_TOKEN or TEST_DIGITALOCEAN_API_TOKEN required for this test") + + # Use a unique agent name to avoid conflicts + timestamp = int(time.time()) + agent_name = f"e2e-test-pyproject-{timestamp}" + + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": agent_name, + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Create pyproject.toml instead of requirements.txt + pyproject_content = '''[project] +name = "e2e-test-agent" +requires-python = ">=3.12" +dependencies = ["gradient-adk"] +''' + (temp_path / "pyproject.toml").write_text(pyproject_content) + + logger.info(f"Testing deploy with pyproject.toml for agent {agent_name}") + + result = subprocess.run( + ["gradient", "agent", "deploy"], + cwd=temp_path, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for deployment + env={**os.environ, "DIGITALOCEAN_API_TOKEN": api_token}, + ) + + combined_output = result.stdout + result.stderr + + assert result.returncode == 0, f"Deploy should have succeeded with pyproject.toml. Output: {combined_output}" + + # Check for success indicators in output + assert "deployed successfully" in combined_output.lower() or \ + "agent deployed" in combined_output.lower(), \ + f"Expected success message in output, got: {combined_output}" + + logger.info(f"Successfully deployed agent {agent_name} with pyproject.toml") + finally: + # Cleanup the deployed agent workspace + asyncio.run(self._cleanup_agent_workspace(api_token, agent_name)) + + @pytest.mark.cli + @pytest.mark.e2e + def test_deploy_with_uv_environment_e2e(self, echo_agent_dir): + """ + Test successful agent deployment with UV package manager configuration. + + Requires: + - DIGITALOCEAN_API_TOKEN or TEST_DIGITALOCEAN_API_TOKEN env var + + Note: This test will deploy an agent named 'e2e-test-uv-{timestamp}' + to avoid conflicts with other tests. + """ + import asyncio + import time + logger = logging.getLogger(__name__) + + # Get API token + api_token = os.getenv("DIGITALOCEAN_API_TOKEN") or os.getenv("TEST_DIGITALOCEAN_API_TOKEN") + + if not api_token: + pytest.skip("DIGITALOCEAN_API_TOKEN or TEST_DIGITALOCEAN_API_TOKEN required for this test") + + # Use a unique agent name to avoid conflicts + timestamp = int(time.time()) + agent_name = f"e2e-test-uv-{timestamp}" + + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": agent_name, + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Create pyproject.toml with [tool.uv] section + pyproject_content = '''[project] +name = "e2e-test-uv-agent" +requires-python = ">=3.12" +dependencies = ["gradient-adk"] + +[tool.uv] +dev-dependencies = [] +''' + (temp_path / "pyproject.toml").write_text(pyproject_content) + + # Create uv.lock file to indicate UV package manager + (temp_path / "uv.lock").write_text("# uv lock file placeholder\n") + + logger.info(f"Testing deploy with UV environment for agent {agent_name}") + + result = subprocess.run( + ["gradient", "agent", "deploy"], + cwd=temp_path, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for deployment + env={**os.environ, "DIGITALOCEAN_API_TOKEN": api_token}, + ) + + combined_output = result.stdout + result.stderr + + assert result.returncode == 0, f"Deploy should have succeeded with UV config. Output: {combined_output}" + + # Check for success indicators in output + assert "deployed successfully" in combined_output.lower() or \ + "agent deployed" in combined_output.lower(), \ + f"Expected success message in output, got: {combined_output}" + + logger.info(f"Successfully deployed agent {agent_name} with UV environment") + finally: + # Cleanup the deployed agent workspace + asyncio.run(self._cleanup_agent_workspace(api_token, agent_name)) + + @pytest.mark.cli + @pytest.mark.e2e + def test_deploy_with_specific_python_version_e2e(self, echo_agent_dir): + """ + Test successful agent deployment with specific Python version in .python-version file. + + Requires: + - DIGITALOCEAN_API_TOKEN or TEST_DIGITALOCEAN_API_TOKEN env var + + Note: This test will deploy an agent named 'e2e-test-pyver-{timestamp}' + to avoid conflicts with other tests. + """ + import asyncio + import time + logger = logging.getLogger(__name__) + + # Get API token + api_token = os.getenv("DIGITALOCEAN_API_TOKEN") or os.getenv("TEST_DIGITALOCEAN_API_TOKEN") + + if not api_token: + pytest.skip("DIGITALOCEAN_API_TOKEN or TEST_DIGITALOCEAN_API_TOKEN required for this test") + + # Use a unique agent name to avoid conflicts + timestamp = int(time.time()) + agent_name = f"e2e-test-pyver-{timestamp}" + + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy the echo agent main.py + shutil.copy(echo_agent_dir / "main.py", temp_path / "main.py") + + # Create .gradient directory and config + gradient_dir = temp_path / ".gradient" + gradient_dir.mkdir() + + config = { + "agent_name": agent_name, + "agent_environment": "main", + "entrypoint_file": "main.py", + } + + with open(gradient_dir / "agent.yml", "w") as f: + yaml.safe_dump(config, f) + + # Create requirements.txt + (temp_path / "requirements.txt").write_text("gradient-adk\n") + + # Create .python-version file with specific version + (temp_path / ".python-version").write_text("3.12\n") + + logger.info(f"Testing deploy with .python-version for agent {agent_name}") + + result = subprocess.run( + ["gradient", "agent", "deploy"], + cwd=temp_path, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for deployment + env={**os.environ, "DIGITALOCEAN_API_TOKEN": api_token}, + ) + + combined_output = result.stdout + result.stderr + + assert result.returncode == 0, f"Deploy should have succeeded with .python-version. Output: {combined_output}" + + # Check for success indicators in output + assert "deployed successfully" in combined_output.lower() or \ + "agent deployed" in combined_output.lower(), \ + f"Expected success message in output, got: {combined_output}" + + logger.info(f"Successfully deployed agent {agent_name} with specific Python version") finally: # Cleanup the deployed agent workspace asyncio.run(self._cleanup_agent_workspace(api_token, agent_name)) \ No newline at end of file diff --git a/tests/cli/agent/deployment/python_environment_detector_test.py b/tests/cli/agent/deployment/python_environment_detector_test.py new file mode 100644 index 0000000..ad043c5 --- /dev/null +++ b/tests/cli/agent/deployment/python_environment_detector_test.py @@ -0,0 +1,431 @@ +"""Tests for Python environment detection.""" + +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +import pytest + +from gradient_adk.cli.agent.deployment.python_environment_detector import ( + PythonEnvironmentDetector, + PythonEnvironmentDetectionError, + SUPPORTED_PYTHON_VERSIONS, +) +from gradient_adk.digital_ocean_api.models import ( + PythonDependencyFile, + PythonPackageManager, + PythonVersion, +) + + +@pytest.fixture +def temp_dir(tmp_path: Path) -> Path: + """Create a temporary directory for test files.""" + return tmp_path + + +@pytest.fixture +def detector() -> PythonEnvironmentDetector: + """Create a PythonEnvironmentDetector instance.""" + return PythonEnvironmentDetector() + + +class TestDependencyFileDetection: + """Tests for dependency file detection.""" + + def test_detect_requirements_txt_only( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection when only requirements.txt exists.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + result = detector._detect_dependency_file(temp_dir) + + assert result == PythonDependencyFile.PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT + + def test_detect_pyproject_toml_only( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection when only pyproject.toml exists.""" + (temp_dir / "pyproject.toml").write_text('[project]\nname = "test"\n') + + result = detector._detect_dependency_file(temp_dir) + + assert result == PythonDependencyFile.PYTHON_DEPENDENCY_FILE_PYPROJECT_TOML + + def test_detect_both_files_warns_and_uses_requirements( + self, temp_dir: Path, detector: PythonEnvironmentDetector, caplog + ): + """Test that both files existing warns and uses requirements.txt.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + (temp_dir / "pyproject.toml").write_text('[project]\nname = "test"\n') + + with caplog.at_level("WARNING"): + result = detector._detect_dependency_file(temp_dir) + + assert result == PythonDependencyFile.PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT + assert "Both requirements.txt and pyproject.toml found" in caplog.text + + def test_detect_no_dependency_file_raises_error( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that missing dependency files raises an error.""" + with pytest.raises(PythonEnvironmentDetectionError) as exc_info: + detector._detect_dependency_file(temp_dir) + + assert "No dependency file found" in str(exc_info.value) + assert "requirements.txt" in str(exc_info.value) + assert "pyproject.toml" in str(exc_info.value) + + +class TestPythonVersionDetection: + """Tests for Python version detection.""" + + def test_detect_from_python_version_file( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from .python-version file.""" + (temp_dir / ".python-version").write_text("3.12\n") + # Need a dependency file for full detection + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_12 + + def test_detect_from_python_version_file_with_patch( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from .python-version file with patch version.""" + (temp_dir / ".python-version").write_text("3.11.5\n") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_11 + + def test_detect_from_python_version_file_with_prefix( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from .python-version file with python- prefix.""" + (temp_dir / ".python-version").write_text("python-3.13\n") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_13 + + def test_detect_from_pyproject_requires_python( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from pyproject.toml requires-python.""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\nrequires-python = ">=3.10"\n' + ) + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_10 + + def test_detect_from_pyproject_requires_python_caret( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from pyproject.toml with caret version.""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\nrequires-python = "^3.11"\n' + ) + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_11 + + def test_detect_from_pyproject_requires_python_tilde( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from pyproject.toml with tilde version.""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\nrequires-python = "~=3.12"\n' + ) + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_12 + + def test_detect_from_pyproject_exact_version( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection from pyproject.toml with exact version.""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\nrequires-python = "==3.13"\n' + ) + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_13 + + def test_detect_fallback_to_runtime( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test fallback to current runtime Python version.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + # Mock sys.version_info with a SimpleNamespace to preserve attribute access + mock_version_info = SimpleNamespace(major=3, minor=12) + with patch.object(sys, "version_info", mock_version_info): + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_12 + + def test_python_version_file_takes_precedence( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that .python-version takes precedence over pyproject.toml.""" + (temp_dir / ".python-version").write_text("3.10\n") + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\nrequires-python = ">=3.13"\n' + ) + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_10 + + def test_unsupported_python_version_raises_error( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that unsupported Python version raises an error.""" + (temp_dir / ".python-version").write_text("3.9\n") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + with pytest.raises(PythonEnvironmentDetectionError) as exc_info: + detector._detect_python_version(temp_dir) + + assert "3.9 is not supported" in str(exc_info.value) + assert "3.10" in str(exc_info.value) + + def test_unsupported_python_version_too_old( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that Python 2.x raises an error.""" + (temp_dir / ".python-version").write_text("2.7\n") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + with pytest.raises(PythonEnvironmentDetectionError) as exc_info: + detector._detect_python_version(temp_dir) + + assert "2.7 is not supported" in str(exc_info.value) + + def test_unsupported_runtime_version_raises_error( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that unsupported runtime version raises an error.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + # Mock sys.version_info with a SimpleNamespace to preserve attribute access + mock_version_info = SimpleNamespace(major=3, minor=9) + with patch.object(sys, "version_info", mock_version_info): + with pytest.raises(PythonEnvironmentDetectionError) as exc_info: + detector._detect_python_version(temp_dir) + + assert "3.9 is not supported" in str(exc_info.value) + + +class TestPackageManagerDetection: + """Tests for package manager detection.""" + + def test_detect_uv_from_lock_file( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test UV detection from uv.lock file.""" + (temp_dir / "uv.lock").write_text("# uv lock file\n") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + result = detector._detect_package_manager(temp_dir) + + assert result == PythonPackageManager.PYTHON_PACKAGE_MANAGER_UV + + def test_detect_uv_from_tool_uv_section( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test UV detection from [tool.uv] in pyproject.toml.""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\n\n[tool.uv]\ndev-dependencies = []\n' + ) + + result = detector._detect_package_manager(temp_dir) + + assert result == PythonPackageManager.PYTHON_PACKAGE_MANAGER_UV + + def test_detect_default_pip( + self, temp_dir: Path, detector: PythonEnvironmentDetector, caplog + ): + """Test default to pip when no UV indicators.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + with caplog.at_level("WARNING"): + result = detector._detect_package_manager(temp_dir) + + assert result == PythonPackageManager.PYTHON_PACKAGE_MANAGER_PIP + assert "defaulting to pip" in caplog.text + + def test_detect_pip_with_pyproject_no_uv( + self, temp_dir: Path, detector: PythonEnvironmentDetector, caplog + ): + """Test pip detection with pyproject.toml but no [tool.uv].""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\n\n[tool.ruff]\nline-length = 88\n' + ) + + with caplog.at_level("WARNING"): + result = detector._detect_package_manager(temp_dir) + + assert result == PythonPackageManager.PYTHON_PACKAGE_MANAGER_PIP + assert "defaulting to pip" in caplog.text + + def test_uv_lock_takes_precedence( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that uv.lock takes precedence over [tool.uv] check.""" + (temp_dir / "uv.lock").write_text("# uv lock file\n") + (temp_dir / "pyproject.toml").write_text('[project]\nname = "test"\n') + + result = detector._detect_package_manager(temp_dir) + + assert result == PythonPackageManager.PYTHON_PACKAGE_MANAGER_UV + + +class TestFullDetection: + """Tests for full environment detection.""" + + def test_full_detection_requirements_txt( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test full detection with requirements.txt.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + (temp_dir / ".python-version").write_text("3.12\n") + + result = detector.detect(temp_dir) + + assert result.dependency_file == PythonDependencyFile.PYTHON_DEPENDENCY_FILE_REQUIREMENTS_TXT + assert result.python_version == PythonVersion.PYTHON_VERSION_3_12 + assert result.package_manager == PythonPackageManager.PYTHON_PACKAGE_MANAGER_PIP + + def test_full_detection_pyproject_with_uv( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test full detection with pyproject.toml and UV.""" + (temp_dir / "pyproject.toml").write_text( + '[project]\nname = "test"\nrequires-python = ">=3.11"\n\n[tool.uv]\n' + ) + + result = detector.detect(temp_dir) + + assert result.dependency_file == PythonDependencyFile.PYTHON_DEPENDENCY_FILE_PYPROJECT_TOML + assert result.python_version == PythonVersion.PYTHON_VERSION_3_11 + assert result.package_manager == PythonPackageManager.PYTHON_PACKAGE_MANAGER_UV + + def test_full_detection_with_all_supported_versions( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test detection works for all supported Python versions.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + for (major, minor), expected_version in SUPPORTED_PYTHON_VERSIONS.items(): + (temp_dir / ".python-version").write_text(f"{major}.{minor}\n") + + result = detector.detect(temp_dir) + + assert result.python_version == expected_version + + def test_full_detection_error_no_dependency_file( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that full detection fails without dependency file.""" + with pytest.raises(PythonEnvironmentDetectionError) as exc_info: + detector.detect(temp_dir) + + assert "No dependency file found" in str(exc_info.value) + + def test_full_detection_error_unsupported_version( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test that full detection fails with unsupported Python version.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + (temp_dir / ".python-version").write_text("3.8\n") + + with pytest.raises(PythonEnvironmentDetectionError) as exc_info: + detector.detect(temp_dir) + + assert "3.8 is not supported" in str(exc_info.value) + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_empty_python_version_file( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test handling of empty .python-version file.""" + (temp_dir / ".python-version").write_text("") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + # Should fall back to runtime version + mock_version_info = SimpleNamespace(major=3, minor=12) + with patch.object(sys, "version_info", mock_version_info): + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_12 + + def test_malformed_python_version_file( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test handling of malformed .python-version file.""" + (temp_dir / ".python-version").write_text("invalid version string\n") + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + # Should fall back to runtime version + mock_version_info = SimpleNamespace(major=3, minor=11) + with patch.object(sys, "version_info", mock_version_info): + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_11 + + def test_pyproject_without_requires_python( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test handling of pyproject.toml without requires-python.""" + (temp_dir / "pyproject.toml").write_text('[project]\nname = "test"\n') + + # Should fall back to runtime version + mock_version_info = SimpleNamespace(major=3, minor=13) + with patch.object(sys, "version_info", mock_version_info): + result = detector._detect_python_version(temp_dir) + + assert result == PythonVersion.PYTHON_VERSION_3_13 + + def test_unreadable_file_handling( + self, temp_dir: Path, detector: PythonEnvironmentDetector + ): + """Test handling of unreadable files gracefully.""" + (temp_dir / "requirements.txt").write_text("gradient-adk\n") + + # Create a .python-version file and make it unreadable + python_version_file = temp_dir / ".python-version" + python_version_file.write_text("3.12\n") + + # Patch read_text to simulate read error + original_read = Path.read_text + + def mock_read_text(self): + if self.name == ".python-version": + raise PermissionError("Permission denied") + return original_read(self) + + mock_version_info = SimpleNamespace(major=3, minor=11) + with patch.object(Path, "read_text", mock_read_text): + with patch.object(sys, "version_info", mock_version_info): + result = detector._detect_python_version(temp_dir) + + # Should fall back to runtime + assert result == PythonVersion.PYTHON_VERSION_3_11 \ No newline at end of file diff --git a/tests/cli/agent/deployment/validation_test.py b/tests/cli/agent/deployment/validation_test.py index 61848e9..7dfa631 100644 --- a/tests/cli/agent/deployment/validation_test.py +++ b/tests/cli/agent/deployment/validation_test.py @@ -37,9 +37,9 @@ def test_validation_fails_when_entrypoint_missing(temp_agent_dir): assert "Entrypoint file not found" in str(exc_info.value) -def test_validation_fails_when_requirements_missing(temp_agent_dir): - """Test that validation fails when requirements.txt is missing.""" - # Create minimal structure without requirements.txt +def test_validation_fails_when_dependency_file_missing(temp_agent_dir): + """Test that validation fails when no dependency file exists.""" + # Create minimal structure without requirements.txt or pyproject.toml (temp_agent_dir / ".gradient").mkdir() (temp_agent_dir / ".gradient" / "agent.yml").write_text("workspace_name: test\n") (temp_agent_dir / "main.py").write_text("# placeholder\n") @@ -47,7 +47,7 @@ def test_validation_fails_when_requirements_missing(temp_agent_dir): with pytest.raises(ValidationError) as exc_info: validate_agent_entrypoint(temp_agent_dir, "main.py", verbose=False) - assert "No requirements.txt found" in str(exc_info.value) + assert "No requirements.txt or pyproject.toml found" in str(exc_info.value) def test_validation_fails_when_config_missing(temp_agent_dir): @@ -247,4 +247,4 @@ async def my_agent(data, context): ) # Should install six and validate successfully - validate_agent_entrypoint(temp_agent_dir, "main.py", verbose=False) + validate_agent_entrypoint(temp_agent_dir, "main.py", verbose=False) \ No newline at end of file