Files
composiohq--composio/python/tests/test_auto_upload_download_files.py
2026-07-13 12:38:34 +08:00

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
)