mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-01 23:32:39 +00:00
feat: persist available Tools from repository on publish
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -48,6 +48,7 @@ class PlusAPI:
|
|||||||
version: str,
|
version: str,
|
||||||
description: Optional[str],
|
description: Optional[str],
|
||||||
encoded_file: str,
|
encoded_file: str,
|
||||||
|
available_tool_classes: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
params = {
|
params = {
|
||||||
"handle": handle,
|
"handle": handle,
|
||||||
@@ -55,6 +56,7 @@ class PlusAPI:
|
|||||||
"version": version,
|
"version": version,
|
||||||
"file": encoded_file,
|
"file": encoded_file,
|
||||||
"description": description,
|
"description": description,
|
||||||
|
"available_tool_classes": available_tool_classes,
|
||||||
}
|
}
|
||||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
|
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from crewai.cli import git
|
|||||||
from crewai.cli.command import BaseCommand, PlusAPIMixin
|
from crewai.cli.command import BaseCommand, PlusAPIMixin
|
||||||
from crewai.cli.config import Settings
|
from crewai.cli.config import Settings
|
||||||
from crewai.cli.utils import (
|
from crewai.cli.utils import (
|
||||||
|
extract_available_tools,
|
||||||
get_project_description,
|
get_project_description,
|
||||||
get_project_name,
|
get_project_name,
|
||||||
get_project_version,
|
get_project_version,
|
||||||
@@ -82,6 +83,14 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
|||||||
project_description = get_project_description(require=False)
|
project_description = get_project_description(require=False)
|
||||||
encoded_tarball = None
|
encoded_tarball = None
|
||||||
|
|
||||||
|
console.print("[bold blue]Discovering tools from your project...[/bold blue]")
|
||||||
|
available_tools = extract_available_tools()
|
||||||
|
|
||||||
|
if available_tools:
|
||||||
|
console.print(
|
||||||
|
f"[green]Found these tools to publish: {', '.join(available_tools)}[/green]"
|
||||||
|
)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_build_dir:
|
with tempfile.TemporaryDirectory() as temp_build_dir:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["uv", "build", "--sdist", "--out-dir", temp_build_dir],
|
["uv", "build", "--sdist", "--out-dir", temp_build_dir],
|
||||||
@@ -105,12 +114,14 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
|||||||
|
|
||||||
encoded_tarball = base64.b64encode(tarball_contents).decode("utf-8")
|
encoded_tarball = base64.b64encode(tarball_contents).decode("utf-8")
|
||||||
|
|
||||||
|
console.print("[bold blue]Publishing tool to repository...[/bold blue]")
|
||||||
publish_response = self.plus_api_client.publish_tool(
|
publish_response = self.plus_api_client.publish_tool(
|
||||||
handle=project_name,
|
handle=project_name,
|
||||||
is_public=is_public,
|
is_public=is_public,
|
||||||
version=project_version,
|
version=project_version,
|
||||||
description=project_description,
|
description=project_description,
|
||||||
encoded_file=f"data:application/x-gzip;base64,{encoded_tarball}",
|
encoded_file=f"data:application/x-gzip;base64,{encoded_tarball}",
|
||||||
|
available_tools=available_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._validate_response(publish_response)
|
self._validate_response(publish_response)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from inspect import isfunction, ismethod
|
from inspect import getmro, isclass, isfunction, ismethod
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, get_type_hints
|
from typing import Any, Dict, List, get_type_hints
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -339,3 +341,110 @@ def fetch_crews(module_attr) -> list[Crew]:
|
|||||||
if crew_instance := get_crew_instance(attr):
|
if crew_instance := get_crew_instance(attr):
|
||||||
crew_instances.append(crew_instance)
|
crew_instances.append(crew_instance)
|
||||||
return crew_instances
|
return crew_instances
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_tool(obj):
|
||||||
|
from crewai.tools.base_tool import Tool
|
||||||
|
|
||||||
|
if isclass(obj):
|
||||||
|
try:
|
||||||
|
return any(base.__name__ == "BaseTool" for base in getmro(obj))
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return isinstance(obj, Tool)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_available_tools(dir_path: str = "src"):
|
||||||
|
"""
|
||||||
|
Extract available tool classes from the project's __init__.py files.
|
||||||
|
Only includes classes that inherit from BaseTool or functions decorated with @tool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of valid tool class names or ["BaseTool"] if none found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
init_files = Path(dir_path).glob("**/__init__.py")
|
||||||
|
available_tools = []
|
||||||
|
|
||||||
|
for init_file in init_files:
|
||||||
|
tools = _load_tools_from_init(init_file)
|
||||||
|
available_tools.extend(tools)
|
||||||
|
|
||||||
|
if not available_tools:
|
||||||
|
_print_no_tools_warning()
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
return available_tools
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error: Could not extract tool classes: {str(e)}[/red]")
|
||||||
|
console.print(
|
||||||
|
"Please ensure your project contains valid tools (classes inheriting from BaseTool or functions with @tool decorator)."
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tools_from_init(init_file: Path) -> list:
|
||||||
|
"""
|
||||||
|
Load and validate tools from a given __init__.py file.
|
||||||
|
"""
|
||||||
|
spec = importlib.util.spec_from_file_location("temp_module", init_file)
|
||||||
|
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
return []
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["temp_module"] = module
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
if not hasattr(module, "__all__"):
|
||||||
|
console.print(
|
||||||
|
f"[bold yellow]Warning: No __all__ defined in {init_file}[/bold yellow]"
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
return [
|
||||||
|
name
|
||||||
|
for name in module.__all__
|
||||||
|
if hasattr(module, name) and is_valid_tool(getattr(module, name))
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Warning: Could not load {init_file}: {str(e)}[/red]")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
sys.modules.pop("temp_module", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_no_tools_warning():
|
||||||
|
"""
|
||||||
|
Display warning and usage instructions if no tools were found.
|
||||||
|
"""
|
||||||
|
console.print(
|
||||||
|
"\n[bold yellow]Warning: No valid tools were exposed in your __init__.py file![/bold yellow]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"Your __init__.py file must contain all classes that inherit from [bold]BaseTool[/bold] "
|
||||||
|
"or functions decorated with [bold]@tool[/bold]."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"\nExample:\n[dim]# In your __init__.py file[/dim]\n"
|
||||||
|
"[green]__all__ = ['YourTool', 'your_tool_function'][/green]\n\n"
|
||||||
|
"[dim]# In your tool.py file[/dim]\n"
|
||||||
|
"[green]from crewai.tools import BaseTool, tool\n\n"
|
||||||
|
"# Tool class example\n"
|
||||||
|
"class YourTool(BaseTool):\n"
|
||||||
|
' name = "your_tool"\n'
|
||||||
|
' description = "Your tool description"\n'
|
||||||
|
" # ... rest of implementation\n\n"
|
||||||
|
"# Decorated function example\n"
|
||||||
|
"@tool\n"
|
||||||
|
"def your_tool_function(text: str) -> str:\n"
|
||||||
|
' """Your tool description"""\n'
|
||||||
|
" # ... implementation\n"
|
||||||
|
" return result\n"
|
||||||
|
)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class TestPlusAPI(unittest.TestCase):
|
|||||||
"version": version,
|
"version": version,
|
||||||
"file": encoded_file,
|
"file": encoded_file,
|
||||||
"description": description,
|
"description": description,
|
||||||
|
"available_tool_classes": None,
|
||||||
}
|
}
|
||||||
mock_make_request.assert_called_once_with(
|
mock_make_request.assert_called_once_with(
|
||||||
"POST", "/crewai_plus/api/v1/tools", json=params
|
"POST", "/crewai_plus/api/v1/tools", json=params
|
||||||
@@ -87,6 +88,7 @@ class TestPlusAPI(unittest.TestCase):
|
|||||||
"version": version,
|
"version": version,
|
||||||
"file": encoded_file,
|
"file": encoded_file,
|
||||||
"description": description,
|
"description": description,
|
||||||
|
"available_tool_classes": None,
|
||||||
}
|
}
|
||||||
mock_make_request.assert_called_once_with(
|
mock_make_request.assert_called_once_with(
|
||||||
"POST", "/crewai_plus/api/v1/tools", json=params
|
"POST", "/crewai_plus/api/v1/tools", json=params
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from crewai.cli import utils
|
from crewai.cli import utils
|
||||||
@@ -100,3 +101,163 @@ def test_tree_copy_to_existing_directory(temp_tree):
|
|||||||
assert os.path.isfile(os.path.join(dest_dir, "file1.txt"))
|
assert os.path.isfile(os.path.join(dest_dir, "file1.txt"))
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(dest_dir)
|
shutil.rmtree(dest_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_project_dir():
|
||||||
|
"""Create a temporary directory for testing tool extraction."""
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
yield Path(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def create_init_file(directory, content):
|
||||||
|
return create_file(directory / "__init__.py", content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_empty_project(temp_project_dir, capsys):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert "No valid tools were exposed in your __init__.py file" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_no_init_file(temp_project_dir, capsys):
|
||||||
|
(temp_project_dir / "some_file.py").write_text("print('hello')")
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert "No valid tools were exposed in your __init__.py file" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_empty_init_file(temp_project_dir, capsys):
|
||||||
|
create_init_file(temp_project_dir, "")
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert "Warning: No __all__ defined in" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_no_all_variable(temp_project_dir, capsys):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"from crewai.tools import BaseTool\n\nclass MyTool(BaseTool):\n pass",
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert "Warning: No __all__ defined in" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_valid_base_tool_class(temp_project_dir):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"""from crewai.tools import BaseTool
|
||||||
|
|
||||||
|
class MyTool(BaseTool):
|
||||||
|
name: str = "my_tool"
|
||||||
|
description: str = "A test tool"
|
||||||
|
|
||||||
|
__all__ = ['MyTool']
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
tools = utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
assert ["MyTool"] == tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_valid_tool_decorator(temp_project_dir):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"""from crewai.tools import tool
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def my_tool_function(text: str) -> str:
|
||||||
|
\"\"\"A test tool function\"\"\"
|
||||||
|
return text
|
||||||
|
|
||||||
|
__all__ = ['my_tool_function']
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
tools = utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
assert ["my_tool_function"] == tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_multiple_valid_tools(temp_project_dir):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"""from crewai.tools import BaseTool, tool
|
||||||
|
|
||||||
|
class MyTool(BaseTool):
|
||||||
|
name: str = "my_tool"
|
||||||
|
description: str = "A test tool"
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def my_tool_function(text: str) -> str:
|
||||||
|
\"\"\"A test tool function\"\"\"
|
||||||
|
return text
|
||||||
|
|
||||||
|
__all__ = ['MyTool', 'my_tool_function']
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
tools = utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
assert ["MyTool", "my_tool_function"] == tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_with_invalid_tool_decorator(temp_project_dir):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"""from crewai.tools import BaseTool
|
||||||
|
|
||||||
|
class MyTool(BaseTool):
|
||||||
|
name: str = "my_tool"
|
||||||
|
description: str = "A test tool"
|
||||||
|
|
||||||
|
def not_a_tool():
|
||||||
|
pass
|
||||||
|
|
||||||
|
__all__ = ['MyTool', 'not_a_tool']
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
tools = utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
assert ["MyTool"] == tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_import_error(temp_project_dir, capsys):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"""from nonexistent_module import something
|
||||||
|
|
||||||
|
class MyTool(BaseTool):
|
||||||
|
pass
|
||||||
|
|
||||||
|
__all__ = ['MyTool']
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert "nonexistent_module" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_available_tools_syntax_error(temp_project_dir, capsys):
|
||||||
|
create_init_file(
|
||||||
|
temp_project_dir,
|
||||||
|
"""from crewai.tools import BaseTool
|
||||||
|
|
||||||
|
class MyTool(BaseTool):
|
||||||
|
# Missing closing parenthesis
|
||||||
|
def __init__(self, name:
|
||||||
|
pass
|
||||||
|
|
||||||
|
__all__ = ['MyTool']
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
utils.extract_available_tools(dir_path=temp_project_dir)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert "was never closed" in captured.out
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ def test_publish_when_not_in_sync(mock_is_synced, capsys, tool_command):
|
|||||||
)
|
)
|
||||||
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
||||||
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False)
|
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False)
|
||||||
|
@patch("crewai.cli.tools.main.extract_available_tools", return_value=["SampleTool"])
|
||||||
def test_publish_when_not_in_sync_and_force(
|
def test_publish_when_not_in_sync_and_force(
|
||||||
|
mock_extract_available_tools,
|
||||||
mock_is_synced,
|
mock_is_synced,
|
||||||
mock_publish,
|
mock_publish,
|
||||||
mock_open,
|
mock_open,
|
||||||
@@ -168,6 +170,7 @@ def test_publish_when_not_in_sync_and_force(
|
|||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="A sample tool",
|
description="A sample tool",
|
||||||
encoded_file=unittest.mock.ANY,
|
encoded_file=unittest.mock.ANY,
|
||||||
|
available_tools=["SampleTool"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +186,9 @@ def test_publish_when_not_in_sync_and_force(
|
|||||||
)
|
)
|
||||||
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
||||||
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True)
|
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True)
|
||||||
|
@patch("crewai.cli.tools.main.extract_available_tools", return_value=["SampleTool"])
|
||||||
def test_publish_success(
|
def test_publish_success(
|
||||||
|
mock_extract_available_tools,
|
||||||
mock_is_synced,
|
mock_is_synced,
|
||||||
mock_publish,
|
mock_publish,
|
||||||
mock_open,
|
mock_open,
|
||||||
@@ -216,6 +221,7 @@ def test_publish_success(
|
|||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="A sample tool",
|
description="A sample tool",
|
||||||
encoded_file=unittest.mock.ANY,
|
encoded_file=unittest.mock.ANY,
|
||||||
|
available_tools=["SampleTool"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -230,7 +236,9 @@ def test_publish_success(
|
|||||||
read_data=b"sample tarball content",
|
read_data=b"sample tarball content",
|
||||||
)
|
)
|
||||||
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
||||||
|
@patch("crewai.cli.tools.main.extract_available_tools", return_value=["SampleTool"])
|
||||||
def test_publish_failure(
|
def test_publish_failure(
|
||||||
|
mock_extract_available_tools,
|
||||||
mock_publish,
|
mock_publish,
|
||||||
mock_open,
|
mock_open,
|
||||||
mock_listdir,
|
mock_listdir,
|
||||||
@@ -266,7 +274,9 @@ def test_publish_failure(
|
|||||||
read_data=b"sample tarball content",
|
read_data=b"sample tarball content",
|
||||||
)
|
)
|
||||||
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
|
||||||
|
@patch("crewai.cli.tools.main.extract_available_tools", return_value=["SampleTool"])
|
||||||
def test_publish_api_error(
|
def test_publish_api_error(
|
||||||
|
mock_extract_available_tools,
|
||||||
mock_publish,
|
mock_publish,
|
||||||
mock_open,
|
mock_open,
|
||||||
mock_listdir,
|
mock_listdir,
|
||||||
|
|||||||
Reference in New Issue
Block a user