Files
crewAI/lib/cli/tests/deploy/test_archive.py
Vinicius Brasil 596150188b Require explicit CrewAI project definitions (#6358)
* Require explicit CrewAI project definitions

JSON crews and declarative flows now resolve from `[tool.crewai]`
metadata instead of implicit filename discovery. This makes project type
selection deterministic, prevents stray `crew.json(c)` files from changing
CLI behavior, and centralizes definition path validation for run, install,
deploy validation, plotting, and memory reset paths.

`[tool.crewai].definition` must be a project-local file path. Absolute
paths, `~`, missing files, directories, and paths escaping the project root
are rejected so deploy and runtime commands use the same contract.

Breaking changes and migration paths:

* JSON crew projects are no longer discovered from `crew.json` or
  `crew.jsonc` alone. Add explicit metadata:

  ```toml
  [tool.crewai]
  type = "crew"
  definition = "crew.jsonc"
  ```

* Declarative flow projects must use a valid project-local definition path:

  ```toml
  [tool.crewai]
  type = "flow"
  definition = "flows/research.yaml"
  ```

* `Flow.from_definition(definition)` is removed. Use:

  ```python
  Flow.from_declaration(contents=definition)
  ```

* `FlowDefinition.to_json()` and `FlowDefinition.to_yaml()` are removed.
  Use `FlowDefinition.to_dict()` and serialize with the caller's JSON or
  YAML library.

* `FlowDefinition.from_dict()` is removed. Use:

  ```python
  FlowDefinition.from_declaration(contents=data)
  ```

* `FlowDefinition.json_schema()` is removed. Use Pydantic's schema API only
  where schema generation is intentionally needed:

  ```python
  FlowDefinition.model_json_schema(by_alias=True)
  ```

* `crewai_cli.run_crew.find_crew_json_file()` and `_has_json_crew()` are
  removed. Use `configured_project_json_crew()` or the shared
  `crewai_core.project.configured_project_definition("crew")` helper.

* `crewai reset-memories` now only loads JSON crews declared through
  `[tool.crewai].definition`, and invalid declared JSON crew definitions
  fail instead of silently falling back to classic crew discovery.

* Address code review comments
2026-06-26 12:07:03 -07:00

308 lines
9.1 KiB
Python

from pathlib import Path
import subprocess
import zipfile
import pytest
from crewai_cli.deploy.archive import create_project_zip
def test_create_project_zip_excludes_local_artifacts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n")
(tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n")
(tmp_path / "__pycache__").mkdir()
(tmp_path / "__pycache__" / "main.pyc").write_bytes(b"compiled")
(tmp_path / ".git").mkdir()
(tmp_path / ".git" / "config").write_text("[core]\n")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"pyproject.toml",
"uv.lock",
"src/main.py",
".env.example",
}
def test_create_project_zip_uses_repository_file_list(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "ignored.txt").write_text("ignored\n")
class RepositoryStub:
def deployable_files(self) -> list[str]:
return ["pyproject.toml", "uv.lock"]
archive_path = create_project_zip(
"demo",
project_dir=tmp_path,
repository=RepositoryStub(), # type: ignore[arg-type]
)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {"pyproject.toml", "uv.lock"}
def test_create_project_zip_without_repository_uses_git_ignore_rules(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / ".gitignore").write_text("node_modules/\nsecret.txt\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "package.json").write_text("{}\n")
(tmp_path / "secret.txt").write_text("secret\n")
try:
subprocess.run(
["git", "init"],
cwd=tmp_path,
capture_output=True,
check=True,
text=True,
)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
pytest.skip(f"git is not available in this environment: {exc}")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
".gitignore",
"pyproject.toml",
"src/main.py",
}
def test_create_project_zip_does_not_fallback_when_repository_listing_fails(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
class RepositoryStub:
def deployable_files(self) -> list[str]:
raise RuntimeError("git listing failed")
with pytest.raises(RuntimeError, match="git listing failed"):
create_project_zip(
"demo",
project_dir=tmp_path,
repository=RepositoryStub(), # type: ignore[arg-type]
)
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:
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)
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
if archive_path is not None:
archive_path.unlink(missing_ok=True)
outside_file.unlink(missing_ok=True)
assert names == {"pyproject.toml"}
def test_create_project_zip_preserves_json_project_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]>=1.15"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "researcher.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert "uv.lock" not in names
assert "crew.jsonc" in names
assert "agents/researcher.jsonc" in names
assert all(not name.startswith("src/") for name in names)
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]>=1.15.0,<2.0.0"]
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "foo.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"agents/foo.jsonc",
"crew.jsonc",
"pyproject.toml",
"uv.lock",
}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
[project.scripts]
json_crew = "old.module:run"
run_crew = "old.module:run"
custom = "custom.module:main"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert 'json_crew = "old.module:run"' in pyproject
assert 'run_crew = "old.module:run"' in pyproject
assert 'custom = "custom.module:main"' in pyproject
assert pyproject.count("[project.scripts]") == 1
assert "[tool.crewai]" in pyproject
@pytest.mark.parametrize(
"tool_config",
[
'tool = "invalid"\n',
'[tool]\ncrewai = "invalid"\n',
],
)
def test_create_project_zip_preserves_json_project_with_malformed_tool_config(
tmp_path: Path, tool_config: str
):
(tmp_path / "pyproject.toml").write_text(
f"""
[project]
name = "json_crew"
version = "0.1.0"
{tool_config}
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "!!!"
version = "0.1.0"
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("invalid", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject