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:
parent
32292d4b39
commit
856606f38c
6 changed files with 465 additions and 26 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
344
libs/tests/core/test_toolkit.py
Normal file
344
libs/tests/core/test_toolkit.py
Normal 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"
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue