561 lines
19 KiB
Python
561 lines
19 KiB
Python
"""Tests for auto_upload_download_files feature in Composio SDK.
|
|
|
|
This module tests the auto_upload_download_files configuration option that controls
|
|
automatic file upload and download behavior during tool execution.
|
|
"""
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from composio.client.types import Tool, tool_list_response
|
|
from composio.core.models.base import allow_tracking
|
|
from composio.core.models.tools import Tools
|
|
|
|
from tests.conftest import mock_http_client
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def disable_telemetry():
|
|
"""Disable telemetry for all tests to prevent thread issues."""
|
|
token = allow_tracking.set(False)
|
|
yield
|
|
allow_tracking.reset(token)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client():
|
|
"""Create a mock HTTP client."""
|
|
return mock_http_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_provider():
|
|
"""Create a mock provider."""
|
|
provider = Mock()
|
|
provider.name = "test_provider"
|
|
return provider
|
|
|
|
|
|
def create_mock_tool(
|
|
slug: str,
|
|
toolkit_slug: str,
|
|
input_parameters: dict | None = None,
|
|
output_parameters: dict | None = None,
|
|
) -> Tool:
|
|
"""Create a mock tool for testing."""
|
|
return Tool(
|
|
name=f"Test {slug}",
|
|
slug=slug,
|
|
description="Test tool",
|
|
input_parameters=input_parameters or {"type": "object", "properties": {}},
|
|
output_parameters=output_parameters or {"type": "object", "properties": {}},
|
|
available_versions=["v1.0.0"],
|
|
version="v1.0.0",
|
|
scopes=[],
|
|
toolkit=tool_list_response.ItemToolkit(
|
|
name=toolkit_slug.title(), slug=toolkit_slug, logo=""
|
|
),
|
|
deprecated=tool_list_response.ItemDeprecated(
|
|
available_versions=["v1.0.0"],
|
|
displayName=f"Test {slug}",
|
|
version="v1.0.0",
|
|
toolkit=tool_list_response.ItemDeprecatedToolkit(logo=""),
|
|
is_deprecated=False,
|
|
),
|
|
is_deprecated=False,
|
|
no_auth=False,
|
|
tags=[],
|
|
)
|
|
|
|
|
|
class TestAutoUploadDownloadFilesEnabled:
|
|
"""Test cases when auto_upload_download_files is enabled."""
|
|
|
|
def test_get_processes_schema_for_file_uploadable(self, mock_client, mock_provider):
|
|
"""Test that _get processes schema when auto_upload_download_files is True."""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
# Create tool with file_uploadable field
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {
|
|
"type": "string",
|
|
"file_uploadable": True,
|
|
"description": "Upload a file",
|
|
},
|
|
"text": {"type": "string"},
|
|
},
|
|
},
|
|
)
|
|
|
|
# Mock client.tools.list
|
|
mock_client.tools.list.return_value = Mock(items=[mock_tool])
|
|
|
|
# Mock provider.wrap_tools
|
|
mock_provider.wrap_tools = Mock(return_value=[])
|
|
|
|
# Get tools
|
|
tools._get(user_id="test-user", tools=["TEST_TOOL"])
|
|
|
|
# Verify schema was processed (format: "path" added to file_uploadable field)
|
|
wrap_tools_call = mock_provider.wrap_tools.call_args
|
|
processed_tools = wrap_tools_call[1]["tools"]
|
|
file_param = processed_tools[0].input_parameters["properties"]["file"]
|
|
|
|
# Check that file_uploadable schema was converted to path format
|
|
assert file_param.get("format") == "path"
|
|
assert file_param.get("type") == "string"
|
|
|
|
def test_execute_calls_substitute_file_uploads(self, mock_client, mock_provider):
|
|
"""Test that execute calls substitute_file_uploads when enabled."""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {"type": "string", "file_uploadable": True},
|
|
},
|
|
},
|
|
output_parameters={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
)
|
|
|
|
with patch.object(
|
|
tools, "get_raw_composio_tool_by_slug", return_value=mock_tool
|
|
):
|
|
mock_execute_response = Mock()
|
|
mock_execute_response.model_dump.return_value = {
|
|
"data": {"result": "success"},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
mock_client.tools.execute.return_value = mock_execute_response
|
|
|
|
# Patch both substitute methods to verify substitute_file_uploads is called
|
|
with (
|
|
patch.object(
|
|
tools._file_helper, "substitute_file_uploads"
|
|
) as mock_upload,
|
|
patch.object(
|
|
tools._file_helper, "substitute_file_downloads"
|
|
) as mock_download,
|
|
):
|
|
mock_upload.return_value = {"file": "processed_path"}
|
|
mock_download.return_value = {
|
|
"data": {"result": "success"},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
|
|
tools.execute(
|
|
slug="TEST_TOOL",
|
|
arguments={"file": "/path/to/file.txt"},
|
|
dangerously_skip_version_check=True,
|
|
)
|
|
|
|
mock_upload.assert_called_once()
|
|
|
|
def test_execute_runs_substitute_before_before_execute(
|
|
self, mock_client, mock_provider
|
|
):
|
|
"""File substitution runs before before_execute modifiers (same order as TypeScript)."""
|
|
from composio.core.models._modifiers import Modifier
|
|
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {"type": "string", "file_uploadable": True},
|
|
},
|
|
},
|
|
output_parameters={"type": "object", "properties": {}},
|
|
)
|
|
tools._tool_schemas["TEST_TOOL"] = mock_tool
|
|
|
|
seen_in_modifier: list = []
|
|
|
|
def before_modifier(tool: str, toolkit: str, params): # type: ignore[no-untyped-def]
|
|
seen_in_modifier.append(dict(params["arguments"]))
|
|
return params
|
|
|
|
modifiers = [
|
|
Modifier(
|
|
modifier=before_modifier, type_="before_execute", tools=[], toolkits=[]
|
|
),
|
|
]
|
|
|
|
with patch.object(
|
|
tools._file_helper,
|
|
"substitute_file_uploads",
|
|
return_value={"file": "after_substitute"},
|
|
) as mock_sub:
|
|
mock_execute_response = Mock()
|
|
mock_execute_response.model_dump.return_value = {
|
|
"data": {},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
mock_client.tools.execute.return_value = mock_execute_response
|
|
|
|
with patch.object(
|
|
tools, "get_raw_composio_tool_by_slug", return_value=mock_tool
|
|
):
|
|
tools.execute(
|
|
slug="TEST_TOOL",
|
|
arguments={"file": "/tmp/x"},
|
|
modifiers=modifiers,
|
|
dangerously_skip_version_check=True,
|
|
)
|
|
|
|
mock_sub.assert_called_once()
|
|
assert len(seen_in_modifier) == 1
|
|
assert seen_in_modifier[0] == {"file": "after_substitute"}
|
|
|
|
def test_execute_calls_substitute_file_downloads(self, mock_client, mock_provider):
|
|
"""Test that execute calls substitute_file_downloads when enabled."""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
output_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {"type": "object", "file_downloadable": True},
|
|
},
|
|
},
|
|
)
|
|
|
|
with patch.object(
|
|
tools, "get_raw_composio_tool_by_slug", return_value=mock_tool
|
|
):
|
|
mock_execute_response = Mock()
|
|
mock_execute_response.model_dump.return_value = {
|
|
"data": {
|
|
"file": {
|
|
"name": "result.txt",
|
|
"mimetype": "text/plain",
|
|
"s3url": "https://s3.example.com/result.txt",
|
|
}
|
|
},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
mock_client.tools.execute.return_value = mock_execute_response
|
|
|
|
# Patch both substitute methods to verify substitute_file_downloads is called
|
|
with (
|
|
patch.object(
|
|
tools._file_helper, "substitute_file_uploads"
|
|
) as mock_upload,
|
|
patch.object(
|
|
tools._file_helper, "substitute_file_downloads"
|
|
) as mock_download,
|
|
):
|
|
mock_upload.return_value = {}
|
|
mock_download.return_value = {
|
|
"data": {"file": "/downloaded/result.txt"},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
|
|
tools.execute(
|
|
slug="TEST_TOOL",
|
|
arguments={},
|
|
dangerously_skip_version_check=True,
|
|
)
|
|
|
|
mock_download.assert_called_once()
|
|
|
|
|
|
class TestAutoUploadDownloadFilesDisabled:
|
|
"""Test cases when auto_upload_download_files is disabled."""
|
|
|
|
def test_get_does_not_process_file_uploadable_when_disabled(
|
|
self, mock_client, mock_provider
|
|
):
|
|
"""Test that _get does not process file_uploadable fields when auto_upload_download_files is False."""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
# Create tool with file_uploadable field
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {
|
|
"type": "string",
|
|
"file_uploadable": True,
|
|
"description": "Upload a file",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
# Mock client.tools.list
|
|
mock_client.tools.list.return_value = Mock(items=[mock_tool])
|
|
|
|
# Mock provider.wrap_tools
|
|
mock_provider.wrap_tools = Mock(return_value=[])
|
|
|
|
# Get tools
|
|
tools._get(user_id="test-user", tools=["TEST_TOOL"])
|
|
|
|
# Verify file_uploadable schema was NOT processed (not converted to path format)
|
|
wrap_tools_call = mock_provider.wrap_tools.call_args
|
|
processed_tools = wrap_tools_call[1]["tools"]
|
|
file_param = processed_tools[0].input_parameters["properties"]["file"]
|
|
|
|
# Should NOT have format: "path" since auto_upload_download_files is False
|
|
assert file_param.get("format") is None
|
|
assert file_param.get("file_uploadable") is True
|
|
|
|
def test_get_still_enhances_descriptions_when_disabled(
|
|
self, mock_client, mock_provider
|
|
):
|
|
"""Test that _get still adds type hints and required notes when auto_upload_download_files is False.
|
|
|
|
The auto_upload_download_files flag should only control file upload/download behavior,
|
|
not the description enhancements (type hints and required notes).
|
|
"""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
# Create tool with various parameter types
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"required": ["required_param"],
|
|
"properties": {
|
|
"text_param": {
|
|
"type": "string",
|
|
"description": "A text parameter",
|
|
},
|
|
"required_param": {
|
|
"type": "integer",
|
|
"description": "A required parameter",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
# Mock client.tools.list
|
|
mock_client.tools.list.return_value = Mock(items=[mock_tool])
|
|
|
|
# Mock provider.wrap_tools
|
|
mock_provider.wrap_tools = Mock(return_value=[])
|
|
|
|
# Get tools
|
|
tools._get(user_id="test-user", tools=["TEST_TOOL"])
|
|
|
|
# Verify description enhancements were applied
|
|
wrap_tools_call = mock_provider.wrap_tools.call_args
|
|
processed_tools = wrap_tools_call[1]["tools"]
|
|
props = processed_tools[0].input_parameters["properties"]
|
|
|
|
# Type hints should be added
|
|
assert (
|
|
"Please provide a value of type string"
|
|
in props["text_param"]["description"]
|
|
)
|
|
assert (
|
|
"Please provide a value of type integer"
|
|
in props["required_param"]["description"]
|
|
)
|
|
|
|
# Required notes should be added
|
|
assert "This parameter is required" in props["required_param"]["description"]
|
|
|
|
def test_execute_skips_file_uploads_when_disabled(self, mock_client, mock_provider):
|
|
"""Test that execute does not call substitute_file_uploads when disabled."""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {"type": "string", "file_uploadable": True},
|
|
},
|
|
},
|
|
)
|
|
|
|
with patch.object(
|
|
tools, "get_raw_composio_tool_by_slug", return_value=mock_tool
|
|
):
|
|
mock_execute_response = Mock()
|
|
mock_execute_response.model_dump.return_value = {
|
|
"data": {"result": "success"},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
mock_client.tools.execute.return_value = mock_execute_response
|
|
|
|
# Patch substitute_file_uploads to verify it's NOT called
|
|
with patch.object(
|
|
tools._file_helper, "substitute_file_uploads"
|
|
) as mock_substitute:
|
|
tools.execute(
|
|
slug="TEST_TOOL",
|
|
arguments={"file": "/path/to/file.txt"},
|
|
dangerously_skip_version_check=True,
|
|
)
|
|
|
|
mock_substitute.assert_not_called()
|
|
|
|
def test_execute_skips_file_downloads_when_disabled(
|
|
self, mock_client, mock_provider
|
|
):
|
|
"""Test that execute does not call substitute_file_downloads when disabled."""
|
|
tools = Tools(
|
|
client=mock_client,
|
|
provider=mock_provider,
|
|
toolkit_versions={"test_toolkit": "20251201_01"},
|
|
)
|
|
|
|
mock_tool = create_mock_tool(
|
|
slug="TEST_TOOL",
|
|
toolkit_slug="test_toolkit",
|
|
output_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {"type": "object", "file_downloadable": True},
|
|
},
|
|
},
|
|
)
|
|
|
|
with patch.object(
|
|
tools, "get_raw_composio_tool_by_slug", return_value=mock_tool
|
|
):
|
|
mock_execute_response = Mock()
|
|
mock_execute_response.model_dump.return_value = {
|
|
"data": {
|
|
"file": {
|
|
"name": "result.txt",
|
|
"mimetype": "text/plain",
|
|
"s3url": "https://s3.example.com/result.txt",
|
|
}
|
|
},
|
|
"error": None,
|
|
"successful": True,
|
|
}
|
|
mock_client.tools.execute.return_value = mock_execute_response
|
|
|
|
# Patch substitute_file_downloads to verify it's NOT called
|
|
with patch.object(
|
|
tools._file_helper, "substitute_file_downloads"
|
|
) as mock_substitute:
|
|
result = tools.execute(
|
|
slug="TEST_TOOL",
|
|
arguments={},
|
|
dangerously_skip_version_check=True,
|
|
)
|
|
|
|
mock_substitute.assert_not_called()
|
|
|
|
# Verify raw S3 URL is preserved in response
|
|
assert (
|
|
result["data"]["file"]["s3url"]
|
|
== "https://s3.example.com/result.txt"
|
|
)
|
|
|
|
|
|
class TestAutoUploadDownloadFilesWithSDK:
|
|
"""Test cases for auto_upload_download_files with SDK initialization."""
|
|
|
|
def test_sdk_passes_auto_upload_download_off_to_tools_by_default(self):
|
|
"""Test that Composio SDK disables automatic file upload/download by default."""
|
|
from composio.sdk import Composio
|
|
|
|
with patch("composio.sdk.HttpClient"):
|
|
with patch.object(Tools, "__init__", return_value=None) as mock_init:
|
|
mock_provider = Mock()
|
|
mock_provider.name = "test"
|
|
|
|
Composio(
|
|
provider=mock_provider,
|
|
api_key="test-key",
|
|
)
|
|
|
|
mock_init.assert_called()
|
|
call_kwargs = mock_init.call_args[1]
|
|
assert (
|
|
call_kwargs.get("dangerously_allow_auto_upload_download_files")
|
|
is False
|
|
)
|
|
|
|
def test_sdk_passes_true_when_dangerously_enabled(self):
|
|
"""Test that Composio SDK passes through dangerously_allow_auto_upload_download_files."""
|
|
from composio.sdk import Composio
|
|
|
|
with patch("composio.sdk.HttpClient"):
|
|
with patch.object(Tools, "__init__", return_value=None) as mock_init:
|
|
mock_provider = Mock()
|
|
mock_provider.name = "test"
|
|
|
|
Composio(
|
|
provider=mock_provider,
|
|
api_key="test-key",
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
)
|
|
|
|
mock_init.assert_called()
|
|
call_kwargs = mock_init.call_args[1]
|
|
assert (
|
|
call_kwargs.get("dangerously_allow_auto_upload_download_files")
|
|
is True
|
|
)
|