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>
This commit is contained in:
Eric Gustin 2025-07-16 09:51:21 -07:00 committed by GitHub
parent 32292d4b39
commit 856606f38c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 465 additions and 26 deletions

View file

@ -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)

View file

@ -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 }

View file

@ -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:
"""

View file

@ -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"}

View file

@ -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"

View file

@ -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"}