mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-01 13:18:10 +00:00
fix(cli): preserve remote deploy on git setup warnings
This commit is contained in:
@@ -72,6 +72,7 @@ def create_project_zip(
|
||||
|
||||
|
||||
def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]:
|
||||
"""Return project-relative files to include in the archive."""
|
||||
if repository is not None:
|
||||
files = [Path(path) for path in repository.deployable_files()]
|
||||
return [
|
||||
@@ -88,16 +89,19 @@ def _project_files(root: Path, repository: git.Repository | None = None) -> list
|
||||
|
||||
|
||||
def _walk_files(root: Path) -> list[Path]:
|
||||
"""List regular files below root as project-relative paths."""
|
||||
return [
|
||||
path.relative_to(root) for path in root.rglob("*") if _is_regular_file(path)
|
||||
]
|
||||
|
||||
|
||||
def _is_regular_file(path: Path) -> bool:
|
||||
"""Return True for regular files, excluding symlinks to files."""
|
||||
return path.is_file() and not path.is_symlink()
|
||||
|
||||
|
||||
def _is_excluded(path: Path) -> bool:
|
||||
"""Return True when a file should be omitted from deployment ZIPs."""
|
||||
parts = set(path.parts)
|
||||
if parts.intersection(_EXCLUDED_DIRS):
|
||||
return True
|
||||
@@ -111,6 +115,7 @@ def _is_excluded(path: Path) -> bool:
|
||||
|
||||
|
||||
def _stage_project(root: Path, files: list[Path]) -> Path:
|
||||
"""Copy archive files into a temporary staging directory."""
|
||||
staging_root = Path(tempfile.mkdtemp(prefix="crewai-deploy-"))
|
||||
|
||||
try:
|
||||
@@ -132,6 +137,7 @@ def _stage_project(root: Path, files: list[Path]) -> Path:
|
||||
|
||||
|
||||
def _is_json_crew_project(root: Path) -> bool:
|
||||
"""Return True for JSON crew projects that need a Python deploy wrapper."""
|
||||
if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
|
||||
return False
|
||||
|
||||
@@ -156,6 +162,7 @@ def _is_json_crew_project(root: Path) -> bool:
|
||||
|
||||
|
||||
def _read_pyproject(root: Path) -> dict[str, Any]:
|
||||
"""Read pyproject.toml, returning an empty mapping on missing or invalid data."""
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return {}
|
||||
@@ -166,6 +173,7 @@ def _read_pyproject(root: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _package_name(root: Path) -> str | None:
|
||||
"""Return the normalized Python package name for the project."""
|
||||
project = _read_pyproject(root).get("project")
|
||||
if not isinstance(project, dict):
|
||||
return None
|
||||
@@ -179,6 +187,7 @@ def _package_name(root: Path) -> str | None:
|
||||
|
||||
|
||||
def _class_name(package_name: str) -> str:
|
||||
"""Return the generated wrapper class name for a package."""
|
||||
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
|
||||
class_name = "".join(part[:1].upper() + part[1:] for part in parts)
|
||||
if not class_name:
|
||||
@@ -189,6 +198,7 @@ def _class_name(package_name: str) -> str:
|
||||
|
||||
|
||||
def _add_json_crew_deploy_wrapper(root: Path) -> None:
|
||||
"""Add Python wrapper files required to deploy a JSON crew project."""
|
||||
package_name = _package_name(root)
|
||||
if package_name is None:
|
||||
return
|
||||
@@ -215,6 +225,7 @@ def _add_json_crew_deploy_wrapper(root: Path) -> None:
|
||||
|
||||
|
||||
def _json_crew_py(class_name: str, crew_filename: str) -> str:
|
||||
"""Render the generated crew.py module for a JSON crew."""
|
||||
return f'''from pathlib import Path
|
||||
|
||||
from crewai import Crew
|
||||
@@ -239,6 +250,7 @@ class {class_name}:
|
||||
|
||||
|
||||
def _json_main_py(package_name: str, class_name: str) -> str:
|
||||
"""Render the generated main.py entrypoints for a JSON crew."""
|
||||
return f"""#!/usr/bin/env python
|
||||
import json
|
||||
import sys
|
||||
@@ -293,6 +305,7 @@ def run_with_trigger():
|
||||
|
||||
|
||||
def _ensure_project_scripts(root: Path, package_name: str) -> None:
|
||||
"""Ensure generated wrappers have project script entrypoints."""
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return
|
||||
|
||||
@@ -54,6 +54,7 @@ def _display_git_remote_help() -> None:
|
||||
|
||||
|
||||
def _env_summary(env_vars: dict[str, str]) -> str:
|
||||
"""Return a compact description of environment variables for prompts."""
|
||||
if not env_vars:
|
||||
return "0 env vars"
|
||||
keys = ", ".join(sorted(env_vars))
|
||||
@@ -167,6 +168,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
self._display_deployment_info(response.json())
|
||||
|
||||
def _deployment_uuid_by_name(self) -> str:
|
||||
"""Resolve the current project's deployment UUID by project name."""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to find a deployment")
|
||||
|
||||
@@ -207,8 +209,9 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
self._display_creation_success(response.json())
|
||||
|
||||
def _prepare_git_repository(self) -> git.Repository | None:
|
||||
"""Prepare Git for deploy while preserving remote deploy when possible."""
|
||||
try:
|
||||
repository = git.Repository()
|
||||
repository = git.Repository(fetch=False)
|
||||
except ValueError as exc:
|
||||
if "not a Git repository" not in str(exc):
|
||||
console.print(
|
||||
@@ -230,6 +233,33 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
_display_git_repository_help()
|
||||
return repository
|
||||
|
||||
remote_repo_url = repository.origin_url()
|
||||
if remote_repo_url:
|
||||
try:
|
||||
repository.fetch()
|
||||
except ValueError as fetch_error:
|
||||
console.print(
|
||||
"Could not fetch from origin. Continuing with remote deployment.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print(str(fetch_error), style="dim")
|
||||
|
||||
try:
|
||||
if repository.create_initial_commit_if_needed():
|
||||
console.print(
|
||||
"Created an initial Git commit for this project.",
|
||||
style="green",
|
||||
)
|
||||
except Exception as commit_error:
|
||||
console.print(
|
||||
"Could not create an initial Git commit. "
|
||||
"Continuing with remote deployment.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print(str(commit_error), style="dim")
|
||||
|
||||
return repository
|
||||
|
||||
try:
|
||||
if repository.create_initial_commit_if_needed():
|
||||
console.print(
|
||||
@@ -252,6 +282,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
repository: git.Repository | None,
|
||||
confirm: bool,
|
||||
) -> Any:
|
||||
"""Create a deployment by uploading a project ZIP archive."""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to create a ZIP deployment")
|
||||
|
||||
@@ -273,6 +304,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
uuid: str,
|
||||
repository: git.Repository | None,
|
||||
) -> Any:
|
||||
"""Update an existing deployment by uploading a project ZIP archive."""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to update a ZIP deployment")
|
||||
|
||||
@@ -302,6 +334,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
)
|
||||
|
||||
def _confirm_zip_input(self, env_vars: dict[str, str], confirm: bool) -> None:
|
||||
"""Prompt before ZIP upload unless confirmation was already supplied."""
|
||||
if not confirm:
|
||||
input(f"Press Enter to continue with {_env_summary(env_vars)}")
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ _INITIAL_COMMIT_EXCLUDE_PATTERNS = [
|
||||
".crewai/",
|
||||
".env",
|
||||
".env.*",
|
||||
"!.env.example",
|
||||
"!.env.sample",
|
||||
".mypy_cache/",
|
||||
".pytest_cache/",
|
||||
".ruff_cache/",
|
||||
|
||||
@@ -31,6 +31,7 @@ class PlusAPI(_CorePlusAPI):
|
||||
timeout: float | None = None,
|
||||
verify: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Send an authenticated multipart request containing a project ZIP."""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
headers = dict(cast(dict[str, str], self.headers))
|
||||
headers.pop("Content-Type", None)
|
||||
@@ -57,6 +58,7 @@ class PlusAPI(_CorePlusAPI):
|
||||
name: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if name:
|
||||
data["name"] = name
|
||||
@@ -77,6 +79,7 @@ class PlusAPI(_CorePlusAPI):
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Update an existing crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if env:
|
||||
data.update({f"env[{key}]": value for key, value in env.items()})
|
||||
|
||||
@@ -77,17 +77,20 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
outside_file = tmp_path.parent / f"{tmp_path.name}-secret.txt"
|
||||
outside_file.write_text("secret\n")
|
||||
archive_path: Path | None = None
|
||||
try:
|
||||
(tmp_path / "external-secret.txt").symlink_to(outside_file)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks are not supported in this environment: {exc}")
|
||||
try:
|
||||
(tmp_path / "external-secret.txt").symlink_to(outside_file)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks are not supported in this environment: {exc}")
|
||||
|
||||
archive_path = create_project_zip("demo", project_dir=tmp_path)
|
||||
try:
|
||||
archive_path = create_project_zip("demo", project_dir=tmp_path)
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
if archive_path is not None:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
outside_file.unlink(missing_ok=True)
|
||||
|
||||
assert names == {"pyproject.toml"}
|
||||
|
||||
|
||||
@@ -236,6 +236,63 @@ class TestDeployCommand(unittest.TestCase):
|
||||
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_remote_keeps_remote_path_when_fetch_fails(
|
||||
self, mock_display, mock_repository, mock_create_project_zip
|
||||
):
|
||||
repository = mock_repository.return_value
|
||||
repository.origin_url.return_value = "https://github.com/test/repo.git"
|
||||
repository.fetch.side_effect = ValueError("fetch failed")
|
||||
repository.create_initial_commit_if_needed.return_value = False
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
self.mock_client.deploy_by_name.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.deploy(skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
mock_repository.assert_called_once_with(fetch=False)
|
||||
repository.fetch.assert_called_once_with()
|
||||
self.assertIn("Continuing with remote deployment", output)
|
||||
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
|
||||
self.mock_client.update_crew_from_zip.assert_not_called()
|
||||
mock_create_project_zip.assert_not_called()
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_remote_keeps_remote_path_when_initial_commit_fails(
|
||||
self, mock_display, mock_repository, mock_create_project_zip
|
||||
):
|
||||
repository = mock_repository.return_value
|
||||
repository.origin_url.return_value = "https://github.com/test/repo.git"
|
||||
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
|
||||
"commit failed"
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
self.mock_client.deploy_by_name.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.deploy(skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
mock_repository.assert_called_once_with(fetch=False)
|
||||
repository.fetch.assert_called_once_with()
|
||||
self.assertIn("Continuing with remote deployment", output)
|
||||
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
|
||||
self.mock_client.update_crew_from_zip.assert_not_called()
|
||||
mock_create_project_zip.assert_not_called()
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
|
||||
@@ -141,6 +141,8 @@ def test_initialize_creates_initial_commit(fp, tmp_path):
|
||||
exclude_file = tmp_path / ".git" / "info" / "exclude"
|
||||
exclude_text = exclude_file.read_text()
|
||||
assert ".env" in exclude_text
|
||||
assert "!.env.example" in exclude_text
|
||||
assert "!.env.sample" in exclude_text
|
||||
assert "__pycache__/" in exclude_text
|
||||
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ class PlusAPI:
|
||||
timeout: float | None = None,
|
||||
verify: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Send an authenticated multipart request containing a project ZIP."""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
headers = dict(cast(dict[str, str], self.headers))
|
||||
headers.pop("Content-Type", None)
|
||||
@@ -349,6 +350,7 @@ class PlusAPI:
|
||||
name: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if name:
|
||||
data["name"] = name
|
||||
@@ -369,6 +371,7 @@ class PlusAPI:
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Update an existing crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if env:
|
||||
data.update({f"env[{key}]": value for key, value in env.items()})
|
||||
|
||||
Reference in New Issue
Block a user