arcade-mcp/libs/tests/core/test_toolkit.py
Eric Gustin 856606f38c
Remove arcade_ prefix requirement and add entry point toolkit discovery (#485)
## Summary
This PR removes the requirement that all toolkits must have the arcade_
prefix and introduces a more flexible toolkit discovery system using
Python entry points.

### 🏷️ Flexible Toolkit Naming
* Community toolkits: Only add arcade_ prefix when the user is in
arcade-ai/toolkits/ directory and explicitly chooses to create a
community contribution.
* External toolkits: No prefix requirement - developers can name their
toolkits however they want
* Toolkit names are now determined by user choice rather than enforced
automatically
### 🔍 Entry Point Discovery
* Added find_arcade_toolkits_from_entrypoints() method to discover
toolkits via entry points
* Entry point group: arcade_toolkits with name: toolkit_name
* Updated pyproject.toml template to include entry point configuration
* Entry point discovery takes precedence over prefix-based discovery for
deduplication
### 📦 Backward Compatibility
* Existing arcade_* prefixed toolkits continue to work via
find_arcade_toolkits_from_prefix()
find_all_arcade_toolkits() now combines both discovery methods
* Deduplication logic prefers entry point toolkits over prefix-based
ones when package names match
### 🛠️ `arcade new` Template Updates
* pyproject.toml template for `arcade new` now includes entry point
configuration: [project.entry-points.arcade_toolkits]
### 🔧 Minor Improvements
* Refactored _strip_arcade_prefix() into a separate method for
reusability
* Updated variable naming for clarity (community_toolkit →
is_community_toolkit)
### Benefits
* Developer Freedom: Toolkit developers are no longer forced to use the
arcade_ prefix. They are also no longer forced to use the package name
as the toolkit name.
* Cleaner Naming: External toolkits can use more natural names (e.g.,
my_company_toolkit instead of arcade_my_company_toolkit)
* Better Discovery: Entry points provide a more standard Python
mechanism for plugin discovery
* Flexible Distribution: Toolkits can be distributed with any package
name while still being discoverable
### Testing
* Added comprehensive tests for the new entry point functionality
* Tests cover edge cases like deduplication, error handling, and
backward compatibility
### Version Bumps
arcade-core: 2.0.0 → 2.1.0
arcade-ai: 2.0.5 → 2.1.0

This change makes the Arcade toolkit ecosystem more flexible and
developer-friendly while maintaining full backward compatibility with
existing toolkits.

---------

Co-authored-by: Mateo Torres <mateo@arcade.dev>
2025-07-16 09:51:21 -07:00

344 lines
12 KiB
Python

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"