diff --git a/libs/arcade-cli/arcade_cli/new.py b/libs/arcade-cli/arcade_cli/new.py index c4a4c26b..29e13556 100644 --- a/libs/arcade-cli/arcade_cli/new.py +++ b/libs/arcade-cli/arcade_cli/new.py @@ -147,12 +147,8 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None: """Create a new toolkit from a template with user input.""" toolkit_directory = Path(output_directory) - package_name = toolkit_name if toolkit_name.startswith("arcade_") else f"arcade_{toolkit_name}" - # Check for illegal characters in the toolkit name - if re.match(r"^[a-z0-9_]+$", package_name): - toolkit_name = package_name.replace("arcade_", "", 1) - + if re.match(r"^[a-z0-9_]+$", toolkit_name): if (toolkit_directory / toolkit_name).exists(): console.print(f"[red]Toolkit '{toolkit_name}' already exists.[/red]") exit(1) @@ -180,15 +176,16 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None: cwd = Path.cwd() # TODO: this detection mechanism works only for people that didn't change the # name of the repo, a better detection method is required here - community_toolkit = False + is_community_toolkit = False if cwd.name == "toolkits" and cwd.parent.name == "arcade-ai": - community_toolkit = ask_yes_no_question( - "Is your toolkit a community contribution (to be merged into Arcade's `arcade-ai` repo)?", - default=False, # False for now to keep the default behavior + prompt = ( + "Is your toolkit a community contribution (to be merged into " + "\x1b]8;;https://github.com/ArcadeAI/arcade-ai\x1b\\ArcadeAI/arcade-ai\x1b]8;;\x1b\\ repo)?" ) + is_community_toolkit = ask_yes_no_question(prompt, default=False) context = { - "package_name": package_name, + "package_name": "arcade_" + toolkit_name if is_community_toolkit else toolkit_name, "toolkit_name": toolkit_name, "toolkit_description": toolkit_description, "toolkit_author_name": toolkit_author_name, @@ -200,7 +197,7 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None: "arcade_ai_min_version": ARCADE_AI_MIN_VERSION, "arcade_ai_max_version": ARCADE_AI_MAX_VERSION, "creation_year": datetime.now().year, - "community_toolkit": community_toolkit, + "is_community_toolkit": is_community_toolkit, } template_directory = Path(__file__).parent / "templates" / "{{ toolkit_name }}" @@ -210,7 +207,7 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None: ) # Create dynamic ignore pattern based on user preferences - ignore_pattern = create_ignore_pattern(include_evals, community_toolkit) + ignore_pattern = create_ignore_pattern(include_evals, is_community_toolkit) try: create_package(env, template_directory, toolkit_directory, context, ignore_pattern) diff --git a/libs/arcade-cli/arcade_cli/templates/{{ toolkit_name }}/pyproject.toml b/libs/arcade-cli/arcade_cli/templates/{{ toolkit_name }}/pyproject.toml index a1379510..dac11a3a 100644 --- a/libs/arcade-cli/arcade_cli/templates/{{ toolkit_name }}/pyproject.toml +++ b/libs/arcade-cli/arcade_cli/templates/{{ toolkit_name }}/pyproject.toml @@ -36,6 +36,10 @@ dev = [ "ruff>=0.7.4,<0.8.0", ] +# Tell Arcade.dev that this package is a toolkit +[project.entry-points.arcade_toolkits] +toolkit_name = "{{ package_name }}" + # Use local path sources for arcade libs when working locally [tool.uv.sources] arcade-ai = { path = "../../", editable = true } diff --git a/libs/arcade-core/arcade_core/toolkit.py b/libs/arcade-core/arcade_core/toolkit.py index 92aee22d..0212d6be 100644 --- a/libs/arcade-core/arcade_core/toolkit.py +++ b/libs/arcade-core/arcade_core/toolkit.py @@ -38,6 +38,13 @@ class Toolkit(BaseModel): """ Validator to strip the 'arcade_' prefix from the name if it exists. """ + return cls._strip_arcade_prefix(value) + + @classmethod + def _strip_arcade_prefix(cls, value: str) -> str: + """ + Strip the 'arcade_' prefix from the name if it exists. + """ if value.startswith("arcade_"): return value[len("arcade_") :] return value @@ -68,24 +75,24 @@ class Toolkit(BaseModel): repo = metadata.get("Repository", None) # type: ignore[attr-defined] except importlib.metadata.PackageNotFoundError as e: - raise ToolkitLoadError(f"Package {package} not found.") from e + raise ToolkitLoadError(f"Package '{package}' not found.") from e except KeyError as e: - raise ToolkitLoadError(f"Metadata key error for package {package}.") from e + raise ToolkitLoadError(f"Metadata key error for package '{package}'.") from e except Exception as e: - raise ToolkitLoadError(f"Failed to load metadata for package {package}.") from e + raise ToolkitLoadError(f"Failed to load metadata for package '{package}'.") from e # Get the package directory try: package_dir = Path(get_package_directory(package)) except (ImportError, AttributeError) as e: - raise ToolkitLoadError(f"Failed to locate package directory for {package}.") from e + raise ToolkitLoadError(f"Failed to locate package directory for '{package}'.") from e # Get all python files in the package directory try: modules = [f for f in package_dir.glob("**/*.py") if f.is_file()] except OSError as e: raise ToolkitLoadError( - f"Failed to locate Python files in package directory for {package}." + f"Failed to locate Python files in package directory for '{package}'." ) from e toolkit = cls( @@ -110,31 +117,118 @@ class Toolkit(BaseModel): return toolkit @classmethod - def find_all_arcade_toolkits(cls) -> list["Toolkit"]: + def from_entrypoint(cls, entry: importlib.metadata.EntryPoint) -> "Toolkit": """ - Find all installed packages prefixed with 'arcade_' in the current - Python interpreter's environment and load them as Toolkits. + Load a Toolkit from an entrypoint. + + The entrypoint value is used as the toolkit name, while the package name + is extracted from the distribution that owns the entrypoint. + + Args: + entry: The EntryPoint object from importlib.metadata Returns: - List[Toolkit]: A list of Toolkit instances. + A Toolkit instance + + Raises: + ToolkitLoadError: If the toolkit cannot be loaded + """ + # Get the package name from the distribution that owns this entrypoint + if not hasattr(entry, "dist") or entry.dist is None: + raise ToolkitLoadError( + f"Entry point '{entry.name}' does not have distribution metadata. " + f"This may indicate an incomplete package installation." + ) + + package_name = entry.dist.name + + toolkit = cls.from_package(package_name) + toolkit.name = cls._strip_arcade_prefix(entry.value) + + return toolkit + + @classmethod + def find_arcade_toolkits_from_entrypoints(cls) -> list["Toolkit"]: + """ + Find and load as Toolkits all installed packages in the + current Python interpreter's environment that have a + registered entrypoint under the 'arcade.toolkits' group. + """ + toolkits = [] + toolkit_entries: list[importlib.metadata.EntryPoint] = [] + + try: + toolkit_entries = importlib.metadata.entry_points( + group="arcade_toolkits", name="toolkit_name" + ) + for entry in toolkit_entries: + try: + toolkit = cls.from_entrypoint(entry) + toolkits.append(toolkit) + logger.debug( + f"Loaded toolkit from entry point: {entry.name} = '{toolkit.name}'" + ) + except ToolkitLoadError as e: + logger.warning( + f"Warning: {e} Skipping toolkit from entry point '{entry.value}'" + ) + except Exception as e: + logger.debug(f"Entry point discovery failed or not available: {e}") + + return toolkits + + @classmethod + def find_arcade_toolkits_from_prefix(cls) -> list["Toolkit"]: + """ + Find and load as Toolkits all installed packages in the + current Python interpreter's environment that are prefixed with 'arcade_'. """ import sysconfig - # Get the site-packages directory of the current interpreter + toolkits = [] site_packages_dir = sysconfig.get_paths()["purelib"] + arcade_packages = [ dist.metadata["Name"] for dist in importlib.metadata.distributions(path=[site_packages_dir]) if dist.metadata["Name"].startswith("arcade_") ] - toolkits = [] + for package in arcade_packages: try: - toolkits.append(cls.from_package(package)) + toolkit = cls.from_package(package) + toolkits.append(toolkit) + logger.debug(f"Loaded toolkit from prefix discovery: {package}") except ToolkitLoadError as e: logger.warning(f"Warning: {e} Skipping toolkit {package}") + return toolkits + @classmethod + def find_all_arcade_toolkits(cls) -> list["Toolkit"]: + """ + Find and load as Toolkits all installed packages in the + current Python interpreter's environment that either + 1. Have a registered entrypoint under the 'arcade.toolkits' group, or + 2. Are prefixed with 'arcade_' + + Returns: + List[Toolkit]: A list of Toolkit instances. + """ + # Find toolkits + entrypoint_toolkits = cls.find_arcade_toolkits_from_entrypoints() + prefix_toolkits = cls.find_arcade_toolkits_from_prefix() + + # Deduplicate. Entrypoints are preferred over prefix-based toolkits. + seen_package_names = set() + all_toolkits = [] + for toolkit in entrypoint_toolkits + prefix_toolkits: + if toolkit.package_name not in seen_package_names: + all_toolkits.append(toolkit) + seen_package_names.add(toolkit.package_name) + + return all_toolkits + def get_package_directory(package_name: str) -> str: """ diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 8540cda5..53ddf718 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "2.0.0" +version = "2.1.0" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = {text = "MIT"} diff --git a/libs/tests/core/test_toolkit.py b/libs/tests/core/test_toolkit.py new file mode 100644 index 00000000..ce23b507 --- /dev/null +++ b/libs/tests/core/test_toolkit.py @@ -0,0 +1,344 @@ +from unittest.mock import MagicMock, patch + +import pytest +from arcade_core.errors import ToolkitLoadError +from arcade_core.toolkit import Toolkit + + +class TestToolkit: + """Test the Toolkit class functionality.""" + + def test_strip_arcade_prefix_validator(self): + """Test that the name validator strips the arcade_ prefix.""" + toolkit = Toolkit( + name="arcade_test", + package_name="arcade_test", + description="Test toolkit", + version="1.0.0", + ) + assert toolkit.name == "test" + + def test_no_arcade_prefix_unchanged(self): + """Test that names without arcade_ prefix remain unchanged.""" + toolkit = Toolkit( + name="mytest", + package_name="mytest", + description="Test toolkit", + version="1.0.0", + ) + assert toolkit.name == "mytest" + + def test_strip_arcade_prefix_method(self): + """Test the _strip_arcade_prefix static method.""" + assert Toolkit._strip_arcade_prefix("arcade_test") == "test" + assert Toolkit._strip_arcade_prefix("test") == "test" + assert Toolkit._strip_arcade_prefix("arcade_my_toolkit") == "my_toolkit" + assert Toolkit._strip_arcade_prefix("myarcade_toolkit") == "myarcade_toolkit" + assert Toolkit._strip_arcade_prefix("") == "" + assert Toolkit._strip_arcade_prefix("arcade_") == "" + + +class TestFromEntrypoint: + """Test the from_entrypoint class method.""" + + @patch("arcade_core.toolkit.Toolkit.from_package") + def test_from_entrypoint_success(self, mock_from_package): + """Test successful creation of toolkit from entry point.""" + # Create mock entry point with dist + mock_entry = MagicMock() + mock_entry.value = "my_toolkit" + mock_entry.name = "toolkit_name" + mock_entry.dist = MagicMock() + mock_entry.dist.name = "my-toolkit" + + # Mock the from_package return + mock_toolkit = Toolkit( + name="my_toolkit", + package_name="my-toolkit", + version="1.2.3", + description="My test toolkit", + author=["Test Author"], + homepage="https://github.com/test/toolkit", + ) + mock_from_package.return_value = mock_toolkit + + toolkit = Toolkit.from_entrypoint(mock_entry) + + mock_from_package.assert_called_once_with("my-toolkit") + assert toolkit.name == "my_toolkit" + assert toolkit.package_name == "my-toolkit" + + def test_from_entrypoint_no_dist(self): + """Test handling when entry point has no dist attribute.""" + # Create mock entry point without dist + mock_entry = MagicMock() + mock_entry.value = "my_toolkit" + mock_entry.name = "toolkit_name" + mock_entry.dist = None + + with pytest.raises(ToolkitLoadError, match="does not have distribution metadata"): + Toolkit.from_entrypoint(mock_entry) + + +class TestFindArcadeToolkitsFromEntrypoints: + """Test the find_arcade_toolkits_from_entrypoints method.""" + + @patch("arcade_core.toolkit.importlib.metadata.entry_points") + @patch("arcade_core.toolkit.Toolkit.from_entrypoint") + def test_find_from_entrypoints_success(self, mock_from_ep, mock_entry_points): + """Test successful discovery of toolkits from entry points.""" + # Create mock entry points + mock_ep1 = MagicMock() + mock_ep1.name = "toolkit_name" + mock_ep1.value = "toolkit1" + mock_ep2 = MagicMock() + mock_ep2.name = "toolkit_name" + mock_ep2.value = "toolkit2" + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + # Create mock toolkits + toolkit1 = Toolkit( + name="toolkit1", + package_name="toolkit1", + version="1.0.0", + description="Toolkit 1", + ) + toolkit2 = Toolkit( + name="toolkit2", + package_name="toolkit2", + version="1.0.0", + description="Toolkit 2", + ) + mock_from_ep.side_effect = [toolkit1, toolkit2] + + toolkits = Toolkit.find_arcade_toolkits_from_entrypoints() + + assert len(toolkits) == 2 + assert toolkits[0].name == "toolkit1" + assert toolkits[1].name == "toolkit2" + + @patch("arcade_core.toolkit.importlib.metadata.entry_points") + @patch("arcade_core.toolkit.Toolkit.from_entrypoint") + def test_find_from_entrypoints_with_errors(self, mock_from_ep, mock_entry_points): + """Test that errors in loading individual toolkits are handled gracefully.""" + # Create mock entry points + mock_ep1 = MagicMock() + mock_ep1.name = "toolkit_name" + mock_ep1.value = "toolkit1" + mock_ep2 = MagicMock() + mock_ep2.name = "toolkit_name" + mock_ep2.value = "toolkit2" + mock_ep3 = MagicMock() + mock_ep3.name = "toolkit_name" + mock_ep3.value = "toolkit3" + mock_entry_points.return_value = [mock_ep1, mock_ep2, mock_ep3] + + # Create mock toolkits + toolkit1 = Toolkit( + name="toolkit1", + package_name="toolkit1", + version="1.0.0", + description="Toolkit 1", + ) + toolkit3 = Toolkit( + name="toolkit3", + package_name="toolkit3", + version="1.0.0", + description="Toolkit 3", + ) + mock_from_ep.side_effect = [toolkit1, ToolkitLoadError("Failed to load toolkit2"), toolkit3] + + toolkits = Toolkit.find_arcade_toolkits_from_entrypoints() + + assert len(toolkits) == 2 + assert toolkits[0].name == "toolkit1" + assert toolkits[1].name == "toolkit3" + + @patch("arcade_core.toolkit.importlib.metadata.entry_points") + def test_find_from_entrypoints_no_group(self, mock_entry_points): + """Test when arcade_toolkits entry point group doesn't exist.""" + # Mock entry_points to return empty list + mock_entry_points.return_value = [] + + # Test + toolkits = Toolkit.find_arcade_toolkits_from_entrypoints() + + assert toolkits == [] + + +class TestFindAllArcadeToolkits: + """Test the combined toolkit discovery method.""" + + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_entrypoints") + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_prefix") + def test_find_all_no_duplicates(self, mock_find_prefix, mock_find_ep): + """Test that find_all returns combined results without duplicates.""" + # Create mock toolkits + toolkit1 = Toolkit( + name="toolkit1", + package_name="toolkit1", + version="1.0.0", + description="Toolkit 1", + ) + toolkit2 = Toolkit( + name="toolkit2", + package_name="toolkit2", + version="1.0.0", + description="Toolkit 2", + ) + # Mock the discovery methods + mock_find_ep.return_value = [toolkit1] + mock_find_prefix.return_value = [toolkit2] + + toolkits = Toolkit.find_all_arcade_toolkits() + + assert len(toolkits) == 2 + assert any(t.name == "toolkit1" for t in toolkits) + assert any(t.name == "toolkit2" for t in toolkits) + + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_entrypoints") + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_prefix") + def test_find_all_with_duplicates_prefers_entrypoint(self, mock_find_prefix, mock_find_ep): + """Test that entry point toolkits are preferred over prefix-based ones.""" + toolkit_ep = Toolkit( + name="test", + package_name="arcade_test", # Same package name + version="2.0.0", + description="Entry point version", + ) + + toolkit_prefix = Toolkit( + name="test", + package_name="arcade_test", # Same package name + version="1.0.0", + description="Prefix version", + ) + # Mock the discovery methods + mock_find_ep.return_value = [toolkit_ep] + mock_find_prefix.return_value = [toolkit_prefix] + + toolkits = Toolkit.find_all_arcade_toolkits() + + assert len(toolkits) == 1 + assert toolkits[0].version == "2.0.0" + assert toolkits[0].description == "Entry point version" + + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_entrypoints") + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_prefix") + def test_find_all_empty(self, mock_find_prefix, mock_find_ep): + """Test when no toolkits are found.""" + mock_find_ep.return_value = [] + mock_find_prefix.return_value = [] + + toolkits = Toolkit.find_all_arcade_toolkits() + assert toolkits == [] + + +class TestEntryPointCompatibility: + """Test compatibility scenarios for entry point discovery.""" + + @patch("arcade_core.toolkit.importlib.metadata.entry_points") + @patch("arcade_core.toolkit.Toolkit.from_entrypoint") + def test_duplicate_toolkit_names_in_entrypoints(self, mock_from_ep, mock_entry_points): + """Test handling of duplicate toolkit names in entry points.""" + # Create mock entry points with same name + mock_ep1 = MagicMock() + mock_ep1.name = "toolkit_name" + mock_ep1.value = "test_v1" + + mock_ep2 = MagicMock() + mock_ep2.name = "toolkit_name" + mock_ep2.value = "test_v2" + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + # Mock toolkit creation - both with same toolkit name + toolkit1 = Toolkit( + name="test", + package_name="test_v1", + version="1.0.0", + description="Test v1", + ) + + toolkit2 = Toolkit( + name="test", + package_name="test_v2", + version="2.0.0", + description="Test v2", + ) + + mock_from_ep.side_effect = [toolkit1, toolkit2] + + # Should return both even with same display name + toolkits = Toolkit.find_arcade_toolkits_from_entrypoints() + assert len(toolkits) == 2 + + @patch("arcade_core.toolkit.Toolkit.from_package") + def test_from_entrypoint_with_arcade_prefix(self, mock_from_package): + """Test that arcade_ prefix is stripped from entry point toolkits.""" + # Create mock entry point + mock_entry = MagicMock() + mock_entry.value = "arcade_example" + mock_entry.name = "toolkit_name" + mock_entry.dist = MagicMock() + mock_entry.dist.name = "arcade-example" + + # Mock the from_package return + mock_toolkit = Toolkit( + name="arcade_example", + package_name="arcade-example", + version="1.0.0", + description="Example toolkit", + ) + mock_from_package.return_value = mock_toolkit + + toolkit = Toolkit.from_entrypoint(mock_entry) + + assert toolkit.name == "example" + assert toolkit.package_name == "arcade-example" + + +class TestToolkitIntegration: + """Integration tests for toolkit discovery and loading.""" + + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_entrypoints") + @patch("arcade_core.toolkit.Toolkit.find_arcade_toolkits_from_prefix") + def test_mixed_toolkit_sources(self, mock_find_prefix, mock_find_ep): + """Test discovering toolkits from both sources with various naming patterns.""" + # Create toolkits with different naming patterns + ep_toolkit1 = Toolkit( + name="custom", + package_name="my_custom_toolkit", + version="1.0.0", + description="Custom toolkit", + ) + ep_toolkit2 = Toolkit( + name="utils", + package_name="arcade_utils", + version="2.0.0", + description="Utils toolkit", + ) + prefix_toolkit1 = Toolkit( + name="legacy", + package_name="arcade_legacy", + version="1.0.0", + description="Legacy toolkit", + ) + prefix_toolkit2 = Toolkit( + name="utils", + package_name="arcade_utils", # Same package name as ep_toolkit2 + version="0.9.0", + description="Old utils toolkit", + ) + mock_find_ep.return_value = [ep_toolkit1, ep_toolkit2] + mock_find_prefix.return_value = [prefix_toolkit1, prefix_toolkit2] + + toolkits = Toolkit.find_all_arcade_toolkits() + + # Should have 3 toolkits (ep_toolkit2 supersedes prefix_toolkit2 due to same package_name) + assert len(toolkits) == 3 + names = {t.name for t in toolkits} + assert names == {"custom", "utils", "legacy"} + utils_toolkit = next(t for t in toolkits if t.name == "utils") + assert utils_toolkit.version == "2.0.0" diff --git a/pyproject.toml b/pyproject.toml index 26a1c9a1..9c70ff4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-ai" -version = "2.0.6" +version = "2.1.0" description = "Arcade.dev - Tool Calling platform for Agents" readme = "README.md" license = {file = "LICENSE"}