**PR Description** This update bumps the integration’s version to `0.2.0` and brings several important changes to how `langchain-arcade` interfaces with Arcade tools: 1. **Updated Tool Definition Imports** • Replaces `arcadepy.types.shared.ToolDefinition` with `arcadepy.types.ToolGetResponse as ToolDefinition`. • The parameter extraction is now done via `tool_def.input.parameters` instead of the previous `tool_def.inputs.parameters`. 2. **Authorization Flow Adjustments** • Uses `auth_response.url` instead of `auth_response.authorization_url`. • The `authorize` and `is_authorized` methods now rely on the Arcade client’s updated arguments (`client.auth.status(id=authorization_id)`). 3. **Tool Execution Parameter Renaming** • The `execute` method now expects `input=kwargs` instead of `inputs=kwargs`, aligning with Arcade’s new API spec. 4. **Tool Retrieval Enhancements** • `_retrieve_tool_definitions` is revised to better handle pagination and tool listing (including when no tools/toolkits are explicitly provided). 5. **Version & Dependency Updates** • Increases `langchain-arcade` to `0.2.0`. • Switches `arcadepy` dependency to `~1.0.0rc1`. • Updates example requirements to consume `langchain-arcade[langgraph]>=0.2.0`. These changes may affect existing code that relies on older parameter names (`inputs.parameters` → `input.parameters`) and the renamed execute argument. Please ensure any integrations or custom usage of Arcade tools is updated accordingly.
311 lines
10 KiB
Python
311 lines
10 KiB
Python
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from arcadepy.pagination import SyncOffsetPage
|
|
from arcadepy.types import ToolGetResponse as ToolDefinition
|
|
from arcadepy.types.shared import AuthAuthorizationResponse as AuthorizationResponse
|
|
from langchain_arcade.manager import ArcadeToolManager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_arcade_client():
|
|
"""
|
|
A fixture to mock the Arcade client object for testing the ArcadeToolManager.
|
|
|
|
This mocks all relevant methods used by the manager, including:
|
|
- tools.get
|
|
- tools.list
|
|
- tools.authorize
|
|
- auth.status
|
|
"""
|
|
mock_client = MagicMock()
|
|
# Mock the "tools" sub-client
|
|
mock_client.tools.get = MagicMock()
|
|
mock_client.tools.list = MagicMock()
|
|
mock_client.tools.authorize = MagicMock()
|
|
# Mock the "auth" sub-client
|
|
mock_client.auth.status = MagicMock()
|
|
|
|
return mock_client
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(mock_arcade_client):
|
|
"""
|
|
A fixture that creates an ArcadeToolManager with the mocked Arcade client.
|
|
"""
|
|
return ArcadeToolManager(client=mock_arcade_client)
|
|
|
|
|
|
@pytest.fixture
|
|
def make_tool():
|
|
"""
|
|
A factory fixture for creating a valid ToolDefinition with a given
|
|
fully qualified name. Because the underlying ToolDefinition model
|
|
expects "toolkit" to be a dictionary with at least one field (for example "slug"),
|
|
and "requirements.authorization" to be a valid dictionary if present, we set them up
|
|
accordingly.
|
|
"""
|
|
|
|
def _make_tool(fully_qualified_name="Search_SearchGoogle", **kwargs):
|
|
# Split on the first dot to derive a 'toolkit' slug and a tool 'name'
|
|
if "." in fully_qualified_name:
|
|
raw_toolkit, raw_tool_name = fully_qualified_name.split(".", 1)
|
|
elif "_" in fully_qualified_name:
|
|
# Convert from "_" to "." to match the expected format of tool name when
|
|
# using Langchain models for LLM inference.
|
|
raw_toolkit, raw_tool_name = fully_qualified_name.split("_", 1)
|
|
|
|
else:
|
|
raw_toolkit, raw_tool_name = fully_qualified_name, fully_qualified_name
|
|
|
|
# Provide a default toolkit dict unless one already exists in kwargs
|
|
toolkit = kwargs.pop("toolkit", {"name": raw_toolkit})
|
|
|
|
# Provide a default input
|
|
# arcadepy.types.ToolGetResponse expects "input" to be a valid structure (dict).
|
|
tool_input = kwargs.pop("input", {"parameters": []})
|
|
|
|
# Convert MagicMock-based requirements (with authorization) to an appropriate dict,
|
|
# or use what's passed. If none is passed, default to None.
|
|
requirements = kwargs.pop("requirements", None)
|
|
if requirements is not None and not isinstance(requirements, dict):
|
|
# If it's e.g. a MagicMock(authorization="xyz"), convert it to a dict
|
|
req_auth = getattr(requirements, "authorization", None)
|
|
# If the test expects an authorization presence, represent it as a dict
|
|
# that Pydantic can parse
|
|
if req_auth is not None:
|
|
requirements = {"authorization": {"type": req_auth}}
|
|
else:
|
|
requirements = {"authorization": None}
|
|
|
|
# Provide a default description if none is supplied
|
|
description = kwargs.pop("description", "Mock tool for testing")
|
|
|
|
# Build the pydantic fields
|
|
data = {
|
|
"fully_qualified_name": fully_qualified_name,
|
|
"name": raw_tool_name,
|
|
"toolkit": toolkit,
|
|
"input": tool_input,
|
|
"description": description,
|
|
"requirements": requirements,
|
|
}
|
|
data.update(kwargs) # merge any extras
|
|
|
|
return ToolDefinition(**data)
|
|
|
|
return _make_tool
|
|
|
|
|
|
def test_init_tools(manager, mock_arcade_client, make_tool):
|
|
"""
|
|
Test that init_tools clears any existing tools and retrieves new ones
|
|
from either an explicit list of tools or an entire toolkit.
|
|
"""
|
|
# Arrange
|
|
mock_tool = make_tool("Search_SearchGoogle")
|
|
mock_arcade_client.tools.get.return_value = mock_tool
|
|
mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool])
|
|
# Act
|
|
manager.init_tools(tools=["Search_SearchGoogle"])
|
|
|
|
# Assert
|
|
assert "Search_SearchGoogle" in manager.tools
|
|
assert manager._tools["Search_SearchGoogle"] == mock_tool
|
|
mock_arcade_client.tools.get.assert_called_once_with(name="Search_SearchGoogle")
|
|
|
|
|
|
def test_get_tools_no_init(manager, mock_arcade_client, make_tool):
|
|
"""
|
|
If get_tools is called without init_tools and no tools are specified,
|
|
it should call init_tools internally and fetch all available tools.
|
|
"""
|
|
# Arrange
|
|
mock_tool = make_tool("Search_SearchGoogle")
|
|
mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool])
|
|
|
|
# Act
|
|
tools = manager.get_tools() # no param means manager auto-inits
|
|
|
|
# Assert
|
|
assert len(tools) == 1
|
|
assert "Search_SearchGoogle" in manager.tools
|
|
assert manager._tools["Search_SearchGoogle"] == mock_tool
|
|
mock_arcade_client.tools.list.assert_called_once()
|
|
|
|
|
|
def test_get_tools_with_explicit(manager, mock_arcade_client, make_tool):
|
|
"""
|
|
If tools or toolkits are provided to get_tools, the manager should
|
|
retrieve or update the internal _tools dictionary accordingly,
|
|
then return them as StructuredTool objects.
|
|
"""
|
|
# Arrange
|
|
mock_tool_google = make_tool("Search_SearchGoogle")
|
|
mock_tool_bing = make_tool("Search_SearchBing")
|
|
mock_arcade_client.tools.get.side_effect = [mock_tool_google, mock_tool_bing]
|
|
|
|
# Act
|
|
retrieved_tools = manager.get_tools(tools=["Search_SearchGoogle", "Search_SearchBing"])
|
|
|
|
# Assert
|
|
assert len(retrieved_tools) == 2
|
|
assert set(manager.tools) == {"Search_SearchGoogle", "Search_SearchBing"}
|
|
mock_arcade_client.tools.get.assert_any_call(name="Search_SearchGoogle")
|
|
mock_arcade_client.tools.get.assert_any_call(name="Search_SearchBing")
|
|
|
|
|
|
def test_authorize(manager, mock_arcade_client):
|
|
"""
|
|
Test the authorize method to ensure it calls the Arcade client's
|
|
tools.authorize method correctly.
|
|
"""
|
|
# Arrange
|
|
mock_arcade_client.tools.authorize.return_value = AuthorizationResponse(
|
|
id="auth_123", status="pending", tool_fully_qualified_name="Search_SearchGoogle"
|
|
)
|
|
|
|
# Act
|
|
response = manager.authorize(tool_name="Search_SearchGoogle", user_id="user_123")
|
|
|
|
# Assert
|
|
assert response.id == "auth_123"
|
|
assert response.status == "pending"
|
|
mock_arcade_client.tools.authorize.assert_called_once_with(
|
|
tool_name="Search_SearchGoogle", user_id="user_123"
|
|
)
|
|
|
|
|
|
def test_is_authorized(manager, mock_arcade_client):
|
|
"""
|
|
Test the is_authorized method which checks if authorization
|
|
has completed for a given authorization ID.
|
|
"""
|
|
# Arrange
|
|
mock_arcade_client.auth.status.return_value = MagicMock(status="completed")
|
|
|
|
# Act
|
|
status_result = manager.is_authorized("auth_abc")
|
|
|
|
# Assert
|
|
assert status_result is True
|
|
mock_arcade_client.auth.status.assert_called_once_with(id="auth_abc")
|
|
|
|
|
|
def test_requires_auth_true(manager, make_tool):
|
|
"""
|
|
Test the requires_auth method returning True if
|
|
the stored tool definition's requirements contain an authorization entry.
|
|
"""
|
|
# Arrange
|
|
tool_name = "Search_SearchGoogle"
|
|
# Pass a MagicMock with 'authorization' to ensure it gets converted
|
|
mock_tool_def = make_tool(tool_name, requirements=MagicMock(authorization="some_required_auth"))
|
|
manager._tools[tool_name] = mock_tool_def
|
|
|
|
# Act
|
|
result = manager.requires_auth(tool_name)
|
|
|
|
# Assert
|
|
assert result is True
|
|
|
|
|
|
def test_requires_auth_false(manager, make_tool):
|
|
"""
|
|
Test the requires_auth method returning False if authorization
|
|
is not required in the tool definition.
|
|
"""
|
|
# Arrange
|
|
tool_name = "Search_SearchGoogle"
|
|
mock_tool_def = make_tool(tool_name, requirements=MagicMock(authorization=None))
|
|
manager._tools[tool_name] = mock_tool_def
|
|
|
|
# Act
|
|
result = manager.requires_auth(tool_name)
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
|
|
def test_get_tool_definition_existing(manager, make_tool):
|
|
"""
|
|
Test the internal _get_tool_definition method retrieving
|
|
an existing tool definition by name.
|
|
"""
|
|
# Arrange
|
|
tool_name = "Search_SearchGoogle"
|
|
mock_tool_def = make_tool(tool_name)
|
|
manager._tools[tool_name] = mock_tool_def
|
|
|
|
# Act
|
|
definition = manager._get_tool_definition(tool_name)
|
|
|
|
# Assert
|
|
assert definition == mock_tool_def
|
|
|
|
|
|
def test_get_tool_definition_missing(manager):
|
|
"""
|
|
Test the internal _get_tool_definition method raising a ValueError
|
|
if the tool is not in the manager.
|
|
"""
|
|
# Act & Assert
|
|
with pytest.raises(ValueError) as excinfo:
|
|
manager._get_tool_definition("Nonexistent.Tool")
|
|
|
|
assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value)
|
|
|
|
|
|
def test_retrieve_tool_definitions_tools_only(manager, mock_arcade_client, make_tool):
|
|
"""
|
|
Test the internal _retrieve_tool_definitions method by specifying tools only.
|
|
"""
|
|
# Arrange
|
|
mock_tool = make_tool("Search_SearchGoogle")
|
|
mock_arcade_client.tools.get.return_value = mock_tool
|
|
|
|
# Act
|
|
results = manager._retrieve_tool_definitions(tools=["Search_SearchGoogle"], toolkits=None)
|
|
|
|
# Assert
|
|
assert len(results) == 1
|
|
assert "Search_SearchGoogle" in results
|
|
mock_arcade_client.tools.get.assert_called_once_with(name="Search_SearchGoogle")
|
|
|
|
|
|
def test_retrieve_tool_definitions_toolkits_only(manager, mock_arcade_client, make_tool):
|
|
"""
|
|
Test the internal _retrieve_tool_definitions method by specifying toolkits.
|
|
"""
|
|
# Arrange
|
|
mock_tool = make_tool("Search_SearchBing")
|
|
mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool])
|
|
|
|
# Act
|
|
results = manager._retrieve_tool_definitions(tools=None, toolkits=["Search"])
|
|
|
|
# Assert
|
|
assert len(results) == 1
|
|
assert "Search_SearchBing" in results
|
|
mock_arcade_client.tools.list.assert_called_once_with(toolkit="Search")
|
|
|
|
|
|
def test_retrieve_tool_definitions_no_args(manager, mock_arcade_client, make_tool):
|
|
"""
|
|
Test the internal _retrieve_tool_definitions method when no
|
|
arguments are provided, retrieving all available tools.
|
|
"""
|
|
# Arrange
|
|
mock_tool1 = make_tool("Search_SearchGoogle")
|
|
mock_tool2 = make_tool("Search_SearchBing")
|
|
mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool1, mock_tool2])
|
|
|
|
# Act
|
|
results = manager._retrieve_tool_definitions()
|
|
|
|
# Assert
|
|
assert len(results) == 2
|
|
assert "Search_SearchGoogle" in results
|
|
assert "Search_SearchBing" in results
|
|
mock_arcade_client.tools.list.assert_called_once()
|