From 98b7626784d1bfa9c1b5bf1c06d2414f1584bfbf Mon Sep 17 00:00:00 2001 From: Thiago Moretto <168731+thiagomoretto@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:21:53 -0300 Subject: [PATCH] feat: extract and publish tool metadata to AMP (#4298) * Exporting tool's metadata to AMP - initial work * Fix payload (nest under `tools` key) * Remove debug message + code simplification * Priting out detected tools * Extract module name * fix: address PR review feedback for tool metadata extraction - Use sha256 instead of md5 for module name hashing (lint S324) - Filter required list to match filtered properties in JSON schema * fix: Use sha256 instead of md5 for module name hashing (lint S324) - Add missing mocks to metadata extraction failure test * style: fix ruff formatting * fix: resolve mypy type errors in utils.py * fix: address bot review feedback on tool metadata - Use `is not None` instead of truthiness check so empty tools list is sent to the API rather than being silently dropped as None - Strip __init__ suffix from module path for tools in __init__.py files - Extend _unwrap_schema to handle function-before, function-wrap, and definitions wrapper types * fix: capture env_vars declared with Field(default_factory=...) When env_vars uses default_factory, pydantic stores a callable in the schema instead of a static default value. Fall back to calling the factory when no static default is present. --------- Co-authored-by: Greyson LaLonde --- lib/crewai/src/crewai/cli/plus_api.py | 4 + lib/crewai/src/crewai/cli/tools/main.py | 65 ++++- lib/crewai/src/crewai/cli/utils.py | 317 ++++++++++++++++++++++-- lib/crewai/tests/cli/test_plus_api.py | 44 ++++ lib/crewai/tests/cli/test_utils.py | 287 +++++++++++++++++++++ lib/crewai/tests/cli/tools/test_main.py | 79 ++++++ 6 files changed, 768 insertions(+), 28 deletions(-) diff --git a/lib/crewai/src/crewai/cli/plus_api.py b/lib/crewai/src/crewai/cli/plus_api.py index 665221f1e..ac7acfda9 100644 --- a/lib/crewai/src/crewai/cli/plus_api.py +++ b/lib/crewai/src/crewai/cli/plus_api.py @@ -73,6 +73,7 @@ class PlusAPI: description: str | None, encoded_file: str, available_exports: list[dict[str, Any]] | None = None, + tools_metadata: list[dict[str, Any]] | None = None, ) -> httpx.Response: params = { "handle": handle, @@ -81,6 +82,9 @@ class PlusAPI: "file": encoded_file, "description": description, "available_exports": available_exports, + "tools_metadata": {"package": handle, "tools": tools_metadata} + if tools_metadata is not None + else None, } return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params) diff --git a/lib/crewai/src/crewai/cli/tools/main.py b/lib/crewai/src/crewai/cli/tools/main.py index 0a9f68af0..72c1e6e25 100644 --- a/lib/crewai/src/crewai/cli/tools/main.py +++ b/lib/crewai/src/crewai/cli/tools/main.py @@ -17,6 +17,7 @@ from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai.cli.utils import ( build_env_with_tool_repository_credentials, extract_available_exports, + extract_tools_metadata, get_project_description, get_project_name, get_project_version, @@ -101,6 +102,18 @@ class ToolCommand(BaseCommand, PlusAPIMixin): console.print( f"[green]Found these tools to publish: {', '.join([e['name'] for e in available_exports])}[/green]" ) + + console.print("[bold blue]Extracting tool metadata...[/bold blue]") + try: + tools_metadata = extract_tools_metadata() + except Exception as e: + console.print( + f"[yellow]Warning: Could not extract tool metadata: {e}[/yellow]\n" + f"Publishing will continue without detailed metadata." + ) + tools_metadata = [] + + self._print_tools_preview(tools_metadata) self._print_current_organization() with tempfile.TemporaryDirectory() as temp_build_dir: @@ -118,7 +131,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): "Project build failed. Please ensure that the command `uv build --sdist` completes successfully.", style="bold red", ) - raise SystemExit + raise SystemExit(1) tarball_path = os.path.join(temp_build_dir, tarball_filename) with open(tarball_path, "rb") as file: @@ -134,6 +147,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): description=project_description, encoded_file=f"data:application/x-gzip;base64,{encoded_tarball}", available_exports=available_exports, + tools_metadata=tools_metadata, ) self._validate_response(publish_response) @@ -246,6 +260,55 @@ class ToolCommand(BaseCommand, PlusAPIMixin): ) raise SystemExit + def _print_tools_preview(self, tools_metadata: list[dict[str, Any]]) -> None: + if not tools_metadata: + console.print("[yellow]No tool metadata extracted.[/yellow]") + return + + console.print( + f"\n[bold]Tools to be published ({len(tools_metadata)}):[/bold]\n" + ) + + for tool in tools_metadata: + console.print(f" [bold cyan]{tool.get('name', 'Unknown')}[/bold cyan]") + if tool.get("module"): + console.print(f" Module: {tool.get('module')}") + console.print(f" Name: {tool.get('humanized_name', 'N/A')}") + console.print( + f" Description: {tool.get('description', 'N/A')[:80]}{'...' if len(tool.get('description', '')) > 80 else ''}" + ) + + init_params = tool.get("init_params_schema", {}).get("properties", {}) + if init_params: + required = tool.get("init_params_schema", {}).get("required", []) + console.print(" Init parameters:") + for param_name, param_info in init_params.items(): + param_type = param_info.get("type", "any") + is_required = param_name in required + req_marker = "[red]*[/red]" if is_required else "" + default = ( + f" = {param_info['default']}" if "default" in param_info else "" + ) + console.print( + f" - {param_name}: {param_type}{default} {req_marker}" + ) + + env_vars = tool.get("env_vars", []) + if env_vars: + console.print(" Environment variables:") + for env_var in env_vars: + req_marker = "[red]*[/red]" if env_var.get("required") else "" + default = ( + f" (default: {env_var['default']})" + if env_var.get("default") + else "" + ) + console.print( + f" - {env_var['name']}: {env_var.get('description', 'N/A')}{default} {req_marker}" + ) + + console.print() + def _print_current_organization(self) -> None: settings = Settings() if settings.org_uuid: diff --git a/lib/crewai/src/crewai/cli/utils.py b/lib/crewai/src/crewai/cli/utils.py index aa3455469..a23bdc85a 100644 --- a/lib/crewai/src/crewai/cli/utils.py +++ b/lib/crewai/src/crewai/cli/utils.py @@ -1,10 +1,15 @@ -from functools import reduce +from collections.abc import Generator, Mapping +from contextlib import contextmanager +from functools import lru_cache, reduce +import hashlib import importlib.util +import inspect from inspect import getmro, isclass, isfunction, ismethod import os from pathlib import Path import shutil import sys +import types from typing import Any, cast, get_type_hints import click @@ -544,43 +549,62 @@ def build_env_with_tool_repository_credentials( return env +@contextmanager +def _load_module_from_file( + init_file: Path, module_name: str | None = None +) -> Generator[types.ModuleType | None, None, None]: + """ + Context manager for loading a module from file with automatic cleanup. + + Yields the loaded module or None if loading fails. + """ + if module_name is None: + module_name = ( + f"temp_module_{hashlib.sha256(str(init_file).encode()).hexdigest()[:8]}" + ) + + spec = importlib.util.spec_from_file_location(module_name, init_file) + if not spec or not spec.loader: + yield None + return + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + + try: + spec.loader.exec_module(module) + yield module + finally: + sys.modules.pop(module_name, None) + + def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]: """ 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) + with _load_module_from_file(init_file) as module: + if module is None: + return [] - if not hasattr(module, "__all__"): - console.print( - f"Warning: No __all__ defined in {init_file}", - style="bold yellow", - ) - raise SystemExit(1) - - return [ - { - "name": name, - } - for name in module.__all__ - if hasattr(module, name) and is_valid_tool(getattr(module, name)) - ] + if not hasattr(module, "__all__"): + console.print( + f"Warning: No __all__ defined in {init_file}", + style="bold yellow", + ) + raise SystemExit(1) + return [ + {"name": name} + for name in module.__all__ + if hasattr(module, name) and is_valid_tool(getattr(module, name)) + ] + except SystemExit: + raise except Exception as e: console.print(f"[red]Warning: Could not load {init_file}: {e!s}[/red]") raise SystemExit(1) from e - finally: - sys.modules.pop("temp_module", None) - def _print_no_tools_warning() -> None: """ @@ -610,3 +634,242 @@ def _print_no_tools_warning() -> None: " # ... implementation\n" " return result\n" ) + + +def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]: + """ + Extract rich metadata from tool classes in the project. + + Returns a list of tool metadata dictionaries containing: + - name: Class name + - humanized_name: From name field default + - description: From description field default + - run_params_schema: JSON Schema for _run() params (from args_schema) + - init_params_schema: JSON Schema for __init__ params (filtered) + - env_vars: List of environment variable dicts + """ + tools_metadata: list[dict[str, Any]] = [] + + for init_file in Path(dir_path).glob("**/__init__.py"): + tools = _extract_tool_metadata_from_init(init_file) + tools_metadata.extend(tools) + + return tools_metadata + + +def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]: + """ + Load module from init file and extract metadata from valid tool classes. + """ + from crewai.tools.base_tool import BaseTool + + try: + with _load_module_from_file(init_file) as module: + if module is None: + return [] + + exported_names = getattr(module, "__all__", None) + if not exported_names: + return [] + + tools_metadata = [] + for name in exported_names: + obj = getattr(module, name, None) + if obj is None or not ( + inspect.isclass(obj) and issubclass(obj, BaseTool) + ): + continue + if tool_info := _extract_single_tool_metadata(obj): + tools_metadata.append(tool_info) + + return tools_metadata + except Exception as e: + console.print( + f"[yellow]Warning: Could not extract metadata from {init_file}: {e}[/yellow]" + ) + return [] + + +def _extract_single_tool_metadata(tool_class: type) -> dict[str, Any] | None: + """ + Extract metadata from a single tool class. + """ + try: + core_schema = cast(Any, tool_class).__pydantic_core_schema__ + if not core_schema: + return None + + schema = _unwrap_schema(core_schema) + fields = schema.get("schema", {}).get("fields", {}) + + try: + file_path = inspect.getfile(tool_class) + relative_path = Path(file_path).relative_to(Path.cwd()) + module_path = relative_path.with_suffix("") + if module_path.parts[0] == "src": + module_path = Path(*module_path.parts[1:]) + if module_path.name == "__init__": + module_path = module_path.parent + module = ".".join(module_path.parts) + except (TypeError, ValueError): + module = tool_class.__module__ + + return { + "name": tool_class.__name__, + "module": module, + "humanized_name": _extract_field_default( + fields.get("name"), fallback=tool_class.__name__ + ), + "description": str( + _extract_field_default(fields.get("description")) + ).strip(), + "run_params_schema": _extract_run_params_schema(fields.get("args_schema")), + "init_params_schema": _extract_init_params_schema(tool_class), + "env_vars": _extract_env_vars(fields.get("env_vars")), + } + + except Exception: + return None + + +def _unwrap_schema(schema: Mapping[str, Any] | dict[str, Any]) -> dict[str, Any]: + """ + Unwrap nested schema structures to get to the actual schema definition. + """ + result: dict[str, Any] = dict(schema) + while ( + result.get("type") + in {"function-after", "function-before", "function-wrap", "default"} + and "schema" in result + ): + result = dict(result["schema"]) + if result.get("type") == "definitions" and "schema" in result: + result = dict(result["schema"]) + return result + + +def _extract_field_default( + field: dict[str, Any] | None, fallback: str | list[Any] = "" +) -> str | list[Any] | int: + """ + Extract the default value from a field schema. + """ + if not field: + return fallback + + schema = field.get("schema", {}) + default = schema.get("default") + return default if isinstance(default, (list, str, int)) else fallback + + +@lru_cache(maxsize=1) +def _get_schema_generator() -> type: + """Get a SchemaGenerator that omits non-serializable defaults.""" + from pydantic.json_schema import GenerateJsonSchema + from pydantic_core import PydanticOmit + + class SchemaGenerator(GenerateJsonSchema): + def handle_invalid_for_json_schema( + self, schema: Any, error_info: Any + ) -> dict[str, Any]: + raise PydanticOmit + + return SchemaGenerator + + +def _extract_run_params_schema( + args_schema_field: dict[str, Any] | None, +) -> dict[str, Any]: + """ + Extract JSON Schema for the tool's run parameters from args_schema field. + """ + from pydantic import BaseModel + + if not args_schema_field: + return {} + + args_schema_class = args_schema_field.get("schema", {}).get("default") + if not ( + inspect.isclass(args_schema_class) and issubclass(args_schema_class, BaseModel) + ): + return {} + + try: + return args_schema_class.model_json_schema( + schema_generator=_get_schema_generator() + ) + except Exception: + return {} + + +_IGNORED_INIT_PARAMS = frozenset( + { + "name", + "description", + "env_vars", + "args_schema", + "description_updated", + "cache_function", + "result_as_answer", + "max_usage_count", + "current_usage_count", + "package_dependencies", + } +) + + +def _extract_init_params_schema(tool_class: type) -> dict[str, Any]: + """ + Extract JSON Schema for the tool's __init__ parameters, filtering out base fields. + """ + try: + json_schema: dict[str, Any] = cast(Any, tool_class).model_json_schema( + schema_generator=_get_schema_generator(), mode="serialization" + ) + filtered_properties = { + key: value + for key, value in json_schema.get("properties", {}).items() + if key not in _IGNORED_INIT_PARAMS + } + json_schema["properties"] = filtered_properties + if "required" in json_schema: + json_schema["required"] = [ + key for key in json_schema["required"] if key in filtered_properties + ] + return json_schema + except Exception: + return {} + + +def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[dict[str, Any]]: + """ + Extract environment variable definitions from env_vars field. + """ + from crewai.tools.base_tool import EnvVar + + if not env_vars_field: + return [] + + schema = env_vars_field.get("schema", {}) + default = schema.get("default") + if default is None: + default_factory = schema.get("default_factory") + if callable(default_factory): + try: + default = default_factory() + except Exception: + default = [] + + if not isinstance(default, list): + return [] + + return [ + { + "name": env_var.name, + "description": env_var.description, + "required": env_var.required, + "default": env_var.default, + } + for env_var in default + if isinstance(env_var, EnvVar) + ] diff --git a/lib/crewai/tests/cli/test_plus_api.py b/lib/crewai/tests/cli/test_plus_api.py index 95a322e21..79baeb733 100644 --- a/lib/crewai/tests/cli/test_plus_api.py +++ b/lib/crewai/tests/cli/test_plus_api.py @@ -136,6 +136,7 @@ class TestPlusAPI(unittest.TestCase): "file": encoded_file, "description": description, "available_exports": None, + "tools_metadata": None, } mock_make_request.assert_called_once_with( "POST", "/crewai_plus/api/v1/tools", json=params @@ -173,6 +174,7 @@ class TestPlusAPI(unittest.TestCase): "file": encoded_file, "description": description, "available_exports": None, + "tools_metadata": None, } self.assert_request_with_org_id( @@ -201,6 +203,48 @@ class TestPlusAPI(unittest.TestCase): "file": encoded_file, "description": description, "available_exports": None, + "tools_metadata": None, + } + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/tools", json=params + ) + self.assertEqual(response, mock_response) + + @patch("crewai.cli.plus_api.PlusAPI._make_request") + def test_publish_tool_with_tools_metadata(self, mock_make_request): + mock_response = MagicMock() + mock_make_request.return_value = mock_response + handle = "test_tool_handle" + public = True + version = "1.0.0" + description = "Test tool description" + encoded_file = "encoded_test_file" + available_exports = [{"name": "MyTool"}] + tools_metadata = [ + { + "name": "MyTool", + "humanized_name": "my_tool", + "description": "A test tool", + "run_params_schema": {"type": "object", "properties": {}}, + "init_params_schema": {"type": "object", "properties": {}}, + "env_vars": [{"name": "API_KEY", "description": "API key", "required": True, "default": None}], + } + ] + + response = self.api.publish_tool( + handle, public, version, description, encoded_file, + available_exports=available_exports, + tools_metadata=tools_metadata, + ) + + params = { + "handle": handle, + "public": public, + "version": version, + "file": encoded_file, + "description": description, + "available_exports": available_exports, + "tools_metadata": {"package": handle, "tools": tools_metadata}, } mock_make_request.assert_called_once_with( "POST", "/crewai_plus/api/v1/tools", json=params diff --git a/lib/crewai/tests/cli/test_utils.py b/lib/crewai/tests/cli/test_utils.py index 5baf1cffe..fc006a417 100644 --- a/lib/crewai/tests/cli/test_utils.py +++ b/lib/crewai/tests/cli/test_utils.py @@ -363,3 +363,290 @@ def test_get_crews_ignores_template_directories( utils.get_crews() assert not template_crew_detected + + +# Tests for extract_tools_metadata + + +def test_extract_tools_metadata_empty_project(temp_project_dir): + """Test that extract_tools_metadata returns empty list for empty project.""" + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert metadata == [] + + +def test_extract_tools_metadata_no_init_file(temp_project_dir): + """Test that extract_tools_metadata returns empty list when no __init__.py exists.""" + (temp_project_dir / "some_file.py").write_text("print('hello')") + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert metadata == [] + + +def test_extract_tools_metadata_empty_init_file(temp_project_dir): + """Test that extract_tools_metadata returns empty list for empty __init__.py.""" + create_init_file(temp_project_dir, "") + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert metadata == [] + + +def test_extract_tools_metadata_no_all_variable(temp_project_dir): + """Test that extract_tools_metadata returns empty list when __all__ is not defined.""" + create_init_file( + temp_project_dir, + "from crewai.tools import BaseTool\n\nclass MyTool(BaseTool):\n pass", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert metadata == [] + + +def test_extract_tools_metadata_valid_base_tool_class(temp_project_dir): + """Test that extract_tools_metadata extracts metadata from a valid BaseTool class.""" + 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'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 1 + assert metadata[0]["name"] == "MyTool" + assert metadata[0]["humanized_name"] == "my_tool" + assert metadata[0]["description"] == "A test tool" + + +def test_extract_tools_metadata_with_args_schema(temp_project_dir): + """Test that extract_tools_metadata extracts run_params_schema from args_schema.""" + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool +from pydantic import BaseModel + +class MyToolInput(BaseModel): + query: str + limit: int = 10 + +class MyTool(BaseTool): + name: str = "my_tool" + description: str = "A test tool" + args_schema: type[BaseModel] = MyToolInput + +__all__ = ['MyTool'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 1 + assert metadata[0]["name"] == "MyTool" + run_params = metadata[0]["run_params_schema"] + assert "properties" in run_params + assert "query" in run_params["properties"] + assert "limit" in run_params["properties"] + + +def test_extract_tools_metadata_with_env_vars(temp_project_dir): + """Test that extract_tools_metadata extracts env_vars.""" + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool +from crewai.tools.base_tool import EnvVar + +class MyTool(BaseTool): + name: str = "my_tool" + description: str = "A test tool" + env_vars: list[EnvVar] = [ + EnvVar(name="MY_API_KEY", description="API key for service", required=True), + EnvVar(name="MY_OPTIONAL_VAR", description="Optional var", required=False, default="default_value"), + ] + +__all__ = ['MyTool'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 1 + env_vars = metadata[0]["env_vars"] + assert len(env_vars) == 2 + assert env_vars[0]["name"] == "MY_API_KEY" + assert env_vars[0]["description"] == "API key for service" + assert env_vars[0]["required"] is True + assert env_vars[1]["name"] == "MY_OPTIONAL_VAR" + assert env_vars[1]["required"] is False + assert env_vars[1]["default"] == "default_value" + + +def test_extract_tools_metadata_with_env_vars_field_default_factory(temp_project_dir): + """Test that extract_tools_metadata extracts env_vars declared with Field(default_factory=...).""" + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool +from crewai.tools.base_tool import EnvVar +from pydantic import Field + +class MyTool(BaseTool): + name: str = "my_tool" + description: str = "A test tool" + env_vars: list[EnvVar] = Field( + default_factory=lambda: [ + EnvVar(name="MY_TOOL_API", description="API token for my tool", required=True), + ] + ) + +__all__ = ['MyTool'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 1 + env_vars = metadata[0]["env_vars"] + assert len(env_vars) == 1 + assert env_vars[0]["name"] == "MY_TOOL_API" + assert env_vars[0]["description"] == "API token for my tool" + assert env_vars[0]["required"] is True + + +def test_extract_tools_metadata_with_custom_init_params(temp_project_dir): + """Test that extract_tools_metadata extracts init_params_schema with custom params.""" + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool + +class MyTool(BaseTool): + name: str = "my_tool" + description: str = "A test tool" + api_endpoint: str = "https://api.example.com" + timeout: int = 30 + +__all__ = ['MyTool'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 1 + init_params = metadata[0]["init_params_schema"] + assert "properties" in init_params + # Custom params should be included + assert "api_endpoint" in init_params["properties"] + assert "timeout" in init_params["properties"] + # Base params should be filtered out + assert "name" not in init_params["properties"] + assert "description" not in init_params["properties"] + + +def test_extract_tools_metadata_multiple_tools(temp_project_dir): + """Test that extract_tools_metadata extracts metadata from multiple tools.""" + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool + +class FirstTool(BaseTool): + name: str = "first_tool" + description: str = "First test tool" + +class SecondTool(BaseTool): + name: str = "second_tool" + description: str = "Second test tool" + +__all__ = ['FirstTool', 'SecondTool'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 2 + names = [m["name"] for m in metadata] + assert "FirstTool" in names + assert "SecondTool" in names + + +def test_extract_tools_metadata_multiple_init_files(temp_project_dir): + """Test that extract_tools_metadata extracts metadata from multiple __init__.py files.""" + # Create tool in root __init__.py + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool + +class RootTool(BaseTool): + name: str = "root_tool" + description: str = "Root tool" + +__all__ = ['RootTool'] +""", + ) + + # Create nested package with another tool + nested_dir = temp_project_dir / "nested" + nested_dir.mkdir() + create_init_file( + nested_dir, + """from crewai.tools import BaseTool + +class NestedTool(BaseTool): + name: str = "nested_tool" + description: str = "Nested tool" + +__all__ = ['NestedTool'] +""", + ) + + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 2 + names = [m["name"] for m in metadata] + assert "RootTool" in names + assert "NestedTool" in names + + +def test_extract_tools_metadata_ignores_non_tool_exports(temp_project_dir): + """Test that extract_tools_metadata ignores non-BaseTool exports.""" + 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 + +SOME_CONSTANT = "value" + +__all__ = ['MyTool', 'not_a_tool', 'SOME_CONSTANT'] +""", + ) + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert len(metadata) == 1 + assert metadata[0]["name"] == "MyTool" + + +def test_extract_tools_metadata_import_error_returns_empty(temp_project_dir): + """Test that extract_tools_metadata returns empty list on import error.""" + create_init_file( + temp_project_dir, + """from nonexistent_module import something + +class MyTool(BaseTool): + pass + +__all__ = ['MyTool'] +""", + ) + # Should not raise, just return empty list + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert metadata == [] + + +def test_extract_tools_metadata_syntax_error_returns_empty(temp_project_dir): + """Test that extract_tools_metadata returns empty list on syntax error.""" + create_init_file( + temp_project_dir, + """from crewai.tools import BaseTool + +class MyTool(BaseTool): + # Missing closing parenthesis + def __init__(self, name: + pass + +__all__ = ['MyTool'] +""", + ) + # Should not raise, just return empty list + metadata = utils.extract_tools_metadata(dir_path=str(temp_project_dir)) + assert metadata == [] diff --git a/lib/crewai/tests/cli/tools/test_main.py b/lib/crewai/tests/cli/tools/test_main.py index 6661011d3..aba6f1075 100644 --- a/lib/crewai/tests/cli/tools/test_main.py +++ b/lib/crewai/tests/cli/tools/test_main.py @@ -185,9 +185,14 @@ def test_publish_when_not_in_sync(mock_is_synced, capsys, tool_command): "crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}], ) +@patch( + "crewai.cli.tools.main.extract_tools_metadata", + return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], +) @patch("crewai.cli.tools.main.ToolCommand._print_current_organization") def test_publish_when_not_in_sync_and_force( mock_print_org, + mock_tools_metadata, mock_available_exports, mock_is_synced, mock_publish, @@ -222,6 +227,7 @@ def test_publish_when_not_in_sync_and_force( description="A sample tool", encoded_file=unittest.mock.ANY, available_exports=[{"name": "SampleTool"}], + tools_metadata=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], ) mock_print_org.assert_called_once() @@ -242,7 +248,12 @@ def test_publish_when_not_in_sync_and_force( "crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}], ) +@patch( + "crewai.cli.tools.main.extract_tools_metadata", + return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], +) def test_publish_success( + mock_tools_metadata, mock_available_exports, mock_is_synced, mock_publish, @@ -277,6 +288,7 @@ def test_publish_success( description="A sample tool", encoded_file=unittest.mock.ANY, available_exports=[{"name": "SampleTool"}], + tools_metadata=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], ) @@ -295,7 +307,12 @@ def test_publish_success( "crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}], ) +@patch( + "crewai.cli.tools.main.extract_tools_metadata", + return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], +) def test_publish_failure( + mock_tools_metadata, mock_available_exports, mock_publish, mock_open, @@ -336,7 +353,12 @@ def test_publish_failure( "crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}], ) +@patch( + "crewai.cli.tools.main.extract_tools_metadata", + return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], +) def test_publish_api_error( + mock_tools_metadata, mock_available_exports, mock_publish, mock_open, @@ -362,6 +384,63 @@ def test_publish_api_error( mock_publish.assert_called_once() +@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch( + "crewai.cli.tools.main.open", + new_callable=unittest.mock.mock_open, + read_data=b"sample tarball content", +) +@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.extract_available_exports", + return_value=[{"name": "SampleTool"}], +) +@patch( + "crewai.cli.tools.main.extract_tools_metadata", + side_effect=Exception("Failed to extract metadata"), +) +def test_publish_metadata_extraction_failure_continues_with_warning( + mock_tools_metadata, + mock_available_exports, + mock_is_synced, + mock_publish, + mock_open, + mock_listdir, + mock_subprocess_run, + mock_get_project_description, + mock_get_project_version, + mock_get_project_name, + capsys, + tool_command, +): + """Test that metadata extraction failure shows warning but continues publishing.""" + mock_publish_response = MagicMock() + mock_publish_response.status_code = 200 + mock_publish_response.json.return_value = {"handle": "sample-tool"} + mock_publish.return_value = mock_publish_response + + tool_command.publish(is_public=True) + + output = capsys.readouterr().out + assert "Warning: Could not extract tool metadata" in output + assert "Publishing will continue without detailed metadata" in output + assert "No tool metadata extracted" in output + mock_publish.assert_called_once_with( + handle="sample-tool", + is_public=True, + version="1.0.0", + description="A sample tool", + encoded_file=unittest.mock.ANY, + available_exports=[{"name": "SampleTool"}], + tools_metadata=[], + ) + + @patch("crewai.cli.tools.main.Settings") def test_print_current_organization_with_org(mock_settings, capsys, tool_command): mock_settings_instance = MagicMock()