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.""" """Create a new toolkit from a template with user input."""
toolkit_directory = Path(output_directory) 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 # Check for illegal characters in the toolkit name
if re.match(r"^[a-z0-9_]+$", package_name): if re.match(r"^[a-z0-9_]+$", toolkit_name):
toolkit_name = package_name.replace("arcade_", "", 1)
if (toolkit_directory / toolkit_name).exists(): if (toolkit_directory / toolkit_name).exists():
console.print(f"[red]Toolkit '{toolkit_name}' already exists.[/red]") console.print(f"[red]Toolkit '{toolkit_name}' already exists.[/red]")
exit(1) exit(1)
@ -180,15 +176,16 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
cwd = Path.cwd() cwd = Path.cwd()
# TODO: this detection mechanism works only for people that didn't change the # TODO: this detection mechanism works only for people that didn't change the
# name of the repo, a better detection method is required here # 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": if cwd.name == "toolkits" and cwd.parent.name == "arcade-ai":
community_toolkit = ask_yes_no_question( prompt = (
"Is your toolkit a community contribution (to be merged into Arcade's `arcade-ai` repo)?", "Is your toolkit a community contribution (to be merged into "
default=False, # False for now to keep the default behavior "\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 = { context = {
"package_name": package_name, "package_name": "arcade_" + toolkit_name if is_community_toolkit else toolkit_name,
"toolkit_name": toolkit_name, "toolkit_name": toolkit_name,
"toolkit_description": toolkit_description, "toolkit_description": toolkit_description,
"toolkit_author_name": toolkit_author_name, "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_min_version": ARCADE_AI_MIN_VERSION,
"arcade_ai_max_version": ARCADE_AI_MAX_VERSION, "arcade_ai_max_version": ARCADE_AI_MAX_VERSION,
"creation_year": datetime.now().year, "creation_year": datetime.now().year,
"community_toolkit": community_toolkit, "is_community_toolkit": is_community_toolkit,
} }
template_directory = Path(__file__).parent / "templates" / "{{ toolkit_name }}" 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 # 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: try:
create_package(env, template_directory, toolkit_directory, context, ignore_pattern) create_package(env, template_directory, toolkit_directory, context, ignore_pattern)

View file

@ -36,6 +36,10 @@ dev = [
"ruff>=0.7.4,<0.8.0", "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 # Use local path sources for arcade libs when working locally
[tool.uv.sources] [tool.uv.sources]
arcade-ai = { path = "../../", editable = true } 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. 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_"): if value.startswith("arcade_"):
return value[len("arcade_") :] return value[len("arcade_") :]
return value return value
@ -68,24 +75,24 @@ class Toolkit(BaseModel):
repo = metadata.get("Repository", None) # type: ignore[attr-defined] repo = metadata.get("Repository", None) # type: ignore[attr-defined]
except importlib.metadata.PackageNotFoundError as e: 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: 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: 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 # Get the package directory
try: try:
package_dir = Path(get_package_directory(package)) package_dir = Path(get_package_directory(package))
except (ImportError, AttributeError) as e: 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 # Get all python files in the package directory
try: try:
modules = [f for f in package_dir.glob("**/*.py") if f.is_file()] modules = [f for f in package_dir.glob("**/*.py") if f.is_file()]
except OSError as e: except OSError as e:
raise ToolkitLoadError( 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 ) from e
toolkit = cls( toolkit = cls(
@ -110,31 +117,118 @@ class Toolkit(BaseModel):
return toolkit return toolkit
@classmethod @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 Load a Toolkit from an entrypoint.
Python interpreter's environment and load them as Toolkits.
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: 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 import sysconfig
# Get the site-packages directory of the current interpreter toolkits = []
site_packages_dir = sysconfig.get_paths()["purelib"] site_packages_dir = sysconfig.get_paths()["purelib"]
arcade_packages = [ arcade_packages = [
dist.metadata["Name"] dist.metadata["Name"]
for dist in importlib.metadata.distributions(path=[site_packages_dir]) for dist in importlib.metadata.distributions(path=[site_packages_dir])
if dist.metadata["Name"].startswith("arcade_") if dist.metadata["Name"].startswith("arcade_")
] ]
toolkits = []
for package in arcade_packages: for package in arcade_packages:
try: 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: except ToolkitLoadError as e:
logger.warning(f"Warning: {e} Skipping toolkit {package}") logger.warning(f"Warning: {e} Skipping toolkit {package}")
return toolkits 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: def get_package_directory(package_name: str) -> str:
""" """

View file

@ -1,6 +1,6 @@
[project] [project]
name = "arcade-core" name = "arcade-core"
version = "2.0.0" version = "2.1.0"
description = "Arcade Core - Core library for Arcade platform" description = "Arcade Core - Core library for Arcade platform"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} 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] [project]
name = "arcade-ai" name = "arcade-ai"
version = "2.0.6" version = "2.1.0"
description = "Arcade.dev - Tool Calling platform for Agents" description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}