chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests"""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""pytest configuration module"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.core.models.triggers import Triggers
|
||||
|
||||
|
||||
def get_py_fixtures_dir() -> Path:
|
||||
"""Get the Python fixtures directory path."""
|
||||
return Path(__file__).parent / "fixtures" / "webhook"
|
||||
|
||||
|
||||
def get_ts_fixtures_dir() -> Path:
|
||||
"""Get the TypeScript fixtures directory path."""
|
||||
return (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "ts"
|
||||
/ "packages"
|
||||
/ "core"
|
||||
/ "test"
|
||||
/ "fixtures"
|
||||
/ "webhook"
|
||||
)
|
||||
|
||||
|
||||
def compute_signature(
|
||||
webhook_id: str, timestamp: str, payload: str, secret: str
|
||||
) -> str:
|
||||
"""Compute webhook signature using HMAC-SHA256."""
|
||||
to_sign = f"{webhook_id}.{timestamp}.{payload}"
|
||||
signature = hmac.new(
|
||||
key=secret.encode("utf-8"),
|
||||
msg=to_sign.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
return f"v1,{base64.b64encode(signature).decode('utf-8')}"
|
||||
|
||||
|
||||
def load_fixtures() -> list[dict]:
|
||||
"""Load all webhook fixtures from the fixtures directory."""
|
||||
fixtures_dir = get_py_fixtures_dir()
|
||||
fixtures = []
|
||||
for fixture_file in fixtures_dir.glob("v*.json"):
|
||||
if "golden" in fixture_file.name:
|
||||
continue
|
||||
with open(fixture_file) as f:
|
||||
fixtures.append(json.load(f))
|
||||
return fixtures
|
||||
|
||||
|
||||
def load_golden_signatures() -> dict:
|
||||
"""Load golden signatures for contract testing."""
|
||||
fixtures_dir = get_py_fixtures_dir()
|
||||
with open(fixtures_dir / "golden-signatures.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def mock_http_client() -> Mock:
|
||||
"""Build a mock ``HttpClient`` for tool-execution tests.
|
||||
|
||||
Production routes non-idempotent writes through ``client.without_retries``
|
||||
(a retry-disabled clone of the client). The mock mirrors that by returning
|
||||
itself for ``without_retries``, so assertions on ``client.tools.execute`` and
|
||||
``client.tools.proxy`` still observe the call.
|
||||
"""
|
||||
client = Mock()
|
||||
client.without_retries = client
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client() -> Mock:
|
||||
"""Create a mock HTTP client."""
|
||||
client = mock_http_client()
|
||||
client.triggers_types = Mock()
|
||||
client.trigger_instances = Mock()
|
||||
client.trigger_instances.manage = Mock()
|
||||
client.connected_accounts = Mock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def triggers(mock_client: Mock) -> Triggers:
|
||||
"""Create a Triggers instance."""
|
||||
return Triggers(client=mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_fixtures() -> list[dict]:
|
||||
"""Load all webhook fixtures."""
|
||||
return load_fixtures()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def golden_signatures() -> dict:
|
||||
"""Load golden signatures."""
|
||||
return load_golden_signatures()
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"description": "Golden signature test cases - DO NOT MODIFY unless algorithm changes",
|
||||
"algorithm": "HMAC-SHA256",
|
||||
"format": "v1,base64(HMAC-SHA256(id.timestamp.payload, secret))",
|
||||
"testCases": [
|
||||
{
|
||||
"name": "simple payload",
|
||||
"id": "msg_test_001",
|
||||
"timestamp": "1700000000",
|
||||
"payload": "{\"test\":\"data\"}",
|
||||
"secret": "test-secret",
|
||||
"expectedSignature": "v1,4Fay6rvxXDEbH+4oA1fYP2BR/Bwzn3/7ONj40iJQagY="
|
||||
},
|
||||
{
|
||||
"name": "unicode payload",
|
||||
"id": "msg_test_002",
|
||||
"timestamp": "1700000001",
|
||||
"payload": "{\"message\":\"Hello World\"}",
|
||||
"secret": "unicode-secret-key",
|
||||
"expectedSignature": "v1,ICmAFUlesRqIFA44bU6Wlce5GszoHjG+w7nurcu5EWc="
|
||||
},
|
||||
{
|
||||
"name": "complex nested payload",
|
||||
"id": "msg_test_003",
|
||||
"timestamp": "1700000002",
|
||||
"payload": "{\"nested\":{\"array\":[1,2,3],\"object\":{\"key\":\"value\"}}}",
|
||||
"secret": "complex-secret",
|
||||
"expectedSignature": "v1,Vx/pw0yOiRRoxdfifaZ3zBKt4B3KkRKFVr6gLQNJs9Q="
|
||||
},
|
||||
{
|
||||
"name": "empty data object",
|
||||
"id": "msg_test_004",
|
||||
"timestamp": "1700000003",
|
||||
"payload": "{\"data\":{}}",
|
||||
"secret": "empty-data-secret",
|
||||
"expectedSignature": "v1,IbLtu+G65OH8Gw7ZFqy38ru+Jrg96yWfueYMMdaFYs0="
|
||||
},
|
||||
{
|
||||
"name": "special characters in secret",
|
||||
"id": "msg_test_005",
|
||||
"timestamp": "1700000004",
|
||||
"payload": "{\"test\":true}",
|
||||
"secret": "secret!@#$%^&*()",
|
||||
"expectedSignature": "v1,kUvMXJ0rouLoROxY70WIzV+zLQGpSRuCXv8evLTPjXg="
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "V1 GitHub push webhook - legacy format sanitized",
|
||||
"capturedAt": "2026-01-29T10:30:00Z",
|
||||
"headers": {
|
||||
"webhook-id": "msg_fixture_v1_001",
|
||||
"webhook-timestamp": "1738150200",
|
||||
"webhook-signature": "v1,A/VFbvOJ8JAzXZweWvc+IHaBie19NVKUpQKbeobt5xo="
|
||||
},
|
||||
"payload": "{\"trigger_name\":\"GITHUB_PUSH_EVENT\",\"connection_id\":\"conn_fixture_001\",\"trigger_id\":\"trigger_fixture_001\",\"payload\":{\"ref\":\"refs/heads/main\",\"commits\":[{\"id\":\"def456\",\"message\":\"Legacy commit\"}]},\"log_id\":\"log_fixture_003\"}",
|
||||
"testSecret": "test-webhook-secret-for-fixtures",
|
||||
"expectedResult": {
|
||||
"version": "V1",
|
||||
"triggerSlug": "GITHUB_PUSH_EVENT",
|
||||
"triggerId": "trigger_fixture_001"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "V2 GitHub push webhook - sanitized real-world structure",
|
||||
"capturedAt": "2026-01-29T10:30:00Z",
|
||||
"headers": {
|
||||
"webhook-id": "msg_fixture_v2_001",
|
||||
"webhook-timestamp": "1738150200",
|
||||
"webhook-signature": "v1,a/mTZohEnMvzupLGWsiDLhTctk+hgx3Q2r1ACjybkhk="
|
||||
},
|
||||
"payload": "{\"type\":\"github_push_event\",\"timestamp\":\"2026-01-29T10:30:00Z\",\"log_id\":\"log_fixture_002\",\"data\":{\"connection_id\":\"conn_uuid_fixture_002\",\"connection_nano_id\":\"ca_fixture_002\",\"trigger_nano_id\":\"ti_fixture_002\",\"trigger_id\":\"trigger_uuid_fixture_002\",\"user_id\":\"user_fixture_002\",\"ref\":\"refs/heads/main\",\"repository\":{\"name\":\"test-repo\"}}}",
|
||||
"testSecret": "test-webhook-secret-for-fixtures",
|
||||
"expectedResult": {
|
||||
"version": "V2",
|
||||
"triggerSlug": "GITHUB_PUSH_EVENT",
|
||||
"userId": "user_fixture_002"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "V3 GitHub push webhook - sanitized real-world structure",
|
||||
"capturedAt": "2026-01-29T10:30:00Z",
|
||||
"headers": {
|
||||
"webhook-id": "msg_fixture_v3_001",
|
||||
"webhook-timestamp": "1738150200",
|
||||
"webhook-signature": "v1,GqTsMaDtroeNNR7rDDzEGVTtHfFtIDknXhmkCXss7cA="
|
||||
},
|
||||
"payload": "{\"id\":\"evt_fixture_v3_001\",\"timestamp\":\"2026-01-29T10:30:00Z\",\"type\":\"composio.trigger.message\",\"metadata\":{\"log_id\":\"log_fixture_001\",\"trigger_slug\":\"GITHUB_PUSH_EVENT\",\"trigger_id\":\"ti_fixture_001\",\"connected_account_id\":\"ca_fixture_001\",\"auth_config_id\":\"ac_fixture_001\",\"user_id\":\"user_fixture_001\"},\"data\":{\"ref\":\"refs/heads/main\",\"repository\":{\"name\":\"test-repo\",\"full_name\":\"user/test-repo\"},\"pusher\":{\"name\":\"test-user\"},\"commits\":[{\"id\":\"abc123\",\"message\":\"Test commit\"}]}}",
|
||||
"testSecret": "test-webhook-secret-for-fixtures",
|
||||
"expectedResult": {
|
||||
"version": "V3",
|
||||
"triggerSlug": "GITHUB_PUSH_EVENT",
|
||||
"userId": "user_fixture_001",
|
||||
"connectedAccountId": "ca_fixture_001"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Tests for auth configs management."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.client.types import (
|
||||
auth_config_create_response,
|
||||
auth_config_list_response,
|
||||
auth_config_retrieve_response,
|
||||
)
|
||||
from composio.core.models.auth_configs import AuthConfigs
|
||||
|
||||
|
||||
class TestAuthConfigs:
|
||||
"""Test suite for AuthConfigs class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
"""Create a mock client for testing."""
|
||||
client = Mock()
|
||||
client.auth_configs = Mock()
|
||||
client.auth_configs.list = Mock()
|
||||
client.auth_configs.create = Mock()
|
||||
client.auth_configs.retrieve = Mock()
|
||||
client.auth_configs.update = Mock()
|
||||
client.auth_configs.delete = Mock()
|
||||
client.auth_configs.update_status = Mock()
|
||||
client.not_given = object() # Sentinel value for optional params
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def auth_configs(self, mock_client):
|
||||
"""Create an AuthConfigs instance with mock client."""
|
||||
return AuthConfigs(client=mock_client)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth_config_response(self):
|
||||
"""Mock auth config retrieve response."""
|
||||
response = Mock(spec=auth_config_retrieve_response.AuthConfigRetrieveResponse)
|
||||
response.id = "auth_12345"
|
||||
response.name = "Test Auth Config"
|
||||
response.no_of_connections = 5
|
||||
response.status = "ENABLED"
|
||||
response.toolkit = Mock()
|
||||
response.toolkit.logo = "https://example.com/logo.png"
|
||||
response.toolkit.slug = "github"
|
||||
response.uuid = "uuid-12345"
|
||||
response.auth_scheme = "OAUTH2"
|
||||
response.credentials = {
|
||||
"client_id": "test_client_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
response.expected_input_fields = [
|
||||
{"name": "client_id", "type": "string"},
|
||||
{"name": "client_secret", "type": "string"},
|
||||
]
|
||||
response.is_composio_managed = True
|
||||
response.created_by = "user_123"
|
||||
response.created_at = "2023-01-01T00:00:00Z"
|
||||
response.last_updated_at = "2023-01-01T00:00:00Z"
|
||||
return response
|
||||
|
||||
def test_constructor_creates_instance(self, auth_configs, mock_client):
|
||||
"""Test that AuthConfigs instance is created successfully."""
|
||||
assert isinstance(auth_configs, AuthConfigs)
|
||||
assert auth_configs._client is mock_client
|
||||
|
||||
# List tests
|
||||
def test_list_without_params(self, auth_configs, mock_client):
|
||||
"""Test listing auth configs without query parameters."""
|
||||
mock_response = Mock(spec=auth_config_list_response.AuthConfigListResponse)
|
||||
mock_response.items = []
|
||||
mock_response.next_cursor = None
|
||||
mock_response.total_pages = 0
|
||||
mock_client.auth_configs.list.return_value = mock_response
|
||||
|
||||
result = auth_configs.list()
|
||||
|
||||
mock_client.auth_configs.list.assert_called_once_with()
|
||||
assert result == mock_response
|
||||
|
||||
def test_list_with_params(self, auth_configs, mock_client):
|
||||
"""Test listing auth configs with query parameters."""
|
||||
mock_response = Mock(spec=auth_config_list_response.AuthConfigListResponse)
|
||||
mock_response.items = []
|
||||
mock_client.auth_configs.list.return_value = mock_response
|
||||
|
||||
result = auth_configs.list(
|
||||
cursor="cursor_123",
|
||||
is_composio_managed=True,
|
||||
limit=10,
|
||||
toolkit_slug="github",
|
||||
)
|
||||
|
||||
mock_client.auth_configs.list.assert_called_once_with(
|
||||
cursor="cursor_123",
|
||||
is_composio_managed=True,
|
||||
limit=10,
|
||||
toolkit_slug="github",
|
||||
)
|
||||
assert result == mock_response
|
||||
|
||||
def test_list_with_empty_result(self, auth_configs, mock_client):
|
||||
"""Test listing auth configs returns empty list."""
|
||||
mock_response = Mock(spec=auth_config_list_response.AuthConfigListResponse)
|
||||
mock_response.items = []
|
||||
mock_response.next_cursor = None
|
||||
mock_response.total_pages = 0
|
||||
mock_client.auth_configs.list.return_value = mock_response
|
||||
|
||||
result = auth_configs.list()
|
||||
|
||||
assert len(result.items) == 0
|
||||
assert result.next_cursor is None
|
||||
assert result.total_pages == 0
|
||||
|
||||
# Create tests
|
||||
def test_create_with_default_composio_managed_auth(self, auth_configs, mock_client):
|
||||
"""Test creating auth config with default Composio managed type."""
|
||||
mock_auth_config = Mock(spec=auth_config_create_response.AuthConfig)
|
||||
mock_auth_config.id = "auth_12345"
|
||||
mock_auth_config.auth_scheme = "OAUTH2"
|
||||
mock_auth_config.is_composio_managed = True
|
||||
|
||||
mock_response = Mock(spec=auth_config_create_response.AuthConfigCreateResponse)
|
||||
mock_response.auth_config = mock_auth_config
|
||||
|
||||
mock_client.auth_configs.create.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "use_composio_managed_auth",
|
||||
"name": "My GitHub Config",
|
||||
}
|
||||
|
||||
result = auth_configs.create("github", options)
|
||||
|
||||
mock_client.auth_configs.create.assert_called_once()
|
||||
call_args = mock_client.auth_configs.create.call_args
|
||||
assert call_args.kwargs["toolkit"] == {"slug": "github"}
|
||||
assert call_args.kwargs["auth_config"] == options
|
||||
assert result == mock_auth_config
|
||||
|
||||
def test_create_with_custom_auth_and_credentials(self, auth_configs, mock_client):
|
||||
"""Test creating custom auth config with credentials."""
|
||||
mock_auth_config = Mock(spec=auth_config_create_response.AuthConfig)
|
||||
mock_auth_config.id = "auth_12345"
|
||||
mock_auth_config.auth_scheme = "OAUTH2"
|
||||
mock_auth_config.is_composio_managed = False
|
||||
|
||||
mock_response = Mock(spec=auth_config_create_response.AuthConfigCreateResponse)
|
||||
mock_response.auth_config = mock_auth_config
|
||||
|
||||
mock_client.auth_configs.create.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "use_custom_auth",
|
||||
"name": "Custom GitHub Auth",
|
||||
"auth_scheme": "OAUTH2",
|
||||
"credentials": {
|
||||
"client_id": "test_client_id",
|
||||
"client_secret": "test_client_secret",
|
||||
},
|
||||
}
|
||||
|
||||
result = auth_configs.create("github", options)
|
||||
|
||||
mock_client.auth_configs.create.assert_called_once()
|
||||
call_args = mock_client.auth_configs.create.call_args
|
||||
assert call_args.kwargs["toolkit"] == {"slug": "github"}
|
||||
assert call_args.kwargs["auth_config"] == options
|
||||
assert result.is_composio_managed is False
|
||||
assert result.auth_scheme == "OAUTH2"
|
||||
|
||||
def test_create_with_tool_access_config(self, auth_configs, mock_client):
|
||||
"""Test creating auth config with tool access configuration."""
|
||||
mock_auth_config = Mock(spec=auth_config_create_response.AuthConfig)
|
||||
mock_auth_config.id = "auth_12345"
|
||||
|
||||
mock_response = Mock(spec=auth_config_create_response.AuthConfigCreateResponse)
|
||||
mock_response.auth_config = mock_auth_config
|
||||
|
||||
mock_client.auth_configs.create.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "use_composio_managed_auth",
|
||||
"name": "Config with Tool Access",
|
||||
"tool_access_config": {
|
||||
"tools_for_connected_account_creation": ["GITHUB_CREATE_ISSUE"]
|
||||
},
|
||||
}
|
||||
|
||||
result = auth_configs.create("github", options)
|
||||
|
||||
mock_client.auth_configs.create.assert_called_once()
|
||||
assert result == mock_auth_config
|
||||
|
||||
# Get tests
|
||||
def test_get_retrieves_auth_config_by_id(
|
||||
self, auth_configs, mock_client, mock_auth_config_response
|
||||
):
|
||||
"""Test retrieving auth config by ID."""
|
||||
mock_client.auth_configs.retrieve.return_value = mock_auth_config_response
|
||||
|
||||
result = auth_configs.get("auth_12345")
|
||||
|
||||
mock_client.auth_configs.retrieve.assert_called_once_with("auth_12345")
|
||||
assert result == mock_auth_config_response
|
||||
assert result.id == "auth_12345"
|
||||
assert result.name == "Test Auth Config"
|
||||
|
||||
def test_get_handles_not_found_error(self, auth_configs, mock_client):
|
||||
"""Test get handles API error when auth config not found."""
|
||||
mock_client.auth_configs.retrieve.side_effect = Exception(
|
||||
"Auth config not found"
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
auth_configs.get("nonexistent_auth")
|
||||
|
||||
assert "Auth config not found" in str(exc_info.value)
|
||||
|
||||
def test_get_with_is_enabled_for_tool_router_true(
|
||||
self, auth_configs, mock_client, mock_auth_config_response
|
||||
):
|
||||
"""Test retrieving auth config with isEnabledForToolRouter set to true."""
|
||||
mock_auth_config_response.is_enabled_for_tool_router = True
|
||||
mock_client.auth_configs.retrieve.return_value = mock_auth_config_response
|
||||
|
||||
result = auth_configs.get("auth_12345")
|
||||
|
||||
assert hasattr(result, "is_enabled_for_tool_router")
|
||||
assert result.is_enabled_for_tool_router is True
|
||||
|
||||
def test_get_with_is_enabled_for_tool_router_false(
|
||||
self, auth_configs, mock_client, mock_auth_config_response
|
||||
):
|
||||
"""Test retrieving auth config with isEnabledForToolRouter set to false."""
|
||||
mock_auth_config_response.is_enabled_for_tool_router = False
|
||||
mock_client.auth_configs.retrieve.return_value = mock_auth_config_response
|
||||
|
||||
result = auth_configs.get("auth_12345")
|
||||
|
||||
assert hasattr(result, "is_enabled_for_tool_router")
|
||||
assert result.is_enabled_for_tool_router is False
|
||||
|
||||
def test_get_with_is_enabled_for_tool_router_undefined(
|
||||
self, auth_configs, mock_client, mock_auth_config_response
|
||||
):
|
||||
"""Test retrieving auth config with isEnabledForToolRouter undefined."""
|
||||
# Don't set the attribute at all to simulate undefined
|
||||
if hasattr(mock_auth_config_response, "is_enabled_for_tool_router"):
|
||||
delattr(mock_auth_config_response, "is_enabled_for_tool_router")
|
||||
mock_client.auth_configs.retrieve.return_value = mock_auth_config_response
|
||||
|
||||
result = auth_configs.get("auth_12345")
|
||||
|
||||
# Should not have the attribute or it should be None
|
||||
assert (
|
||||
not hasattr(result, "is_enabled_for_tool_router")
|
||||
or result.is_enabled_for_tool_router is None
|
||||
)
|
||||
|
||||
# Update tests
|
||||
def test_update_custom_auth_config_with_credentials(
|
||||
self, auth_configs, mock_client
|
||||
):
|
||||
"""Test updating custom auth config with credentials."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": {
|
||||
"client_id": "new_client_id",
|
||||
"client_secret": "new_client_secret",
|
||||
},
|
||||
}
|
||||
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
mock_client.auth_configs.update.assert_called_once()
|
||||
call_args = mock_client.auth_configs.update.call_args
|
||||
assert call_args.kwargs["nanoid"] == "auth_12345"
|
||||
assert call_args.kwargs["type"] == "custom"
|
||||
assert call_args.kwargs["credentials"] == options["credentials"]
|
||||
assert result == mock_response
|
||||
|
||||
def test_update_default_auth_config_with_scopes(self, auth_configs, mock_client):
|
||||
"""Test updating default auth config with scopes."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "default",
|
||||
"scopes": "read:user,repo",
|
||||
}
|
||||
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
mock_client.auth_configs.update.assert_called_once()
|
||||
# Check that scopes are not directly passed but other fields are
|
||||
call_args = mock_client.auth_configs.update.call_args
|
||||
assert call_args.kwargs["nanoid"] == "auth_12345"
|
||||
assert call_args.kwargs["type"] == "default"
|
||||
assert result == mock_response
|
||||
|
||||
def test_update_with_is_enabled_for_tool_router(self, auth_configs, mock_client):
|
||||
"""Test updating auth config with isEnabledForToolRouter."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": {"api_key": "new_key"},
|
||||
"is_enabled_for_tool_router": True,
|
||||
}
|
||||
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
call_args = mock_client.auth_configs.update.call_args
|
||||
assert call_args.kwargs["is_enabled_for_tool_router"] is True
|
||||
assert result == mock_response
|
||||
|
||||
def test_update_with_tool_access_config(self, auth_configs, mock_client):
|
||||
"""Test updating auth config with tool access configuration."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": {"api_key": "new_key"},
|
||||
"tool_access_config": {
|
||||
"tools_for_connected_account_creation": ["GITHUB_CREATE_ISSUE"]
|
||||
},
|
||||
}
|
||||
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
call_args = mock_client.auth_configs.update.call_args
|
||||
assert call_args.kwargs["tool_access_config"] == options["tool_access_config"]
|
||||
assert result == mock_response
|
||||
|
||||
def test_update_with_large_credential_object(self, auth_configs, mock_client):
|
||||
"""Test updating auth config with large credential object."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
large_credentials = {
|
||||
"field1": "value1",
|
||||
"field2": "value2",
|
||||
"field3": {"nested": "object"},
|
||||
"field4": ["array", "values"],
|
||||
"field5": 12345,
|
||||
"field6": True,
|
||||
}
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": large_credentials,
|
||||
}
|
||||
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
call_args = mock_client.auth_configs.update.call_args
|
||||
assert call_args.kwargs["credentials"] == large_credentials
|
||||
assert result == mock_response
|
||||
|
||||
def test_update_handles_api_error(self, auth_configs, mock_client):
|
||||
"""Test update handles API errors."""
|
||||
mock_client.auth_configs.update.side_effect = Exception("Update failed")
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": {"api_key": "key"},
|
||||
}
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
auth_configs.update("auth_12345", options=options)
|
||||
|
||||
assert "Update failed" in str(exc_info.value)
|
||||
|
||||
# Delete tests
|
||||
def test_delete_auth_config_by_id(self, auth_configs, mock_client):
|
||||
"""Test deleting auth config by ID."""
|
||||
mock_response = {"id": "auth_12345", "status": "deleted"}
|
||||
mock_client.auth_configs.delete.return_value = mock_response
|
||||
|
||||
result = auth_configs.delete("auth_12345")
|
||||
|
||||
mock_client.auth_configs.delete.assert_called_once_with("auth_12345")
|
||||
assert result == mock_response
|
||||
|
||||
def test_delete_handles_api_error(self, auth_configs, mock_client):
|
||||
"""Test delete handles API errors."""
|
||||
mock_client.auth_configs.delete.side_effect = Exception("Delete failed")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
auth_configs.delete("auth_12345")
|
||||
|
||||
assert "Delete failed" in str(exc_info.value)
|
||||
|
||||
# Enable/Disable tests
|
||||
def test_enable_auth_config(self, auth_configs, mock_client):
|
||||
"""Test enabling auth config."""
|
||||
mock_response = {"id": "auth_12345", "status": "ENABLED"}
|
||||
mock_client.auth_configs.update_status.return_value = mock_response
|
||||
|
||||
result = auth_configs.enable("auth_12345")
|
||||
|
||||
mock_client.auth_configs.update_status.assert_called_once_with(
|
||||
"ENABLED", nanoid="auth_12345"
|
||||
)
|
||||
assert result == mock_response
|
||||
|
||||
def test_disable_auth_config(self, auth_configs, mock_client):
|
||||
"""Test disabling auth config."""
|
||||
mock_response = {"id": "auth_12345", "status": "DISABLED"}
|
||||
mock_client.auth_configs.update_status.return_value = mock_response
|
||||
|
||||
result = auth_configs.disable("auth_12345")
|
||||
|
||||
mock_client.auth_configs.update_status.assert_called_once_with(
|
||||
"DISABLED", nanoid="auth_12345"
|
||||
)
|
||||
assert result == mock_response
|
||||
|
||||
def test_enable_handles_api_error(self, auth_configs, mock_client):
|
||||
"""Test enable handles API errors."""
|
||||
mock_client.auth_configs.update_status.side_effect = Exception("Enable failed")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
auth_configs.enable("auth_12345")
|
||||
|
||||
assert "Enable failed" in str(exc_info.value)
|
||||
|
||||
def test_disable_handles_api_error(self, auth_configs, mock_client):
|
||||
"""Test disable handles API errors."""
|
||||
mock_client.auth_configs.update_status.side_effect = Exception("Disable failed")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
auth_configs.disable("auth_12345")
|
||||
|
||||
assert "Disable failed" in str(exc_info.value)
|
||||
|
||||
# Edge cases
|
||||
def test_update_with_missing_optional_fields(self, auth_configs, mock_client):
|
||||
"""Test update works with only required fields."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": {"api_key": "key"},
|
||||
}
|
||||
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
call_args = mock_client.auth_configs.update.call_args
|
||||
# Verify that optional fields use the sentinel value
|
||||
assert call_args.kwargs["is_enabled_for_tool_router"] == mock_client.not_given
|
||||
assert call_args.kwargs["tool_access_config"] == mock_client.not_given
|
||||
assert result == mock_response
|
||||
|
||||
def test_create_with_minimal_options(self, auth_configs, mock_client):
|
||||
"""Test creating auth config with minimal options."""
|
||||
mock_auth_config = Mock(spec=auth_config_create_response.AuthConfig)
|
||||
mock_auth_config.id = "auth_12345"
|
||||
|
||||
mock_response = Mock(spec=auth_config_create_response.AuthConfigCreateResponse)
|
||||
mock_response.auth_config = mock_auth_config
|
||||
|
||||
mock_client.auth_configs.create.return_value = mock_response
|
||||
|
||||
# Just the toolkit, using default type
|
||||
result = auth_configs.create("github", {"type": "use_composio_managed_auth"})
|
||||
|
||||
mock_client.auth_configs.create.assert_called_once()
|
||||
assert result == mock_auth_config
|
||||
|
||||
def test_list_with_pagination(self, auth_configs, mock_client):
|
||||
"""Test listing auth configs with pagination."""
|
||||
mock_response = Mock(spec=auth_config_list_response.AuthConfigListResponse)
|
||||
mock_response.items = [Mock(), Mock(), Mock()]
|
||||
mock_response.next_cursor = "next_page_cursor"
|
||||
mock_response.total_pages = 5
|
||||
mock_client.auth_configs.list.return_value = mock_response
|
||||
|
||||
result = auth_configs.list(cursor="current_cursor", limit=3)
|
||||
|
||||
mock_client.auth_configs.list.assert_called_once()
|
||||
assert len(result.items) == 3
|
||||
assert result.next_cursor == "next_page_cursor"
|
||||
assert result.total_pages == 5
|
||||
|
||||
def test_get_minimal_auth_config(self, auth_configs, mock_client):
|
||||
"""Test retrieving auth config with minimal fields."""
|
||||
minimal_response = Mock(
|
||||
spec=auth_config_retrieve_response.AuthConfigRetrieveResponse
|
||||
)
|
||||
minimal_response.id = "auth_minimal"
|
||||
minimal_response.name = "Minimal Config"
|
||||
minimal_response.no_of_connections = 0
|
||||
minimal_response.status = "DISABLED"
|
||||
minimal_response.toolkit = Mock()
|
||||
minimal_response.toolkit.logo = ""
|
||||
minimal_response.toolkit.slug = "minimal-toolkit"
|
||||
minimal_response.uuid = "uuid-minimal"
|
||||
|
||||
mock_client.auth_configs.retrieve.return_value = minimal_response
|
||||
|
||||
result = auth_configs.get("auth_minimal")
|
||||
|
||||
assert result.id == "auth_minimal"
|
||||
assert result.name == "Minimal Config"
|
||||
assert result.no_of_connections == 0
|
||||
assert result.status == "DISABLED"
|
||||
|
||||
|
||||
class TestAuthConfigsOverloads:
|
||||
"""Test type overloads for create and update methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
"""Create a mock client for testing."""
|
||||
client = Mock()
|
||||
client.auth_configs = Mock()
|
||||
client.auth_configs.create = Mock()
|
||||
client.auth_configs.update = Mock()
|
||||
client.not_given = object()
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def auth_configs(self, mock_client):
|
||||
"""Create an AuthConfigs instance with mock client."""
|
||||
return AuthConfigs(client=mock_client)
|
||||
|
||||
def test_create_overload_use_composio_managed_auth(self, auth_configs, mock_client):
|
||||
"""Test create with use_composio_managed_auth type."""
|
||||
mock_auth_config = Mock(spec=auth_config_create_response.AuthConfig)
|
||||
mock_response = Mock(spec=auth_config_create_response.AuthConfigCreateResponse)
|
||||
mock_response.auth_config = mock_auth_config
|
||||
mock_client.auth_configs.create.return_value = mock_response
|
||||
|
||||
options = {"type": "use_composio_managed_auth"}
|
||||
result = auth_configs.create("github", options)
|
||||
|
||||
assert result == mock_auth_config
|
||||
|
||||
def test_create_overload_use_custom_auth(self, auth_configs, mock_client):
|
||||
"""Test create with use_custom_auth type."""
|
||||
mock_auth_config = Mock(spec=auth_config_create_response.AuthConfig)
|
||||
mock_response = Mock(spec=auth_config_create_response.AuthConfigCreateResponse)
|
||||
mock_response.auth_config = mock_auth_config
|
||||
mock_client.auth_configs.create.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "use_custom_auth",
|
||||
"auth_scheme": "API_KEY",
|
||||
"credentials": {"api_key": "test_key"},
|
||||
}
|
||||
result = auth_configs.create("github", options)
|
||||
|
||||
assert result == mock_auth_config
|
||||
|
||||
def test_update_overload_custom_type(self, auth_configs, mock_client):
|
||||
"""Test update with custom type."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "custom",
|
||||
"credentials": {"api_key": "new_key"},
|
||||
}
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
assert result == mock_response
|
||||
|
||||
def test_update_overload_default_type(self, auth_configs, mock_client):
|
||||
"""Test update with default type."""
|
||||
mock_response = {"id": "auth_12345", "status": "success"}
|
||||
mock_client.auth_configs.update.return_value = mock_response
|
||||
|
||||
options = {
|
||||
"type": "default",
|
||||
"scopes": "read:user",
|
||||
}
|
||||
result = auth_configs.update("auth_12345", options=options)
|
||||
|
||||
assert result == mock_response
|
||||
@@ -0,0 +1,560 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,856 @@
|
||||
import logging
|
||||
import time
|
||||
import warnings
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from composio_client import omit
|
||||
|
||||
from composio import exceptions
|
||||
from composio.core.models.connected_accounts import (
|
||||
AuthScheme,
|
||||
ConnectedAccounts,
|
||||
ConnectionRequest,
|
||||
)
|
||||
|
||||
|
||||
def _set_initiate_response(mock_client, body, headers=None):
|
||||
"""SEC-339: route an `initiate()` mock response through the
|
||||
``with_raw_response.create`` surface that the SDK consumes for
|
||||
deprecation-header gating.
|
||||
|
||||
``headers`` defaults to ``None`` (no Deprecation header → no warning,
|
||||
matching custom-auth-config / non-OAuth-scheme behavior). Pass
|
||||
``{"Deprecation": "@..."}`` to simulate the apollo retiring branch.
|
||||
"""
|
||||
raw = Mock()
|
||||
raw.parse.return_value = body
|
||||
raw.headers = headers or {}
|
||||
mock_client.connected_accounts.with_raw_response.create.return_value = raw
|
||||
return raw
|
||||
|
||||
|
||||
class TestAuthScheme:
|
||||
def test_oauth2_with_access_token_sets_active_status(self):
|
||||
scheme = AuthScheme()
|
||||
options = {"access_token": "test_token", "refresh_token": "test_refresh"}
|
||||
|
||||
state = scheme.oauth2(options)
|
||||
|
||||
assert state["auth_scheme"] == "OAUTH2"
|
||||
assert state["val"]["access_token"] == "test_token"
|
||||
assert state["val"]["refresh_token"] == "test_refresh"
|
||||
assert state["val"]["status"] == "ACTIVE"
|
||||
|
||||
def test_oauth2_without_access_token_sets_initializing_status(self):
|
||||
scheme = AuthScheme()
|
||||
options = {"client_id": "id", "client_secret": "secret"}
|
||||
|
||||
state = scheme.oauth2(options)
|
||||
|
||||
assert state["auth_scheme"] == "OAUTH2"
|
||||
assert state["val"]["client_id"] == "id"
|
||||
assert state["val"]["client_secret"] == "secret"
|
||||
assert state["val"]["status"] == "INITIALIZING"
|
||||
|
||||
def test_oauth2_with_empty_access_token_sets_initializing_status(self):
|
||||
scheme = AuthScheme()
|
||||
state = scheme.oauth2({"access_token": ""})
|
||||
|
||||
assert state["val"]["status"] == "INITIALIZING"
|
||||
|
||||
def test_oauth2_honors_explicit_status_override(self):
|
||||
scheme = AuthScheme()
|
||||
state = scheme.oauth2({"access_token": "test_token", "status": "INITIALIZING"})
|
||||
|
||||
assert state["val"]["status"] == "INITIALIZING"
|
||||
|
||||
def test_oauth1_with_both_tokens_sets_active_status(self):
|
||||
scheme = AuthScheme()
|
||||
state = scheme.oauth1({"oauth_token": "tok", "oauth_token_secret": "secret"})
|
||||
|
||||
assert state["auth_scheme"] == "OAUTH1"
|
||||
assert state["val"]["oauth_token"] == "tok"
|
||||
assert state["val"]["oauth_token_secret"] == "secret"
|
||||
assert state["val"]["status"] == "ACTIVE"
|
||||
|
||||
def test_oauth1_without_secret_sets_initializing_status(self):
|
||||
scheme = AuthScheme()
|
||||
state = scheme.oauth1({"oauth_token": "tok"})
|
||||
|
||||
assert state["auth_scheme"] == "OAUTH1"
|
||||
assert state["val"]["status"] == "INITIALIZING"
|
||||
|
||||
def test_oauth1_with_empty_token_sets_initializing_status(self):
|
||||
scheme = AuthScheme()
|
||||
state = scheme.oauth1({"oauth_token": "", "oauth_token_secret": "secret"})
|
||||
|
||||
assert state["val"]["status"] == "INITIALIZING"
|
||||
|
||||
def test_oauth1_honors_explicit_status_override(self):
|
||||
scheme = AuthScheme()
|
||||
state = scheme.oauth1(
|
||||
{
|
||||
"oauth_token": "tok",
|
||||
"oauth_token_secret": "secret",
|
||||
"status": "INITIALIZING",
|
||||
}
|
||||
)
|
||||
|
||||
assert state["val"]["status"] == "INITIALIZING"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method_name, expected_auth_scheme, expected_status",
|
||||
[
|
||||
("api_key", "API_KEY", "ACTIVE"),
|
||||
("basic", "BASIC", "ACTIVE"),
|
||||
("bearer_token", "BEARER_TOKEN", "ACTIVE"),
|
||||
("google_service_account", "GOOGLE_SERVICE_ACCOUNT", "ACTIVE"),
|
||||
("no_auth", "NO_AUTH", "ACTIVE"),
|
||||
("calcom_auth", "CALCOM_AUTH", "ACTIVE"),
|
||||
("billcom_auth", "BILLCOM_AUTH", "ACTIVE"),
|
||||
("basic_with_jwt", "BASIC_WITH_JWT", "ACTIVE"),
|
||||
],
|
||||
)
|
||||
def test_auth_scheme_helpers_set_expected_auth_scheme_and_status(
|
||||
self, method_name, expected_auth_scheme, expected_status
|
||||
):
|
||||
scheme = AuthScheme()
|
||||
method = getattr(scheme, method_name)
|
||||
options = {"foo": "bar"}
|
||||
|
||||
state = method(options) # type: ignore[misc]
|
||||
|
||||
assert state["auth_scheme"] == expected_auth_scheme
|
||||
assert state["val"]["foo"] == "bar"
|
||||
assert state["val"]["status"] == expected_status
|
||||
|
||||
|
||||
class TestConnectionRequest:
|
||||
def test_wait_for_connection_returns_when_active(self, monkeypatch):
|
||||
mock_client = Mock()
|
||||
pending = Mock()
|
||||
pending.status = "PENDING"
|
||||
active = Mock()
|
||||
active.status = "ACTIVE"
|
||||
mock_client.connected_accounts.retrieve.side_effect = [pending, active]
|
||||
|
||||
req = ConnectionRequest(
|
||||
id="conn-123",
|
||||
status="PENDING",
|
||||
redirect_url=None,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
# Control time to avoid real sleep. Use a monotonic counter instead of a
|
||||
# finite list, because other code (e.g. tracing) may also call time.time().
|
||||
current_time = {"value": 0.0}
|
||||
|
||||
def fake_time():
|
||||
value = current_time["value"]
|
||||
current_time["value"] += 0.1
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(time, "time", fake_time)
|
||||
monkeypatch.setattr(time, "sleep", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = req.wait_for_connection(timeout=1.0)
|
||||
|
||||
assert result is active
|
||||
assert req.status == "ACTIVE"
|
||||
assert mock_client.connected_accounts.retrieve.call_count == 2
|
||||
mock_client.connected_accounts.retrieve.assert_called_with(nanoid="conn-123")
|
||||
|
||||
def test_wait_for_connection_times_out(self, monkeypatch):
|
||||
mock_client = Mock()
|
||||
pending = Mock()
|
||||
pending.status = "PENDING"
|
||||
mock_client.connected_accounts.retrieve.return_value = pending
|
||||
|
||||
req = ConnectionRequest(
|
||||
id="conn-timeout",
|
||||
status="PENDING",
|
||||
redirect_url=None,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
# Simulate time moving forward until the timeout is exceeded. Again, use
|
||||
# a monotonic counter so extra calls to time.time() do not exhaust test
|
||||
# data.
|
||||
current_time = {"value": 0.0}
|
||||
|
||||
def fake_time():
|
||||
value = current_time["value"]
|
||||
current_time["value"] += 0.6
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(time, "time", fake_time)
|
||||
monkeypatch.setattr(time, "sleep", lambda *_args, **_kwargs: None)
|
||||
|
||||
with pytest.raises(exceptions.ComposioSDKTimeoutError) as excinfo:
|
||||
req.wait_for_connection(timeout=1.0)
|
||||
|
||||
assert "Timeout while waiting for connection conn-timeout" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.parametrize("terminal_status", ["FAILED", "EXPIRED", "REVOKED"])
|
||||
def test_wait_for_connection_fails_fast_on_terminal_status(
|
||||
self, monkeypatch, terminal_status
|
||||
):
|
||||
mock_client = Mock()
|
||||
terminal = Mock()
|
||||
terminal.status = terminal_status
|
||||
mock_client.connected_accounts.retrieve.return_value = terminal
|
||||
|
||||
req = ConnectionRequest(
|
||||
id="conn-terminal",
|
||||
status="PENDING",
|
||||
redirect_url=None,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
# Patch `time.time` defensively — matches sibling tests.
|
||||
current_time = {"value": 0.0}
|
||||
|
||||
def fake_time():
|
||||
value = current_time["value"]
|
||||
current_time["value"] += 0.1
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(time, "time", fake_time)
|
||||
monkeypatch.setattr(time, "sleep", lambda *_args, **_kwargs: None)
|
||||
|
||||
with pytest.raises(exceptions.SDKError) as excinfo:
|
||||
req.wait_for_connection(timeout=10.0)
|
||||
|
||||
assert "conn-terminal" in str(excinfo.value)
|
||||
assert terminal_status in str(excinfo.value)
|
||||
# One retrieve call only — no polling once we hit a terminal state.
|
||||
assert mock_client.connected_accounts.retrieve.call_count == 1
|
||||
|
||||
def test_wait_for_connection_does_not_treat_inactive_as_terminal(self, monkeypatch):
|
||||
mock_client = Mock()
|
||||
inactive = Mock()
|
||||
inactive.status = "INACTIVE"
|
||||
active = Mock()
|
||||
active.status = "ACTIVE"
|
||||
mock_client.connected_accounts.retrieve.side_effect = [inactive, active]
|
||||
|
||||
req = ConnectionRequest(
|
||||
id="conn-inactive-recover",
|
||||
status="PENDING",
|
||||
redirect_url=None,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
current_time = {"value": 0.0}
|
||||
|
||||
def fake_time():
|
||||
value = current_time["value"]
|
||||
current_time["value"] += 0.1
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(time, "time", fake_time)
|
||||
monkeypatch.setattr(time, "sleep", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = req.wait_for_connection(timeout=1.0)
|
||||
|
||||
assert result is active
|
||||
assert req.status == "ACTIVE"
|
||||
assert mock_client.connected_accounts.retrieve.call_count == 2
|
||||
|
||||
def test_from_id_uses_client_retrieve(self):
|
||||
mock_client = Mock()
|
||||
retrieved = Mock()
|
||||
retrieved.status = "PENDING"
|
||||
mock_client.connected_accounts.retrieve.return_value = retrieved
|
||||
|
||||
req = ConnectionRequest.from_id("conn-from-id", client=mock_client)
|
||||
|
||||
mock_client.connected_accounts.retrieve.assert_called_once_with(
|
||||
nanoid="conn-from-id"
|
||||
)
|
||||
assert req.id == "conn-from-id"
|
||||
assert req.status == "PENDING"
|
||||
assert req.redirect_url is None
|
||||
|
||||
|
||||
class TestConnectedAccounts:
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
client = Mock()
|
||||
client.connected_accounts.retrieve = Mock()
|
||||
client.connected_accounts.list = Mock()
|
||||
client.connected_accounts.delete = Mock()
|
||||
client.connected_accounts.update_status = Mock()
|
||||
client.connected_accounts.refresh = Mock()
|
||||
client.connected_accounts.create = Mock()
|
||||
client.connected_accounts.patch = Mock()
|
||||
client.link.create = Mock()
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def connected_accounts(self, mock_client):
|
||||
return ConnectedAccounts(client=mock_client)
|
||||
|
||||
def test_constructor_binds_methods(self, connected_accounts, mock_client):
|
||||
assert connected_accounts.get is mock_client.connected_accounts.retrieve
|
||||
assert connected_accounts.list is mock_client.connected_accounts.list
|
||||
assert connected_accounts.delete is mock_client.connected_accounts.delete
|
||||
assert (
|
||||
connected_accounts.update_status
|
||||
is mock_client.connected_accounts.update_status
|
||||
)
|
||||
assert connected_accounts.refresh is mock_client.connected_accounts.refresh
|
||||
|
||||
def test_enable_and_disable_partials(self, connected_accounts, mock_client):
|
||||
connected_accounts.enable("conn-1")
|
||||
connected_accounts.disable("conn-2")
|
||||
|
||||
mock_client.connected_accounts.update_status.assert_any_call(
|
||||
"conn-1", enabled=True
|
||||
)
|
||||
mock_client.connected_accounts.update_status.assert_any_call(
|
||||
"conn-2", enabled=False
|
||||
)
|
||||
|
||||
def test_initiate_raises_when_multiple_accounts_and_not_allow_multiple(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
mock_accounts = Mock()
|
||||
mock_accounts.items = [Mock(), Mock()]
|
||||
mock_client.connected_accounts.list.return_value = mock_accounts
|
||||
|
||||
with pytest.raises(exceptions.ComposioMultipleConnectedAccountsError):
|
||||
connected_accounts.initiate(
|
||||
user_id="user-1", auth_config_id="auth-1", allow_multiple=False
|
||||
)
|
||||
|
||||
def test_initiate_filters_by_active_status_when_checking_existing_accounts(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
"""
|
||||
Test that initiate only considers ACTIVE accounts when checking for duplicates.
|
||||
This ensures expired or inactive accounts don't block new connection creation.
|
||||
"""
|
||||
mock_accounts = Mock()
|
||||
mock_accounts.items = []
|
||||
mock_client.connected_accounts.list.return_value = mock_accounts
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.id = "conn-123"
|
||||
mock_response.connection_data.val.status = "PENDING"
|
||||
mock_response.connection_data.val.redirect_url = "https://redirect"
|
||||
_set_initiate_response(mock_client, mock_response)
|
||||
|
||||
connected_accounts.initiate(user_id="user-1", auth_config_id="auth-1")
|
||||
|
||||
# Verify that list is called with statuses=["ACTIVE"] to filter only active accounts
|
||||
mock_client.connected_accounts.list.assert_called_once_with(
|
||||
user_ids=["user-1"], auth_config_ids=["auth-1"], statuses=["ACTIVE"]
|
||||
)
|
||||
|
||||
def test_initiate_warns_and_creates_when_allow_multiple(
|
||||
self, connected_accounts, mock_client, caplog
|
||||
):
|
||||
mock_accounts = Mock()
|
||||
mock_accounts.items = [Mock(), Mock()]
|
||||
mock_client.connected_accounts.list.return_value = mock_accounts
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.id = "conn-123"
|
||||
mock_response.connection_data.val.status = "PENDING"
|
||||
mock_response.connection_data.val.redirect_url = "https://redirect"
|
||||
_set_initiate_response(mock_client, mock_response)
|
||||
|
||||
config = {
|
||||
"auth_scheme": "API_KEY",
|
||||
"val": {"key": "secret", "status": "ACTIVE"},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = connected_accounts.initiate(
|
||||
user_id="user-1",
|
||||
auth_config_id="auth-1",
|
||||
callback_url="https://cb",
|
||||
allow_multiple=True,
|
||||
config=config,
|
||||
)
|
||||
|
||||
mock_client.connected_accounts.list.assert_called_once_with(
|
||||
user_ids=["user-1"], auth_config_ids=["auth-1"], statuses=["ACTIVE"]
|
||||
)
|
||||
call_kwargs = (
|
||||
mock_client.connected_accounts.with_raw_response.create.call_args.kwargs
|
||||
)
|
||||
assert call_kwargs["auth_config"] == {"id": "auth-1"}
|
||||
assert call_kwargs["connection"]["user_id"] == "user-1"
|
||||
assert call_kwargs["connection"]["callback_url"] == "https://cb"
|
||||
assert call_kwargs["connection"]["state"] == config
|
||||
|
||||
assert isinstance(result, ConnectionRequest)
|
||||
assert result.id == "conn-123"
|
||||
assert result.status == "PENDING"
|
||||
assert result.redirect_url == "https://redirect"
|
||||
assert "[Warn:AllowMultiple] Multiple connected accounts found" in caplog.text
|
||||
|
||||
def test_link_builds_payload_and_returns_connection_request(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
# link() now mirrors initiate() and pre-flights list() to enforce the
|
||||
# allow_multiple guard; default to no existing connections here.
|
||||
no_accounts = Mock()
|
||||
no_accounts.items = []
|
||||
mock_client.connected_accounts.list.return_value = no_accounts
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.connected_account_id = "conn-999"
|
||||
mock_response.redirect_url = "https://redirect"
|
||||
mock_client.link.create.return_value = mock_response
|
||||
|
||||
result = connected_accounts.link(
|
||||
user_id="user-1",
|
||||
auth_config_id="auth-1",
|
||||
callback_url="https://cb",
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.link.create.call_args.kwargs
|
||||
assert call_kwargs["auth_config_id"] == "auth-1"
|
||||
assert call_kwargs["user_id"] == "user-1"
|
||||
assert call_kwargs["callback_url"] == "https://cb"
|
||||
|
||||
assert isinstance(result, ConnectionRequest)
|
||||
assert result.id == "conn-999"
|
||||
assert result.status == "INITIATED"
|
||||
assert result.redirect_url == "https://redirect"
|
||||
|
||||
def test_link_omits_callback_url_when_not_provided(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
no_accounts = Mock()
|
||||
no_accounts.items = []
|
||||
mock_client.connected_accounts.list.return_value = no_accounts
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.connected_account_id = "conn-000"
|
||||
mock_response.redirect_url = None
|
||||
mock_client.link.create.return_value = mock_response
|
||||
|
||||
connected_accounts.link(user_id="user-1", auth_config_id="auth-1")
|
||||
|
||||
call_kwargs = mock_client.link.create.call_args.kwargs
|
||||
assert call_kwargs["auth_config_id"] == "auth-1"
|
||||
assert call_kwargs["user_id"] == "user-1"
|
||||
assert call_kwargs["callback_url"] is omit
|
||||
|
||||
def test_link_raises_when_active_connection_exists_and_not_allow_multiple(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
"""link() guards against duplicate connections, mirroring initiate()."""
|
||||
existing = Mock()
|
||||
existing.items = [Mock()]
|
||||
mock_client.connected_accounts.list.return_value = existing
|
||||
|
||||
with pytest.raises(exceptions.ComposioMultipleConnectedAccountsError):
|
||||
connected_accounts.link(user_id="user-1", auth_config_id="auth-1")
|
||||
|
||||
mock_client.connected_accounts.list.assert_called_once_with(
|
||||
user_ids=["user-1"], auth_config_ids=["auth-1"], statuses=["ACTIVE"]
|
||||
)
|
||||
mock_client.link.create.assert_not_called()
|
||||
|
||||
def test_link_skips_guard_when_allow_multiple_is_true(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
"""allow_multiple=True bypasses the guard and proceeds with link.create."""
|
||||
existing = Mock()
|
||||
existing.items = [Mock()]
|
||||
mock_client.connected_accounts.list.return_value = existing
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.connected_account_id = "conn-new"
|
||||
mock_response.redirect_url = "https://redirect"
|
||||
mock_client.link.create.return_value = mock_response
|
||||
|
||||
result = connected_accounts.link(
|
||||
user_id="user-1",
|
||||
auth_config_id="auth-1",
|
||||
alias="work",
|
||||
allow_multiple=True,
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.link.create.call_args.kwargs
|
||||
assert call_kwargs["alias"] == "work"
|
||||
assert result.id == "conn-new"
|
||||
|
||||
def test_initiate_with_oauth2_tokens_returns_active_connection_request(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
mock_accounts = Mock()
|
||||
mock_accounts.items = []
|
||||
mock_client.connected_accounts.list.return_value = mock_accounts
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.id = "conn-active"
|
||||
mock_response.connection_data.val.status = "ACTIVE"
|
||||
mock_response.connection_data.val.redirect_url = None
|
||||
_set_initiate_response(mock_client, mock_response)
|
||||
|
||||
scheme = AuthScheme()
|
||||
config = scheme.oauth2(
|
||||
{"access_token": "tok", "refresh_token": "ref", "expires_in": 3600}
|
||||
)
|
||||
|
||||
result = connected_accounts.initiate(
|
||||
user_id="user-1", auth_config_id="auth-1", config=config
|
||||
)
|
||||
|
||||
assert isinstance(result, ConnectionRequest)
|
||||
assert result.id == "conn-active"
|
||||
assert result.status == "ACTIVE"
|
||||
assert result.redirect_url is None
|
||||
|
||||
def test_wait_for_connection_delegates_to_connection_request(self, mock_client):
|
||||
connected_accounts = ConnectedAccounts(client=mock_client)
|
||||
|
||||
with patch(
|
||||
"composio.core.models.connected_accounts.ConnectionRequest.from_id"
|
||||
) as mock_from_id:
|
||||
mock_request = Mock()
|
||||
mock_request.wait_for_connection.return_value = "connected"
|
||||
mock_from_id.return_value = mock_request
|
||||
|
||||
result = connected_accounts.wait_for_connection(id="conn-123", timeout=42.0)
|
||||
|
||||
mock_from_id.assert_called_once_with(id="conn-123", client=mock_client)
|
||||
mock_request.wait_for_connection.assert_called_once_with(timeout=42.0)
|
||||
assert result == "connected"
|
||||
|
||||
|
||||
def _make_bad_request_error(message: str):
|
||||
"""Build a BadRequestError instance for testing the error mapper.
|
||||
|
||||
The composio_client BadRequestError constructor signature is internal —
|
||||
rather than depend on it, we stub a minimal object that ``str(error)``
|
||||
surfaces the message and which is recognized as the BadRequestError type
|
||||
by ``isinstance``. This mirrors how production responses arrive: the
|
||||
error class with a message body.
|
||||
"""
|
||||
from composio_client import BadRequestError
|
||||
|
||||
error = BadRequestError.__new__(BadRequestError)
|
||||
Exception.__init__(error, message)
|
||||
return error
|
||||
|
||||
|
||||
class TestConnectedAccountsAcl:
|
||||
"""Tests for SHARED accounts surface on ``link()`` and
|
||||
``composio.experimental.update_acl()``."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
client = Mock()
|
||||
client.connected_accounts.retrieve = Mock()
|
||||
client.connected_accounts.list = Mock()
|
||||
client.connected_accounts.delete = Mock()
|
||||
client.connected_accounts.update_status = Mock()
|
||||
client.connected_accounts.refresh = Mock()
|
||||
client.connected_accounts.create = Mock()
|
||||
client.connected_accounts.patch = Mock()
|
||||
client.link.create = Mock()
|
||||
# Default: no existing connections, so link() doesn't trip the guard.
|
||||
no_accounts = Mock()
|
||||
no_accounts.items = []
|
||||
client.connected_accounts.list.return_value = no_accounts
|
||||
# Default link.create response — overridden per test where the
|
||||
# response shape matters.
|
||||
default_link = Mock()
|
||||
default_link.connected_account_id = "ca_test_shared"
|
||||
default_link.redirect_url = "https://redirect"
|
||||
client.link.create.return_value = default_link
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def connected_accounts(self, mock_client):
|
||||
return ConnectedAccounts(client=mock_client)
|
||||
|
||||
@pytest.fixture
|
||||
def experimental(self, mock_client):
|
||||
from composio.core.models.experimental import ExperimentalAPI
|
||||
|
||||
return ExperimentalAPI(client=mock_client)
|
||||
|
||||
# -- link() forwards the experimental block -----------------------------
|
||||
|
||||
def test_link_forwards_experimental_block(self, connected_accounts, mock_client):
|
||||
connected_accounts.link(
|
||||
user_id="user_creator",
|
||||
auth_config_id="auth_config_123",
|
||||
experimental={
|
||||
"account_type": "SHARED",
|
||||
"acl_config_for_shared": {
|
||||
"allow_all_users": True,
|
||||
"not_allowed_user_ids": ["user_bob"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.link.create.call_args.kwargs
|
||||
assert call_kwargs["experimental"] == {
|
||||
"account_type": "SHARED",
|
||||
"acl_config_for_shared": {
|
||||
"allow_all_users": True,
|
||||
"not_allowed_user_ids": ["user_bob"],
|
||||
},
|
||||
}
|
||||
|
||||
def test_link_omits_experimental_when_not_provided(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
connected_accounts.link(
|
||||
user_id="user_creator",
|
||||
auth_config_id="auth_config_123",
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.link.create.call_args.kwargs
|
||||
assert call_kwargs["experimental"] is omit
|
||||
|
||||
def test_link_preserves_explicit_empty_lists(self, connected_accounts, mock_client):
|
||||
"""An empty list is meaningful (clear the allow/deny list)."""
|
||||
connected_accounts.link(
|
||||
user_id="user_creator",
|
||||
auth_config_id="auth_config_123",
|
||||
experimental={
|
||||
"account_type": "SHARED",
|
||||
"acl_config_for_shared": {
|
||||
"allowed_user_ids": [],
|
||||
"not_allowed_user_ids": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.link.create.call_args.kwargs
|
||||
assert call_kwargs["experimental"]["acl_config_for_shared"] == {
|
||||
"allowed_user_ids": [],
|
||||
"not_allowed_user_ids": [],
|
||||
}
|
||||
|
||||
def test_link_maps_acl_only_for_shared_to_typed_error(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
mock_client.link.create.side_effect = _make_bad_request_error(
|
||||
"acl_config_for_shared is only valid on SHARED connections."
|
||||
)
|
||||
|
||||
with pytest.raises(exceptions.ComposioAclOnlyForSharedError):
|
||||
connected_accounts.link(
|
||||
user_id="user_creator",
|
||||
auth_config_id="auth_config_123",
|
||||
experimental={
|
||||
"account_type": "PRIVATE",
|
||||
"acl_config_for_shared": {"allow_all_users": True},
|
||||
},
|
||||
)
|
||||
|
||||
def test_link_rethrows_non_acl_bad_request_errors(
|
||||
self, connected_accounts, mock_client
|
||||
):
|
||||
unrelated = _make_bad_request_error("auth_config_id is required")
|
||||
mock_client.link.create.side_effect = unrelated
|
||||
|
||||
from composio_client import BadRequestError
|
||||
|
||||
with pytest.raises(BadRequestError):
|
||||
connected_accounts.link(
|
||||
user_id="user_creator", auth_config_id="auth_config_123"
|
||||
)
|
||||
|
||||
# -- experimental.update_acl() body construction + mapper ---------------
|
||||
|
||||
def test_update_acl_serializes_nested_body(self, experimental, mock_client):
|
||||
response = Mock()
|
||||
response.id = "ca_abc"
|
||||
response.status = "ACTIVE"
|
||||
response.success = True
|
||||
mock_client.connected_accounts.patch.return_value = response
|
||||
|
||||
result = experimental.update_acl(
|
||||
"ca_abc",
|
||||
allow_all_users=True,
|
||||
not_allowed_user_ids=["user_bob"],
|
||||
)
|
||||
|
||||
mock_client.connected_accounts.patch.assert_called_once_with(
|
||||
"ca_abc",
|
||||
experimental={
|
||||
"acl_config_for_shared": {
|
||||
"allow_all_users": True,
|
||||
"not_allowed_user_ids": ["user_bob"],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert result is response
|
||||
|
||||
def test_update_acl_omits_absent_fields(self, experimental, mock_client):
|
||||
experimental.update_acl("ca_abc", allowed_user_ids=["user_alice"])
|
||||
|
||||
mock_client.connected_accounts.patch.assert_called_once_with(
|
||||
"ca_abc",
|
||||
experimental={
|
||||
"acl_config_for_shared": {"allowed_user_ids": ["user_alice"]}
|
||||
},
|
||||
)
|
||||
|
||||
def test_update_acl_preserves_empty_array(self, experimental, mock_client):
|
||||
experimental.update_acl("ca_abc", allowed_user_ids=[])
|
||||
|
||||
mock_client.connected_accounts.patch.assert_called_once_with(
|
||||
"ca_abc",
|
||||
experimental={"acl_config_for_shared": {"allowed_user_ids": []}},
|
||||
)
|
||||
|
||||
def test_update_acl_rejects_all_none(self, experimental, mock_client):
|
||||
with pytest.raises(exceptions.ValidationError):
|
||||
experimental.update_acl("ca_abc")
|
||||
mock_client.connected_accounts.patch.assert_not_called()
|
||||
|
||||
def test_update_acl_maps_acl_only_for_shared_to_typed_error(
|
||||
self, experimental, mock_client
|
||||
):
|
||||
mock_client.connected_accounts.patch.side_effect = _make_bad_request_error(
|
||||
"acl_config_for_shared is only valid on SHARED connections."
|
||||
)
|
||||
|
||||
with pytest.raises(exceptions.ComposioAclOnlyForSharedError):
|
||||
experimental.update_acl("ca_abc", allow_all_users=True)
|
||||
|
||||
def test_update_acl_rethrows_non_acl_bad_request_errors(
|
||||
self, experimental, mock_client
|
||||
):
|
||||
unrelated = _make_bad_request_error("some other 400")
|
||||
mock_client.connected_accounts.patch.side_effect = unrelated
|
||||
|
||||
from composio_client import BadRequestError
|
||||
|
||||
with pytest.raises(BadRequestError):
|
||||
experimental.update_acl("ca_abc", allow_all_users=True)
|
||||
|
||||
def test_update_acl_requires_client(self):
|
||||
from composio.core.models.experimental import ExperimentalAPI
|
||||
|
||||
with pytest.raises(exceptions.ValidationError):
|
||||
ExperimentalAPI().update_acl("ca_abc", allow_all_users=True)
|
||||
|
||||
# -- list(account_type=...) — flat experimental filter -----------------
|
||||
|
||||
def test_list_forwards_account_type_filter(self, connected_accounts, mock_client):
|
||||
connected_accounts.list(account_type="SHARED", user_ids=["user_creator"])
|
||||
mock_client.connected_accounts.list.assert_called_once_with(
|
||||
account_type="SHARED", user_ids=["user_creator"]
|
||||
)
|
||||
|
||||
|
||||
# SEC-339: initiate() must gate its DeprecationWarning on the response
|
||||
# `Deprecation` HTTP header (RFC 9745) that apollo emits only on the
|
||||
# retiring branch (Composio-managed + redirectable OAuth). These tests pin
|
||||
# that contract so the previous false-positive behavior — warning purely
|
||||
# off auth_scheme, which over-fired for custom auth configs — can't come
|
||||
# back. See https://docs.composio.dev/docs/changelog/2026/04/24
|
||||
class TestInitiateDeprecationHeaderGate:
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
client = Mock()
|
||||
client.connected_accounts.list = Mock()
|
||||
return client
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warning_flag(self):
|
||||
"""Reset the module-level one-time warning guard before each test
|
||||
so warning emission is deterministic regardless of test order."""
|
||||
import composio.core.models.connected_accounts as ca_mod
|
||||
|
||||
ca_mod._legacy_initiate_warning_emitted = False
|
||||
yield
|
||||
ca_mod._legacy_initiate_warning_emitted = False
|
||||
|
||||
@staticmethod
|
||||
def _no_existing_accounts(mock_client):
|
||||
empty = Mock()
|
||||
empty.items = []
|
||||
mock_client.connected_accounts.list.return_value = empty
|
||||
|
||||
@staticmethod
|
||||
def _make_response():
|
||||
body = Mock()
|
||||
body.id = "conn-dep"
|
||||
body.connection_data.val.status = "INITIATED"
|
||||
body.connection_data.val.redirect_url = "https://redirect"
|
||||
return body
|
||||
|
||||
def test_warns_once_when_response_carries_deprecation_header(self, mock_client):
|
||||
"""Managed + redirectable-OAuth path: apollo sets `Deprecation`,
|
||||
SDK emits a `DeprecationWarning` pointing callers at link()."""
|
||||
self._no_existing_accounts(mock_client)
|
||||
body = self._make_response()
|
||||
_set_initiate_response(
|
||||
mock_client,
|
||||
body,
|
||||
headers={
|
||||
"Deprecation": "@1776988800",
|
||||
"Sunset": "Fri, 08 May 2026 00:00:00 GMT",
|
||||
"Link": (
|
||||
"<https://docs.composio.dev/docs/changelog/2026/04/24>; "
|
||||
'rel="deprecation"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
connected_accounts = ConnectedAccounts(client=mock_client)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
req = connected_accounts.initiate(user_id="user-1", auth_config_id="auth-1")
|
||||
|
||||
assert isinstance(req, ConnectionRequest)
|
||||
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert len(deprecations) == 1
|
||||
message = str(deprecations[0].message)
|
||||
assert "composio.connected_accounts.link()" in message
|
||||
assert "2026-07-03" in message
|
||||
|
||||
def test_does_not_warn_when_response_has_no_deprecation_header(self, mock_client):
|
||||
"""Custom auth config / non-OAuth scheme: apollo returns a clean
|
||||
response, SDK must stay silent. Regression for the prior
|
||||
auth_scheme-only check that over-fired here."""
|
||||
self._no_existing_accounts(mock_client)
|
||||
_set_initiate_response(mock_client, self._make_response(), headers={})
|
||||
|
||||
connected_accounts = ConnectedAccounts(client=mock_client)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
connected_accounts.initiate(user_id="user-1", auth_config_id="auth-1")
|
||||
|
||||
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert deprecations == []
|
||||
|
||||
def test_warns_at_most_once_per_process_across_calls(self, mock_client):
|
||||
"""The one-time guard must hold across multiple calls in the same
|
||||
process even when each response carries the Deprecation header."""
|
||||
self._no_existing_accounts(mock_client)
|
||||
# Same headers for both calls; helper rewires the same return on
|
||||
# each invocation, so both calls see the Deprecation header.
|
||||
_set_initiate_response(
|
||||
mock_client,
|
||||
self._make_response(),
|
||||
headers={"Deprecation": "@1776988800"},
|
||||
)
|
||||
|
||||
connected_accounts = ConnectedAccounts(client=mock_client)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
connected_accounts.initiate(user_id="user-1", auth_config_id="auth-1")
|
||||
empty = Mock()
|
||||
empty.items = []
|
||||
mock_client.connected_accounts.list.return_value = empty
|
||||
connected_accounts.initiate(user_id="user-1", auth_config_id="auth-1")
|
||||
|
||||
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert len(deprecations) == 1
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Test core types module."""
|
||||
|
||||
from composio.core.types import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
)
|
||||
|
||||
|
||||
class TestCoreTypes:
|
||||
"""Test cases for core types."""
|
||||
|
||||
def test_toolkit_latest_version_literal(self):
|
||||
"""Test ToolkitLatestVersion is a literal type."""
|
||||
# Should be a Literal["latest"]
|
||||
assert hasattr(ToolkitLatestVersion, "__origin__")
|
||||
|
||||
def test_toolkit_version_is_union(self):
|
||||
"""Test ToolkitVersion is a Union type."""
|
||||
# ToolkitVersion should be Union[Literal["latest"], str]
|
||||
import typing
|
||||
|
||||
assert hasattr(ToolkitVersion, "__origin__")
|
||||
assert ToolkitVersion.__origin__ is typing.Union
|
||||
|
||||
def test_toolkit_versions_is_dict(self):
|
||||
"""Test ToolkitVersions is dict type."""
|
||||
# Should be Dict[str, ToolkitVersion]
|
||||
assert hasattr(ToolkitVersions, "__origin__")
|
||||
assert ToolkitVersions.__origin__ is dict
|
||||
|
||||
def test_toolkit_version_param_is_union(self):
|
||||
"""Test ToolkitVersionParam is union type."""
|
||||
# Should be Union[str, ToolkitVersions, None]
|
||||
assert hasattr(ToolkitVersionParam, "__origin__")
|
||||
|
||||
def test_toolkit_version_param_accepts_string(self):
|
||||
"""Test ToolkitVersionParam accepts string."""
|
||||
version: ToolkitVersionParam = "latest"
|
||||
assert version == "latest"
|
||||
|
||||
version = "v1.0.0"
|
||||
assert version == "v1.0.0"
|
||||
|
||||
def test_toolkit_version_param_accepts_dict(self):
|
||||
"""Test ToolkitVersionParam accepts dictionary."""
|
||||
version: ToolkitVersionParam = {"github": "v1.0.0", "slack": "latest"}
|
||||
assert isinstance(version, dict)
|
||||
assert version["github"] == "v1.0.0"
|
||||
assert version["slack"] == "latest"
|
||||
|
||||
def test_toolkit_version_param_accepts_none(self):
|
||||
"""Test ToolkitVersionParam accepts None."""
|
||||
version: ToolkitVersionParam = None
|
||||
assert version is None
|
||||
|
||||
def test_all_types_exported(self):
|
||||
"""Test that all types are properly exported."""
|
||||
from composio.core.types import __all__
|
||||
|
||||
expected_exports = {
|
||||
"ToolkitLatestVersion",
|
||||
"ToolkitVersion",
|
||||
"ToolkitVersions",
|
||||
"ToolkitVersionParam",
|
||||
}
|
||||
|
||||
assert set(__all__) == expected_exports
|
||||
|
||||
def test_types_importable_from_main_package(self):
|
||||
"""Test that types are importable from main package."""
|
||||
from composio import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
)
|
||||
|
||||
# Should be the same objects
|
||||
from composio.core.types import (
|
||||
ToolkitLatestVersion as CoreToolkitLatestVersion,
|
||||
)
|
||||
from composio.core.types import (
|
||||
ToolkitVersion as CoreToolkitVersion,
|
||||
)
|
||||
from composio.core.types import (
|
||||
ToolkitVersionParam as CoreToolkitVersionParam,
|
||||
)
|
||||
from composio.core.types import (
|
||||
ToolkitVersions as CoreToolkitVersions,
|
||||
)
|
||||
|
||||
assert ToolkitLatestVersion is CoreToolkitLatestVersion
|
||||
assert ToolkitVersion is CoreToolkitVersion
|
||||
assert ToolkitVersionParam is CoreToolkitVersionParam
|
||||
assert ToolkitVersions is CoreToolkitVersions
|
||||
|
||||
def test_types_importable_from_types_module(self):
|
||||
"""Test that types are importable from types module."""
|
||||
# Should be the same objects as core types
|
||||
from composio.core.types import (
|
||||
ToolkitLatestVersion as CoreToolkitLatestVersion,
|
||||
)
|
||||
from composio.core.types import (
|
||||
ToolkitVersion as CoreToolkitVersion,
|
||||
)
|
||||
from composio.core.types import (
|
||||
ToolkitVersionParam as CoreToolkitVersionParam,
|
||||
)
|
||||
from composio.core.types import (
|
||||
ToolkitVersions as CoreToolkitVersions,
|
||||
)
|
||||
from composio.types import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
)
|
||||
|
||||
assert ToolkitLatestVersion is CoreToolkitLatestVersion
|
||||
assert ToolkitVersion is CoreToolkitVersion
|
||||
assert ToolkitVersionParam is CoreToolkitVersionParam
|
||||
assert ToolkitVersions is CoreToolkitVersions
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Cross-SDK compatibility tests.
|
||||
|
||||
These tests verify that the Python SDK uses identical fixtures
|
||||
to the TypeScript SDK, ensuring webhook verification works consistently
|
||||
across both SDKs.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import get_py_fixtures_dir, get_ts_fixtures_dir
|
||||
|
||||
|
||||
class TestCrossSDKFixtureCompatibility:
|
||||
"""Tests verifying Python and TypeScript fixtures are synchronized."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture_name",
|
||||
[
|
||||
"golden-signatures.json",
|
||||
"v1-github-push.json",
|
||||
"v2-github-push.json",
|
||||
"v3-github-push.json",
|
||||
],
|
||||
)
|
||||
def test_fixtures_are_identical(self, fixture_name: str) -> None:
|
||||
"""Verify TypeScript and Python fixtures are byte-identical."""
|
||||
ts_path = get_ts_fixtures_dir() / fixture_name
|
||||
py_path = get_py_fixtures_dir() / fixture_name
|
||||
|
||||
with open(ts_path) as f:
|
||||
ts_content = json.load(f)
|
||||
with open(py_path) as f:
|
||||
py_content = json.load(f)
|
||||
|
||||
assert ts_content == py_content, (
|
||||
f"Fixture {fixture_name} differs between TypeScript and Python SDKs. "
|
||||
"Ensure fixtures are kept in sync."
|
||||
)
|
||||
@@ -0,0 +1,904 @@
|
||||
"""Tests for custom tools in tool router sessions.
|
||||
|
||||
Covers:
|
||||
- Decorator API (inference, overrides, validation)
|
||||
- Toolkit builder with @toolkit.tool() decorator
|
||||
- Slug prefixing and collision detection
|
||||
- Serialization to API format
|
||||
- Custom tool execution (validation, defaults, errors)
|
||||
- Routing map building and lookup
|
||||
- SessionContextImpl sibling routing
|
||||
- ToolRouterSession integration (execute routing, custom_tools(), custom_toolkits())
|
||||
- Multi-execute routing (all-local, all-remote, mixed, parallel)
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from composio_client import omit
|
||||
from composio_client.types.tool_router.session_execute_response import (
|
||||
SessionExecuteResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session_proxy_execute_response import (
|
||||
SessionProxyExecuteResponse,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from composio.core.models.custom_tool import (
|
||||
ExperimentalToolkit,
|
||||
build_custom_tools_map,
|
||||
build_custom_tools_map_from_response,
|
||||
serialize_custom_toolkits,
|
||||
serialize_custom_tools,
|
||||
)
|
||||
from composio.core.models.custom_tool_execution import (
|
||||
execute_custom_tool,
|
||||
find_custom_tool,
|
||||
)
|
||||
from composio.core.models.experimental import ExperimentalAPI
|
||||
from composio.core.models.session_context import SessionContextImpl
|
||||
from composio.core.models.tool_router_session import ToolRouterSession
|
||||
from composio.exceptions import ValidationError
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
exp = ExperimentalAPI()
|
||||
|
||||
|
||||
class GrepInput(BaseModel):
|
||||
pattern: str = Field(description="Pattern to search for")
|
||||
path: str = Field(default=".", description="File path")
|
||||
|
||||
|
||||
class EmailInput(BaseModel):
|
||||
to: str = Field(description="Recipient email")
|
||||
subject: str = Field(description="Subject")
|
||||
|
||||
|
||||
class SetRoleInput(BaseModel):
|
||||
user_id: str = Field(description="User ID")
|
||||
role: str = Field(default="viewer", description="New role")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grep_tool():
|
||||
@exp.tool()
|
||||
def grep(input: GrepInput, ctx):
|
||||
"""Search for patterns in files."""
|
||||
return {"matches": [input.pattern], "path": input.path}
|
||||
|
||||
return grep
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_tool():
|
||||
@exp.tool(extends_toolkit="gmail")
|
||||
def get_emails(input: EmailInput, ctx):
|
||||
"""Get emails from Gmail."""
|
||||
return {"emails": []}
|
||||
|
||||
return get_emails
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def set_role_tool():
|
||||
@exp.tool()
|
||||
def set_role(input: SetRoleInput, ctx):
|
||||
"""Set a user's role."""
|
||||
return {"user_id": input.user_id, "role": input.role, "updated": True}
|
||||
|
||||
return set_role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def role_toolkit(set_role_tool):
|
||||
tk = ExperimentalToolkit(
|
||||
slug="ROLE_MANAGER", name="Role Manager", description="Manage user roles"
|
||||
)
|
||||
tk._tools.append(set_role_tool)
|
||||
return tk
|
||||
|
||||
|
||||
class MockSessionContext:
|
||||
user_id = "test-user"
|
||||
|
||||
def execute(self, tool_slug, arguments):
|
||||
return {"data": {}, "error": None, "successful": True}
|
||||
|
||||
def proxy_execute(self, **kwargs):
|
||||
return {"status": 200, "data": {}, "headers": {}}
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Decorator API tests
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDecoratorTool:
|
||||
def test_bare_decorator(self):
|
||||
@exp.tool
|
||||
def my_tool(input: GrepInput, ctx):
|
||||
"""My tool description."""
|
||||
return {}
|
||||
|
||||
assert my_tool.slug == "MY_TOOL"
|
||||
assert my_tool.name == "My Tool"
|
||||
assert my_tool.description == "My tool description."
|
||||
assert my_tool.input_schema["type"] == "object"
|
||||
assert "pattern" in my_tool.input_schema["properties"]
|
||||
|
||||
def test_decorator_with_parens(self):
|
||||
@exp.tool()
|
||||
def search(input: GrepInput, ctx):
|
||||
"""Search stuff."""
|
||||
return {}
|
||||
|
||||
assert search.slug == "SEARCH"
|
||||
|
||||
def test_decorator_with_extends_toolkit(self):
|
||||
@exp.tool(extends_toolkit="gmail")
|
||||
def fetch_mail(input: EmailInput, ctx):
|
||||
"""Fetch mail."""
|
||||
return {}
|
||||
|
||||
assert fetch_mail.extends_toolkit == "gmail"
|
||||
|
||||
def test_decorator_with_preload(self):
|
||||
@exp.tool(preload=True)
|
||||
def fetch_context(input: GrepInput, ctx):
|
||||
"""Fetch context."""
|
||||
return {}
|
||||
|
||||
assert fetch_context.preload is True
|
||||
|
||||
def test_decorator_explicit_overrides(self):
|
||||
@exp.tool(slug="CUSTOM", name="Custom Name", description="Custom desc")
|
||||
def whatever(input: GrepInput, ctx):
|
||||
"""Original docstring."""
|
||||
return {}
|
||||
|
||||
assert whatever.slug == "CUSTOM"
|
||||
assert whatever.name == "Custom Name"
|
||||
assert whatever.description == "Custom desc"
|
||||
|
||||
def test_infers_from_function_name(self):
|
||||
@exp.tool()
|
||||
def search_user_by_email(input: GrepInput, ctx):
|
||||
"""Find a user."""
|
||||
return {}
|
||||
|
||||
assert search_user_by_email.slug == "SEARCH_USER_BY_EMAIL"
|
||||
assert search_user_by_email.name == "Search User By Email"
|
||||
|
||||
def test_infers_description_from_docstring(self):
|
||||
@exp.tool()
|
||||
def my_func(input: GrepInput, ctx):
|
||||
"""Trimmed docstring."""
|
||||
return {}
|
||||
|
||||
assert my_func.description == "Trimmed docstring."
|
||||
|
||||
def test_multiline_docstring(self):
|
||||
@exp.tool()
|
||||
def multi(input: GrepInput, ctx):
|
||||
"""Search for users by email.
|
||||
|
||||
This tool searches the internal database for users
|
||||
matching the given pattern. Returns a list of matches.
|
||||
|
||||
Supports wildcards.
|
||||
"""
|
||||
return {}
|
||||
|
||||
assert multi.description.startswith("Search for users by email.")
|
||||
# inspect.cleandoc strips indentation
|
||||
assert "\n " not in multi.description
|
||||
assert "Supports wildcards." in multi.description
|
||||
|
||||
def test_indented_docstring_cleaned(self):
|
||||
@exp.tool()
|
||||
def indented(input: GrepInput, ctx):
|
||||
"""
|
||||
Leading and trailing whitespace removed.
|
||||
Indentation normalized.
|
||||
"""
|
||||
return {}
|
||||
|
||||
assert indented.description == (
|
||||
"Leading and trailing whitespace removed.\nIndentation normalized."
|
||||
)
|
||||
|
||||
def test_missing_docstring_raises(self):
|
||||
with pytest.raises(ValidationError, match="description is required"):
|
||||
|
||||
@exp.tool()
|
||||
def no_doc(input: GrepInput, ctx):
|
||||
return {}
|
||||
|
||||
def test_missing_basemodel_annotation_raises(self):
|
||||
with pytest.raises(ValidationError, match="BaseModel subclass"):
|
||||
|
||||
@exp.tool()
|
||||
def bad(x: str, ctx):
|
||||
"""Bad tool."""
|
||||
return {}
|
||||
|
||||
def test_no_params_rejected(self):
|
||||
with pytest.raises(ValidationError, match="at least one parameter"):
|
||||
|
||||
@exp.tool()
|
||||
def no_params():
|
||||
"""No params."""
|
||||
return {}
|
||||
|
||||
def test_too_many_params_rejected(self):
|
||||
with pytest.raises(ValidationError, match="at most 2"):
|
||||
|
||||
@exp.tool()
|
||||
def three_params(input: GrepInput, ctx, extra):
|
||||
"""Three params."""
|
||||
return {}
|
||||
|
||||
def test_first_param_must_be_basemodel(self):
|
||||
with pytest.raises(ValidationError, match="first parameter"):
|
||||
|
||||
@exp.tool()
|
||||
def swapped(ctx, input: GrepInput):
|
||||
"""Swapped order — ctx first, input second."""
|
||||
return {}
|
||||
|
||||
def test_future_annotations_are_resolved(self):
|
||||
namespace = {"exp": exp}
|
||||
exec(
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FutureInput(BaseModel):
|
||||
pattern: str = Field(description="Pattern to search for")
|
||||
|
||||
|
||||
@exp.tool()
|
||||
def future_tool(input: FutureInput, ctx):
|
||||
'''Resolved from postponed annotations.'''
|
||||
return {}
|
||||
""",
|
||||
namespace,
|
||||
)
|
||||
|
||||
future_tool = namespace["future_tool"]
|
||||
future_input = namespace["FutureInput"]
|
||||
|
||||
assert future_tool.input_params is future_input
|
||||
|
||||
def test_local_string_annotations_are_resolved(self):
|
||||
def build_tool():
|
||||
class LocalInput(BaseModel):
|
||||
pattern: str = Field(description="Pattern to search for")
|
||||
|
||||
@exp.tool
|
||||
def nested_search(input: "LocalInput", ctx):
|
||||
"""Resolved from local string annotations."""
|
||||
return {}
|
||||
|
||||
return nested_search, LocalInput
|
||||
|
||||
local_tool, local_input = build_tool()
|
||||
assert local_tool.input_params is local_input
|
||||
|
||||
def test_async_function_rejected(self):
|
||||
with pytest.raises(ValidationError, match="async"):
|
||||
|
||||
@exp.tool()
|
||||
async def async_tool(input: GrepInput, ctx):
|
||||
"""Async tool."""
|
||||
return {}
|
||||
|
||||
def test_async_single_param_rejected(self):
|
||||
"""Async must be caught even when wrapper hides it (single-param path)."""
|
||||
with pytest.raises(ValidationError, match="async"):
|
||||
|
||||
@exp.tool()
|
||||
async def async_single(input: GrepInput):
|
||||
"""Async single param."""
|
||||
return {}
|
||||
|
||||
def test_root_model_rejected(self):
|
||||
from pydantic import RootModel
|
||||
|
||||
class ListInput(RootModel[list[int]]):
|
||||
pass
|
||||
|
||||
with pytest.raises(ValidationError, match="RootModel"):
|
||||
|
||||
@exp.tool()
|
||||
def bad(input: ListInput, ctx):
|
||||
"""Bad tool."""
|
||||
return {}
|
||||
|
||||
def test_single_param_function(self):
|
||||
"""Function with only input param (no ctx) should work."""
|
||||
|
||||
@exp.tool()
|
||||
def simple(input: GrepInput):
|
||||
"""Simple tool."""
|
||||
return {"pattern": input.pattern}
|
||||
|
||||
m = build_custom_tools_map([simple])
|
||||
entry = find_custom_tool(m, "SIMPLE")
|
||||
result = execute_custom_tool(entry, {"pattern": "test"}, MockSessionContext()) # type: ignore
|
||||
assert result["successful"] is True
|
||||
assert result["data"]["pattern"] == "test"
|
||||
|
||||
def test_slug_validation(self):
|
||||
with pytest.raises(ValidationError, match="LOCAL_"):
|
||||
|
||||
@exp.tool(slug="LOCAL_BAD")
|
||||
def bad(input: GrepInput, ctx):
|
||||
"""Bad tool."""
|
||||
return {}
|
||||
|
||||
def test_creates_frozen_custom_tool(self):
|
||||
@exp.tool()
|
||||
def frozen(input: GrepInput, ctx):
|
||||
"""Frozen tool."""
|
||||
return {}
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
frozen.slug = "NEW" # type: ignore
|
||||
|
||||
def test_input_schema_includes_defaults(self, grep_tool):
|
||||
assert "pattern" in grep_tool.input_schema.get("required", [])
|
||||
assert "path" not in grep_tool.input_schema.get("required", [])
|
||||
|
||||
|
||||
class TestToolkitBuilder:
|
||||
def test_toolkit_with_decorator(self):
|
||||
tk = ExperimentalToolkit(slug="MY_TK", name="My TK", description="Desc")
|
||||
|
||||
@tk.tool()
|
||||
def tool_a(input: GrepInput, ctx):
|
||||
"""Tool A."""
|
||||
return {}
|
||||
|
||||
@tk.tool()
|
||||
def tool_b(input: GrepInput, ctx):
|
||||
"""Tool B."""
|
||||
return {}
|
||||
|
||||
assert tk.slug == "MY_TK"
|
||||
assert len(tk.tools) == 2
|
||||
assert tk.tools[0].slug == "TOOL_A"
|
||||
assert tk.tools[1].slug == "TOOL_B"
|
||||
|
||||
def test_toolkit_bare_decorator(self):
|
||||
tk = ExperimentalToolkit(slug="TK2", name="TK2", description="Desc")
|
||||
|
||||
@tk.tool
|
||||
def tool_c(input: GrepInput, ctx):
|
||||
"""Tool C."""
|
||||
return {}
|
||||
|
||||
assert len(tk.tools) == 1
|
||||
|
||||
def test_toolkit_slug_validation(self):
|
||||
with pytest.raises(ValidationError, match="LOCAL_"):
|
||||
ExperimentalToolkit(slug="LOCAL_BAD", name="Bad", description="Desc")
|
||||
|
||||
def test_toolkit_name_required(self):
|
||||
with pytest.raises(ValidationError, match="name is required"):
|
||||
ExperimentalToolkit(slug="TK", name="", description="Desc")
|
||||
|
||||
def test_toolkit_via_experimental_api(self):
|
||||
tk = exp.Toolkit(slug="API_TK", name="API TK", description="Via API")
|
||||
assert isinstance(tk, ExperimentalToolkit)
|
||||
assert tk.slug == "API_TK"
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Serialization tests
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSerialization:
|
||||
def test_serialize_standalone_tool(self, grep_tool):
|
||||
result = serialize_custom_tools([grep_tool])
|
||||
assert len(result) == 1
|
||||
assert result[0]["slug"] == "GREP"
|
||||
assert result[0]["input_schema"]["type"] == "object"
|
||||
assert "extends_toolkit" not in result[0]
|
||||
|
||||
def test_serialize_extension_tool(self, email_tool):
|
||||
result = serialize_custom_tools([email_tool])
|
||||
assert result[0]["extends_toolkit"] == "gmail"
|
||||
|
||||
def test_serialize_custom_tool_preload_hint(self, grep_tool):
|
||||
preloaded = replace(grep_tool, preload=True)
|
||||
result = serialize_custom_tools([preloaded])
|
||||
assert result[0]["preload"] is True
|
||||
|
||||
def test_serialize_custom_tool_omits_redundant_preload_false(self, grep_tool):
|
||||
search_only = replace(grep_tool, preload=False)
|
||||
result = serialize_custom_tools([search_only])
|
||||
assert "preload" not in result[0]
|
||||
|
||||
def test_serialize_toolkit(self, role_toolkit):
|
||||
result = serialize_custom_toolkits([role_toolkit])
|
||||
assert len(result) == 1
|
||||
assert result[0]["slug"] == "ROLE_MANAGER"
|
||||
assert len(result[0]["tools"]) == 1
|
||||
|
||||
def test_serialize_toolkit_preload_hint(self, role_toolkit):
|
||||
role_toolkit.preload = True
|
||||
result = serialize_custom_toolkits([role_toolkit])
|
||||
assert result[0]["preload"] is True
|
||||
assert result[0]["tools"][0]["preload"] is True
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Routing map tests
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCustomToolsMap:
|
||||
def test_build_map_standalone(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
assert "LOCAL_GREP" in m.by_final_slug
|
||||
|
||||
def test_build_map_extension(self, email_tool):
|
||||
m = build_custom_tools_map([email_tool])
|
||||
assert "LOCAL_GMAIL_GET_EMAILS" in m.by_final_slug
|
||||
|
||||
def test_build_map_toolkit(self, role_toolkit):
|
||||
m = build_custom_tools_map([], [role_toolkit])
|
||||
assert "LOCAL_ROLE_MANAGER_SET_ROLE" in m.by_final_slug
|
||||
|
||||
def test_collision_detection(self, grep_tool):
|
||||
with pytest.raises(ValidationError, match="collision"):
|
||||
build_custom_tools_map([grep_tool, grep_tool])
|
||||
|
||||
|
||||
class TestFindCustomTool:
|
||||
def test_find_by_final_slug(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
assert find_custom_tool(m, "LOCAL_GREP") is not None
|
||||
|
||||
def test_find_case_insensitive(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
assert find_custom_tool(m, "grep") is not None
|
||||
|
||||
def test_find_nonexistent(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
assert find_custom_tool(m, "NONEXISTENT") is None
|
||||
|
||||
def test_find_none_map(self):
|
||||
assert find_custom_tool(None, "GREP") is None
|
||||
|
||||
|
||||
class TestBuildMapFromResponse:
|
||||
def test_builds_from_response(self, grep_tool, email_tool, role_toolkit):
|
||||
mock_exp = MagicMock()
|
||||
mock_ct1 = MagicMock(
|
||||
slug="LOCAL_GREP", original_slug="GREP", extends_toolkit=None
|
||||
)
|
||||
mock_ct2 = MagicMock(
|
||||
slug="LOCAL_GMAIL_GET_EMAILS",
|
||||
original_slug="GET_EMAILS",
|
||||
extends_toolkit="gmail",
|
||||
)
|
||||
mock_exp.custom_tools = [mock_ct1, mock_ct2]
|
||||
mock_ctk = MagicMock(slug="ROLE_MANAGER")
|
||||
mock_ctk.tools = [
|
||||
MagicMock(slug="LOCAL_ROLE_MANAGER_SET_ROLE", original_slug="SET_ROLE")
|
||||
]
|
||||
mock_exp.custom_toolkits = [mock_ctk]
|
||||
|
||||
m = build_custom_tools_map_from_response(
|
||||
[grep_tool, email_tool], [role_toolkit], mock_exp
|
||||
)
|
||||
assert "LOCAL_GREP" in m.by_final_slug
|
||||
assert "LOCAL_GMAIL_GET_EMAILS" in m.by_final_slug
|
||||
assert "LOCAL_ROLE_MANAGER_SET_ROLE" in m.by_final_slug
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Execution tests
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExecuteCustomTool:
|
||||
def test_successful_execution(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
entry = find_custom_tool(m, "GREP")
|
||||
result = execute_custom_tool(entry, {"pattern": "hello"}, MockSessionContext()) # type: ignore
|
||||
assert result["successful"] is True
|
||||
assert result["data"]["matches"] == ["hello"]
|
||||
|
||||
def test_validation_failure(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
entry = find_custom_tool(m, "GREP")
|
||||
result = execute_custom_tool(entry, {}, MockSessionContext()) # type: ignore
|
||||
assert result["successful"] is False
|
||||
|
||||
def test_execute_error(self):
|
||||
@exp.tool()
|
||||
def bad(input: GrepInput, ctx):
|
||||
"""Bad."""
|
||||
raise RuntimeError("boom")
|
||||
|
||||
m = build_custom_tools_map([bad])
|
||||
entry = find_custom_tool(m, "BAD")
|
||||
result = execute_custom_tool(entry, {"pattern": "x"}, MockSessionContext()) # type: ignore
|
||||
assert result["successful"] is False
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# SessionContextImpl tests
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSessionContextImpl:
|
||||
def test_sibling_routing(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
ctx = SessionContextImpl(
|
||||
client=MagicMock(), user_id="u", session_id="s", custom_tools_map=m
|
||||
)
|
||||
result = ctx.execute("GREP", {"pattern": "test"})
|
||||
assert isinstance(result, SessionExecuteResponse)
|
||||
assert result.error is None
|
||||
assert result.log_id == ""
|
||||
assert result.data["matches"] == ["test"]
|
||||
|
||||
def test_remote_fallback(self, grep_tool):
|
||||
m = build_custom_tools_map([grep_tool])
|
||||
mock_client = MagicMock()
|
||||
mock_client.tool_router.session.execute.return_value = SessionExecuteResponse(
|
||||
data={"remote": True}, error=None, log_id="log_123"
|
||||
)
|
||||
ctx = SessionContextImpl(
|
||||
client=mock_client, user_id="u", session_id="s", custom_tools_map=m
|
||||
)
|
||||
ctx.execute("NONEXISTENT", {"arg": "val"})
|
||||
mock_client.tool_router.session.execute.assert_called_once_with(
|
||||
session_id="s",
|
||||
tool_slug="NONEXISTENT",
|
||||
arguments={"arg": "val"},
|
||||
experimental=omit,
|
||||
)
|
||||
|
||||
def test_remote_fallback_passes_inline_custom_tools(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.tool_router.session.execute.return_value = SessionExecuteResponse(
|
||||
data={"remote": True}, error=None, log_id="log_123"
|
||||
)
|
||||
inline_payload = {
|
||||
"custom_tools": [
|
||||
{
|
||||
"slug": "GREP",
|
||||
"name": "Grep",
|
||||
"description": "Search local text",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
}
|
||||
ctx = SessionContextImpl(
|
||||
client=mock_client,
|
||||
user_id="u",
|
||||
session_id="s",
|
||||
inline_custom_tools_payload=inline_payload,
|
||||
)
|
||||
ctx.execute("GMAIL_SEND_EMAIL", {"to": "a@b.com"})
|
||||
mock_client.tool_router.session.execute.assert_called_once_with(
|
||||
session_id="s",
|
||||
tool_slug="GMAIL_SEND_EMAIL",
|
||||
arguments={"to": "a@b.com"},
|
||||
experimental=inline_payload,
|
||||
)
|
||||
|
||||
def test_proxy_execute(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.tool_router.session.proxy_execute.return_value = (
|
||||
SessionProxyExecuteResponse(
|
||||
status=200, data={"ok": True}, headers={}, binary_data=None
|
||||
)
|
||||
)
|
||||
ctx = SessionContextImpl(client=mock_client, user_id="u", session_id="s")
|
||||
result = ctx.proxy_execute(
|
||||
toolkit="gmail", endpoint="https://example.com", method="GET"
|
||||
)
|
||||
assert isinstance(result, SessionProxyExecuteResponse)
|
||||
assert result.status == 200
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# ToolRouterSession integration
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_deps(grep_tool, email_tool, role_toolkit):
|
||||
return {
|
||||
"client": MagicMock(),
|
||||
"provider": MagicMock(),
|
||||
"experimental": MagicMock(),
|
||||
"tools_map": build_custom_tools_map([grep_tool, email_tool], [role_toolkit]),
|
||||
}
|
||||
|
||||
|
||||
def _session(deps, **overrides):
|
||||
kwargs = dict(
|
||||
client=deps["client"],
|
||||
provider=deps["provider"],
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
session_id="s",
|
||||
mcp=MagicMock(),
|
||||
experimental=deps["experimental"],
|
||||
custom_tools_map=deps["tools_map"],
|
||||
user_id="u",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return ToolRouterSession(**kwargs)
|
||||
|
||||
|
||||
class TestToolRouterSessionCustomTools:
|
||||
def test_execute_local(self, mock_session_deps):
|
||||
s = _session(mock_session_deps)
|
||||
result = s.execute("GREP", arguments={"pattern": "x"})
|
||||
# Returns SessionExecuteResponse — same type as remote
|
||||
assert isinstance(result, SessionExecuteResponse)
|
||||
assert result.error is None
|
||||
assert result.log_id == ""
|
||||
assert result.data["matches"] == ["x"]
|
||||
mock_session_deps["client"].tool_router.session.execute.assert_not_called()
|
||||
|
||||
def test_execute_remote(self, mock_session_deps):
|
||||
mock_response = SessionExecuteResponse(
|
||||
data={"sent": True}, error=None, log_id="log_123"
|
||||
)
|
||||
mock_session_deps[
|
||||
"client"
|
||||
].tool_router.session.execute.return_value = mock_response
|
||||
s = _session(mock_session_deps)
|
||||
result = s.execute("GMAIL_SEND_EMAIL", arguments={"to": "a@b.com"})
|
||||
mock_session_deps["client"].tool_router.session.execute.assert_called_once()
|
||||
call_args = mock_session_deps["client"].tool_router.session.execute.call_args
|
||||
assert call_args.kwargs["session_id"] == "s"
|
||||
assert call_args.kwargs["tool_slug"] == "GMAIL_SEND_EMAIL"
|
||||
assert call_args.kwargs["arguments"] == {"to": "a@b.com"}
|
||||
assert "extra_body" not in call_args.kwargs
|
||||
assert "enable_auto_workbench_offload" not in call_args.kwargs
|
||||
# Remote returns client model as-is (backward compat, supports attribute access)
|
||||
assert isinstance(result, SessionExecuteResponse)
|
||||
assert result.data == {"sent": True}
|
||||
assert result.log_id == "log_123"
|
||||
|
||||
def test_execute_remote_passes_inline_custom_tools(self, mock_session_deps):
|
||||
mock_response = SessionExecuteResponse(
|
||||
data={"sent": True}, error=None, log_id="log_123"
|
||||
)
|
||||
mock_session_deps[
|
||||
"client"
|
||||
].tool_router.session.execute.return_value = mock_response
|
||||
inline_payload = {
|
||||
"custom_tools": [
|
||||
{
|
||||
"slug": "GREP",
|
||||
"name": "Grep",
|
||||
"description": "Search local text",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
}
|
||||
s = _session(mock_session_deps, inline_custom_tools_payload=inline_payload)
|
||||
s.execute("GMAIL_SEND_EMAIL", arguments={"to": "a@b.com"})
|
||||
|
||||
call_args = mock_session_deps["client"].tool_router.session.execute.call_args
|
||||
assert call_args.kwargs["experimental"] == inline_payload
|
||||
|
||||
def test_proxy_execute(self, mock_session_deps):
|
||||
mock_session_deps[
|
||||
"client"
|
||||
].tool_router.session.proxy_execute.return_value = SessionProxyExecuteResponse(
|
||||
status=200, data={"ok": True}, headers={}, binary_data=None
|
||||
)
|
||||
s = _session(mock_session_deps)
|
||||
result = s.proxy_execute(
|
||||
toolkit="gmail", endpoint="https://example.com", method="GET"
|
||||
)
|
||||
assert isinstance(result, SessionProxyExecuteResponse)
|
||||
assert result.status == 200
|
||||
|
||||
def test_custom_tools_list(self, mock_session_deps):
|
||||
s = _session(mock_session_deps)
|
||||
assert len(s.custom_tools()) == 3
|
||||
|
||||
def test_custom_tools_filter(self, mock_session_deps):
|
||||
s = _session(mock_session_deps)
|
||||
assert len(s.custom_tools(toolkit="gmail")) == 1
|
||||
|
||||
def test_custom_toolkits_list(self, mock_session_deps):
|
||||
s = _session(mock_session_deps)
|
||||
tks = s.custom_toolkits()
|
||||
assert len(tks) == 1
|
||||
assert tks[0].slug == "ROLE_MANAGER"
|
||||
|
||||
def test_empty_when_no_map(self):
|
||||
s = ToolRouterSession(
|
||||
client=MagicMock(),
|
||||
provider=MagicMock(),
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
session_id="s",
|
||||
mcp=MagicMock(),
|
||||
experimental=MagicMock(),
|
||||
)
|
||||
assert s.custom_tools() == []
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Multi-execute routing
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMultiExecuteRouting:
|
||||
def _make_session(self, *tools, inline_custom_tools_payload=None):
|
||||
m = build_custom_tools_map(list(tools))
|
||||
return ToolRouterSession(
|
||||
client=MagicMock(),
|
||||
provider=MagicMock(),
|
||||
dangerously_allow_auto_upload_download_files=True,
|
||||
session_id="s",
|
||||
mcp=MagicMock(),
|
||||
experimental=MagicMock(),
|
||||
custom_tools_map=m,
|
||||
user_id="u",
|
||||
inline_custom_tools_payload=inline_custom_tools_payload,
|
||||
)
|
||||
|
||||
def test_single_local(self, grep_tool):
|
||||
s = self._make_session(grep_tool)
|
||||
result = s._route_multi_execute(
|
||||
{"tools": [{"tool_slug": "GREP", "arguments": {"pattern": "x"}}]},
|
||||
MagicMock(),
|
||||
)
|
||||
assert result["successful"] is True
|
||||
assert result["data"]["matches"] == ["x"]
|
||||
|
||||
def test_all_remote(self, grep_tool):
|
||||
s = self._make_session(grep_tool)
|
||||
tm = MagicMock()
|
||||
remote = {"data": {"results": []}, "error": None, "successful": True}
|
||||
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
||||
result = s._route_multi_execute(
|
||||
{"tools": [{"tool_slug": "REMOTE", "arguments": {}}]}, tm
|
||||
)
|
||||
assert result == remote
|
||||
|
||||
def test_all_remote_passes_inline_custom_tools(self, grep_tool):
|
||||
inline_payload = {
|
||||
"custom_tools": [
|
||||
{
|
||||
"slug": "GREP",
|
||||
"name": "Grep",
|
||||
"description": "Search local text",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
}
|
||||
s = self._make_session(grep_tool, inline_custom_tools_payload=inline_payload)
|
||||
tm = MagicMock()
|
||||
remote = {"data": {"results": []}, "error": None, "successful": True}
|
||||
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
||||
s._route_multi_execute(
|
||||
{"tools": [{"tool_slug": "REMOTE", "arguments": {}}]}, tm
|
||||
)
|
||||
|
||||
tm._wrap_execute_tool_for_tool_router.assert_called_once_with(
|
||||
session_id="s",
|
||||
modifiers=None,
|
||||
inline_custom_tools_payload=inline_payload,
|
||||
)
|
||||
|
||||
def test_mixed(self, grep_tool):
|
||||
s = self._make_session(grep_tool)
|
||||
tm = MagicMock()
|
||||
remote = {
|
||||
"data": {
|
||||
"results": [
|
||||
{"tool_slug": "R", "response": {"successful": True, "data": {}}},
|
||||
],
|
||||
"total_count": 1,
|
||||
"success_count": 1,
|
||||
"error_count": 0,
|
||||
},
|
||||
"error": None,
|
||||
"successful": True,
|
||||
}
|
||||
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
||||
result = s._route_multi_execute(
|
||||
{
|
||||
"tools": [
|
||||
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
||||
{"tool_slug": "REMOTE", "arguments": {}},
|
||||
]
|
||||
},
|
||||
tm,
|
||||
)
|
||||
results = result["data"]["results"]
|
||||
assert len(results) == 2
|
||||
# Remotes first, locals appended (matches TS)
|
||||
assert results[0]["tool_slug"] == "R"
|
||||
assert results[1]["tool_slug"] == "GREP"
|
||||
assert result["data"]["total_count"] == 2
|
||||
assert result["data"]["success_count"] == 2
|
||||
assert result["data"]["error_count"] == 0
|
||||
|
||||
def test_failure_propagated(self):
|
||||
@exp.tool()
|
||||
def ok_tool(input: GrepInput, ctx):
|
||||
"""OK."""
|
||||
return {"ok": True}
|
||||
|
||||
@exp.tool()
|
||||
def bad_tool(input: GrepInput, ctx):
|
||||
"""Bad."""
|
||||
raise RuntimeError("boom")
|
||||
|
||||
s = self._make_session(ok_tool, bad_tool)
|
||||
result = s._route_multi_execute(
|
||||
{
|
||||
"tools": [
|
||||
{"tool_slug": "OK_TOOL", "arguments": {"pattern": "x"}},
|
||||
{"tool_slug": "BAD_TOOL", "arguments": {"pattern": "y"}},
|
||||
]
|
||||
},
|
||||
MagicMock(),
|
||||
)
|
||||
assert result["successful"] is False
|
||||
assert "1 out of 2" in result["error"]
|
||||
|
||||
def test_remote_null_data_does_not_crash(self, grep_tool):
|
||||
s = self._make_session(grep_tool)
|
||||
tm = MagicMock()
|
||||
remote = {"data": None, "error": None, "successful": True}
|
||||
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
||||
result = s._route_multi_execute(
|
||||
{
|
||||
"tools": [
|
||||
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
||||
{"tool_slug": "REMOTE", "arguments": {}},
|
||||
]
|
||||
},
|
||||
tm,
|
||||
)
|
||||
assert result["successful"] is True
|
||||
assert result["data"]["results"][0]["tool_slug"] == "GREP"
|
||||
|
||||
def test_remote_batch_error_without_item_errors_uses_batch_message(self, grep_tool):
|
||||
s = self._make_session(grep_tool)
|
||||
tm = MagicMock()
|
||||
remote = {
|
||||
"data": None,
|
||||
"error": "Remote batch failed before per-tool results were produced",
|
||||
"successful": False,
|
||||
}
|
||||
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
||||
result = s._route_multi_execute(
|
||||
{
|
||||
"tools": [
|
||||
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
||||
{"tool_slug": "REMOTE", "arguments": {}},
|
||||
]
|
||||
},
|
||||
tm,
|
||||
)
|
||||
assert result["successful"] is False
|
||||
assert (
|
||||
result["error"]
|
||||
== "Remote batch failed before per-tool results were produced"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,655 @@
|
||||
"""Test Gemini provider functionality.
|
||||
|
||||
Verifies:
|
||||
- Provider initialization and attributes
|
||||
- wrap_tool returns Python callables with correct metadata
|
||||
- wrap_tools returns list of callables
|
||||
- Callables are AFC-compatible (work with google-genai's function map)
|
||||
- handle_response dispatches function calls via stored executors (backward compat)
|
||||
- _process_execution_result helper
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.client.types import Tool, tool_list_response
|
||||
from composio.core.models.base import allow_tracking
|
||||
from composio.core.provider import AgenticProvider
|
||||
|
||||
try:
|
||||
import composio_gemini as _composio_gemini # noqa: F401
|
||||
|
||||
HAS_COMPOSIO_GEMINI = True
|
||||
except ImportError:
|
||||
HAS_COMPOSIO_GEMINI = False
|
||||
|
||||
try:
|
||||
from google.genai import types as genai_types
|
||||
|
||||
HAS_GENAI = True
|
||||
except ImportError:
|
||||
genai_types = None # type: ignore[assignment]
|
||||
HAS_GENAI = False
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.gemini,
|
||||
pytest.mark.skipif(
|
||||
not HAS_COMPOSIO_GEMINI, reason="composio_gemini package not installed"
|
||||
),
|
||||
]
|
||||
|
||||
requires_genai = pytest.mark.skipif(
|
||||
not HAS_GENAI, reason="google-genai package not installed"
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def create_mock_tool(
|
||||
slug: str,
|
||||
toolkit_slug: str,
|
||||
version: str = "12012025_00",
|
||||
input_parameters: dict | None = None,
|
||||
description: str = "Test tool for provider testing",
|
||||
) -> Tool:
|
||||
"""Create a mock tool for testing."""
|
||||
return Tool(
|
||||
name=f"Test {slug}",
|
||||
slug=slug,
|
||||
description=description,
|
||||
input_parameters=input_parameters
|
||||
or {"type": "object", "properties": {}, "required": []},
|
||||
output_parameters={},
|
||||
available_versions=[version],
|
||||
version=version,
|
||||
scopes=[],
|
||||
toolkit=tool_list_response.ItemToolkit(
|
||||
name=toolkit_slug.title(), slug=toolkit_slug, logo=""
|
||||
),
|
||||
deprecated=tool_list_response.ItemDeprecated(
|
||||
available_versions=[version],
|
||||
displayName=f"Test {slug}",
|
||||
version=version,
|
||||
toolkit=tool_list_response.ItemDeprecatedToolkit(logo=""),
|
||||
is_deprecated=False,
|
||||
),
|
||||
is_deprecated=False,
|
||||
no_auth=False,
|
||||
tags=[],
|
||||
)
|
||||
|
||||
|
||||
def create_mock_execute_tool():
|
||||
"""Create a mock execute_tool function matching AgenticProviderExecuteFn."""
|
||||
mock_fn = Mock()
|
||||
mock_fn.return_value = {
|
||||
"data": {"result": "success"},
|
||||
"error": None,
|
||||
"successful": True,
|
||||
}
|
||||
return mock_fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGeminiProviderInitialization:
|
||||
def test_initialization(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
assert provider is not None
|
||||
assert provider.name == "gemini"
|
||||
assert isinstance(provider, AgenticProvider)
|
||||
|
||||
def test_has_empty_executors_on_init(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
assert provider._executors == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wrap_tool – callable creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWrapTool:
|
||||
def test_returns_callable(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool("GITHUB_STAR_REPO", "github")
|
||||
result = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
assert callable(result)
|
||||
assert inspect.isfunction(result)
|
||||
|
||||
def test_callable_has_correct_name(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool("GITHUB_STAR_REPO", "github")
|
||||
result = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
assert result.__name__ == "GITHUB_STAR_REPO"
|
||||
|
||||
def test_callable_has_correct_doc(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"GITHUB_STAR_REPO", "github", description="Star a GitHub repository"
|
||||
)
|
||||
result = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
assert result.__doc__ == "Star a GitHub repository"
|
||||
|
||||
def test_callable_has_typed_signature(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"GITHUB_CREATE_ISSUE",
|
||||
"github",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
)
|
||||
result = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
|
||||
sig = inspect.signature(result)
|
||||
assert "title" in sig.parameters
|
||||
assert "body" in sig.parameters
|
||||
assert sig.parameters["title"].annotation is str
|
||||
assert sig.parameters["body"].annotation is str
|
||||
|
||||
def test_callable_has_annotations(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"TEST_TOOL",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
result = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
|
||||
assert "name" in result.__annotations__
|
||||
assert result.__annotations__["name"] is str
|
||||
assert result.__annotations__["return"] is dict
|
||||
|
||||
def test_stores_executor(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool("GITHUB_STAR_REPO", "github")
|
||||
execute_tool = create_mock_execute_tool()
|
||||
provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
assert "GITHUB_STAR_REPO" in provider._executors
|
||||
stored_execute_tool, _aliases = provider._executors["GITHUB_STAR_REPO"]
|
||||
assert stored_execute_tool is execute_tool
|
||||
|
||||
def test_callable_executes_correctly(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"MY_TOOL",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
execute_tool = create_mock_execute_tool()
|
||||
func = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
result = func(query="hello")
|
||||
execute_tool.assert_called_once_with("MY_TOOL", {"query": "hello"})
|
||||
# _process_execution_result extracts data when successful
|
||||
assert result == {"result": "success"}
|
||||
|
||||
def test_callable_processes_error_result(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"MY_TOOL",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
execute_tool = Mock()
|
||||
execute_tool.return_value = {
|
||||
"data": {},
|
||||
"error": "Auth failed",
|
||||
"successful": False,
|
||||
}
|
||||
func = provider.wrap_tool(tool, execute_tool)
|
||||
result = func(q="test")
|
||||
assert result["error"] == "Auth failed"
|
||||
|
||||
def test_callable_converts_pydantic_args_to_dicts(self):
|
||||
"""AFC may pass Pydantic GeneratedModel instances as kwargs; they must be
|
||||
converted to plain dicts before reaching execute_tool so the Composio
|
||||
API can JSON-serialize them.
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
class FakeQuery(BaseModel):
|
||||
use_case: str = ""
|
||||
known_fields: str = ""
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"COMPOSIO_SEARCH_TOOLS",
|
||||
"composio",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"title": "Query",
|
||||
"properties": {
|
||||
"use_case": {"type": "string"},
|
||||
"known_fields": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
execute_tool = create_mock_execute_tool()
|
||||
func = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
# Simulate what the SDK does: pass Pydantic model instances
|
||||
func(queries=[FakeQuery(use_case="summarize email")])
|
||||
|
||||
call_args = execute_tool.call_args[0][1]
|
||||
# The queries value must be a plain list of dicts, not Pydantic models
|
||||
assert isinstance(call_args["queries"], list)
|
||||
assert isinstance(call_args["queries"][0], dict)
|
||||
assert call_args["queries"][0]["use_case"] == "summarize email"
|
||||
|
||||
def test_array_param_has_parameterized_type(self):
|
||||
"""Array parameters must produce List[X], not bare List.
|
||||
|
||||
The google-genai SDK rejects bare List because it generates
|
||||
{"type": "ARRAY"} without "items".
|
||||
"""
|
||||
import typing
|
||||
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"TOOL_WITH_ARRAY",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
func = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
|
||||
sig = inspect.signature(func)
|
||||
tags_annotation = sig.parameters["tags"].annotation
|
||||
# Must be a parameterized generic like List[str], not bare list
|
||||
assert typing.get_origin(tags_annotation) is list
|
||||
assert typing.get_args(tags_annotation) != ()
|
||||
|
||||
def test_reserved_keyword_handling(self):
|
||||
"""Parameters named 'for' or 'async' are substituted and reinstated."""
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"TOOL_WITH_RESERVED",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"for": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
execute_tool = create_mock_execute_tool()
|
||||
func = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
# The callable's signature should use the cleaned name
|
||||
sig = inspect.signature(func)
|
||||
assert "for_rs" in sig.parameters
|
||||
assert "for" not in sig.parameters
|
||||
|
||||
# When called with the cleaned name, it reinstates the original
|
||||
func(for_rs="test_value", name="hello")
|
||||
call_args = execute_tool.call_args
|
||||
assert call_args[0][1]["for"] == "test_value"
|
||||
assert call_args[0][1]["name"] == "hello"
|
||||
|
||||
def test_wrap_same_tool_twice_does_not_corrupt_schema(self):
|
||||
"""Wrapping the same Tool object twice must not corrupt the schema."""
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"TOOL_RESERVED",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"for": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["for"],
|
||||
},
|
||||
)
|
||||
execute_tool = create_mock_execute_tool()
|
||||
|
||||
func1 = provider.wrap_tool(tool, execute_tool)
|
||||
func2 = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
# Both callables must have the cleaned parameter
|
||||
sig1 = inspect.signature(func1)
|
||||
sig2 = inspect.signature(func2)
|
||||
assert "for_rs" in sig1.parameters
|
||||
assert "for_rs" in sig2.parameters
|
||||
|
||||
# Original tool schema must be unchanged
|
||||
assert "for" in tool.input_parameters["properties"]
|
||||
assert "for_rs" not in tool.input_parameters["properties"]
|
||||
|
||||
def test_reserved_keyword_stays_required(self):
|
||||
"""A reserved keyword listed in 'required' must remain required after renaming."""
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"TOOL_REQUIRED_RESERVED",
|
||||
"test",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"for": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["for", "name"],
|
||||
},
|
||||
)
|
||||
func = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
|
||||
sig = inspect.signature(func)
|
||||
# Both params should be required (no default value)
|
||||
assert sig.parameters["for_rs"].default is inspect.Parameter.empty
|
||||
assert sig.parameters["name"].default is inspect.Parameter.empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wrap_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWrapTools:
|
||||
def test_returns_list_of_callables(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tools = [
|
||||
create_mock_tool("GITHUB_STAR_REPO", "github"),
|
||||
create_mock_tool("GMAIL_SEND_EMAIL", "gmail"),
|
||||
]
|
||||
result = provider.wrap_tools(tools, create_mock_execute_tool())
|
||||
|
||||
assert len(result) == 2
|
||||
assert all(inspect.isfunction(f) for f in result)
|
||||
|
||||
def test_stores_all_executors(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tools = [
|
||||
create_mock_tool("GITHUB_STAR_REPO", "github"),
|
||||
create_mock_tool("GMAIL_SEND_EMAIL", "gmail"),
|
||||
]
|
||||
provider.wrap_tools(tools, create_mock_execute_tool())
|
||||
|
||||
assert "GITHUB_STAR_REPO" in provider._executors
|
||||
assert "GMAIL_SEND_EMAIL" in provider._executors
|
||||
|
||||
def test_empty_list(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
result = provider.wrap_tools([], create_mock_execute_tool())
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AFC compatibility (requires google-genai)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_genai
|
||||
class TestAFCCompatibility:
|
||||
"""Verify callables work with google-genai's AFC pipeline."""
|
||||
|
||||
def _wrap_one_tool(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool(
|
||||
"GITHUB_STAR_REPO",
|
||||
"github",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
description="Star a GitHub repository",
|
||||
)
|
||||
func = provider.wrap_tool(tool, create_mock_execute_tool())
|
||||
return func
|
||||
|
||||
def test_callable_is_function(self):
|
||||
"""inspect.isfunction must be True for t_tool() to call from_callable."""
|
||||
func = self._wrap_one_tool()
|
||||
assert inspect.isfunction(func)
|
||||
|
||||
def test_callable_not_afc_incompatible(self):
|
||||
"""Callables should not be flagged as AFC-incompatible."""
|
||||
func = self._wrap_one_tool()
|
||||
# The SDK checks: isinstance(tool, types.Tool) and tool.function_declarations
|
||||
# A callable is not a types.Tool, so it should not be flagged.
|
||||
assert not isinstance(func, genai_types.Tool)
|
||||
|
||||
def test_callable_enters_function_map(self):
|
||||
"""The callable should appear in the function map built by the SDK."""
|
||||
func = self._wrap_one_tool()
|
||||
# Simulate what get_function_map does: callable(tool) -> function_map[tool.__name__]
|
||||
assert callable(func)
|
||||
function_map = {}
|
||||
if callable(func):
|
||||
function_map[func.__name__] = func
|
||||
assert "GITHUB_STAR_REPO" in function_map
|
||||
|
||||
def test_callables_in_generate_content_config(self):
|
||||
"""Wrapped callables can be passed to GenerateContentConfig without error."""
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tools = [
|
||||
create_mock_tool(
|
||||
"GITHUB_STAR_REPO",
|
||||
"github",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
),
|
||||
]
|
||||
wrapped = provider.wrap_tools(tools, create_mock_execute_tool())
|
||||
|
||||
# This should not raise
|
||||
config = genai_types.GenerateContentConfig(tools=wrapped)
|
||||
assert config.tools is not None
|
||||
assert len(config.tools) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_response (backward compat)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_genai
|
||||
class TestHandleResponse:
|
||||
def _create_mock_response(self, function_calls: list[tuple[str, dict]]):
|
||||
parts = []
|
||||
for name, args in function_calls:
|
||||
part = MagicMock()
|
||||
part.function_call = MagicMock()
|
||||
part.function_call.name = name
|
||||
part.function_call.args = args
|
||||
parts.append(part)
|
||||
|
||||
response = MagicMock()
|
||||
response.candidates = [MagicMock()]
|
||||
response.candidates[0].content = MagicMock()
|
||||
response.candidates[0].content.parts = parts
|
||||
return response
|
||||
|
||||
def test_executes_function_call(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
tool = create_mock_tool("GITHUB_STAR_REPO", "github")
|
||||
execute_tool = create_mock_execute_tool()
|
||||
provider.wrap_tools([tool], execute_tool)
|
||||
|
||||
response = self._create_mock_response(
|
||||
[("GITHUB_STAR_REPO", {"repo": "composio/composio"})]
|
||||
)
|
||||
function_responses, executed = provider.handle_response(response)
|
||||
|
||||
assert executed is True
|
||||
assert len(function_responses) == 1
|
||||
execute_tool.assert_called_once_with(
|
||||
slug="GITHUB_STAR_REPO",
|
||||
arguments={"repo": "composio/composio"},
|
||||
)
|
||||
|
||||
def test_no_function_calls(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
response = MagicMock()
|
||||
response.candidates = [MagicMock()]
|
||||
text_part = MagicMock()
|
||||
text_part.function_call = None
|
||||
response.candidates[0].content = MagicMock()
|
||||
response.candidates[0].content.parts = [text_part]
|
||||
|
||||
function_responses, executed = provider.handle_response(response)
|
||||
assert executed is False
|
||||
assert function_responses == []
|
||||
|
||||
def test_unknown_function_name(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
response = self._create_mock_response([("UNKNOWN_TOOL", {"param": "value"})])
|
||||
function_responses, executed = provider.handle_response(response)
|
||||
assert executed is False
|
||||
assert function_responses == []
|
||||
|
||||
def test_no_candidates(self):
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
provider = GeminiProvider()
|
||||
response = MagicMock()
|
||||
response.candidates = []
|
||||
function_responses, executed = provider.handle_response(response)
|
||||
assert executed is False
|
||||
assert function_responses == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _process_execution_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessExecutionResult:
|
||||
def test_non_dict_result(self):
|
||||
from composio_gemini.provider import _process_execution_result
|
||||
|
||||
assert _process_execution_result("hello") == {"result": "hello"}
|
||||
assert _process_execution_result(42) == {"result": 42}
|
||||
|
||||
def test_successful_with_dict_data(self):
|
||||
from composio_gemini.provider import _process_execution_result
|
||||
|
||||
result = _process_execution_result(
|
||||
{"data": {"key": "value"}, "error": None, "successful": True}
|
||||
)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_successful_with_non_dict_data(self):
|
||||
from composio_gemini.provider import _process_execution_result
|
||||
|
||||
result = _process_execution_result(
|
||||
{"data": "plain text", "error": None, "successful": True}
|
||||
)
|
||||
assert result == {"result": "plain text"}
|
||||
|
||||
def test_failed_result(self):
|
||||
from composio_gemini.provider import _process_execution_result
|
||||
|
||||
result = _process_execution_result(
|
||||
{"data": {}, "error": "Something went wrong", "successful": False}
|
||||
)
|
||||
assert result["error"] == "Something went wrong"
|
||||
|
||||
def test_passthrough_dict(self):
|
||||
from composio_gemini.provider import _process_execution_result
|
||||
|
||||
result = _process_execution_result({"custom": "response"})
|
||||
assert result == {"custom": "response"}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Regression tests for SDK reference generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.core.models.triggers import Triggers
|
||||
|
||||
|
||||
def load_generate_docs_module():
|
||||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "generate-docs.py"
|
||||
spec = importlib.util.spec_from_file_location("_generate_docs", script_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_triggers_parse_generated_example_is_valid_python():
|
||||
generate_docs = load_generate_docs_module()
|
||||
|
||||
parsed = generate_docs.parse_docstring(Triggers.parse.__doc__)
|
||||
assert parsed["examples"], "Triggers.parse() must keep its public example"
|
||||
|
||||
try:
|
||||
ast.parse(parsed["examples"][0])
|
||||
except SyntaxError as exc:
|
||||
pytest.fail(
|
||||
"Triggers.parse() generated SDK reference example must be "
|
||||
f"copy-pasteable Python: {exc}"
|
||||
)
|
||||
|
||||
|
||||
def test_generated_examples_do_not_keep_nested_markdown_fences():
|
||||
generate_docs = load_generate_docs_module()
|
||||
|
||||
parsed = generate_docs.parse_docstring(
|
||||
"""
|
||||
Fetch SDK data.
|
||||
|
||||
Example:
|
||||
```python
|
||||
result = composio.tools.get_raw_tool_router_meta_tools("session_123")
|
||||
print(result)
|
||||
```
|
||||
"""
|
||||
)
|
||||
|
||||
assert parsed["examples"] == [
|
||||
'result = composio.tools.get_raw_tool_router_meta_tools("session_123")\n'
|
||||
"print(result)"
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Test imports to ensure no circular import issues."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_basic_import():
|
||||
"""Test basic import of Composio class."""
|
||||
from composio import Composio
|
||||
|
||||
assert Composio is not None
|
||||
|
||||
|
||||
def test_import_all_public_exports():
|
||||
"""Test importing all public exports from composio package."""
|
||||
from composio import (
|
||||
Composio,
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
__version__,
|
||||
after_execute,
|
||||
before_execute,
|
||||
schema_modifier,
|
||||
)
|
||||
|
||||
assert Composio is not None
|
||||
assert after_execute is not None
|
||||
assert before_execute is not None
|
||||
assert schema_modifier is not None
|
||||
assert ToolkitLatestVersion is not None
|
||||
assert ToolkitVersion is not None
|
||||
assert ToolkitVersionParam is not None
|
||||
assert ToolkitVersions is not None
|
||||
assert __version__ is not None
|
||||
|
||||
|
||||
def test_import_types():
|
||||
"""Test importing types from different modules."""
|
||||
from composio.types import (
|
||||
Modifiers,
|
||||
Tool,
|
||||
ToolExecuteParams,
|
||||
ToolExecutionResponse,
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
TriggerEvent,
|
||||
TTool,
|
||||
TToolCollection,
|
||||
auth_scheme,
|
||||
)
|
||||
|
||||
assert Tool is not None
|
||||
assert TTool is not None
|
||||
assert TToolCollection is not None
|
||||
assert ToolExecuteParams is not None
|
||||
assert ToolExecutionResponse is not None
|
||||
assert TriggerEvent is not None
|
||||
assert Modifiers is not None
|
||||
assert auth_scheme is not None
|
||||
assert ToolkitLatestVersion is not None
|
||||
assert ToolkitVersion is not None
|
||||
assert ToolkitVersions is not None
|
||||
assert ToolkitVersionParam is not None
|
||||
|
||||
|
||||
def test_import_core_types():
|
||||
"""Test importing core types directly."""
|
||||
from composio.core.types import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
)
|
||||
|
||||
assert ToolkitLatestVersion is not None
|
||||
assert ToolkitVersion is not None
|
||||
assert ToolkitVersions is not None
|
||||
assert ToolkitVersionParam is not None
|
||||
|
||||
|
||||
def test_import_core_models():
|
||||
"""Test importing core models."""
|
||||
from composio.core.models import (
|
||||
AuthConfigs,
|
||||
ConnectedAccounts,
|
||||
Toolkits,
|
||||
Tools,
|
||||
Triggers,
|
||||
)
|
||||
|
||||
assert AuthConfigs is not None
|
||||
assert ConnectedAccounts is not None
|
||||
assert Toolkits is not None
|
||||
assert Tools is not None
|
||||
assert Triggers is not None
|
||||
|
||||
|
||||
def test_import_sdk():
|
||||
"""Test importing SDK directly."""
|
||||
from composio.sdk import Composio
|
||||
|
||||
assert Composio is not None
|
||||
|
||||
|
||||
def test_import_exceptions():
|
||||
"""Test importing exceptions."""
|
||||
from composio import exceptions
|
||||
|
||||
assert exceptions is not None
|
||||
assert hasattr(exceptions, "ApiKeyNotProvidedError")
|
||||
|
||||
|
||||
def test_circular_import_prevention():
|
||||
"""Test that circular imports are prevented."""
|
||||
# This test passes if no ImportError is raised during import
|
||||
try:
|
||||
from composio import Composio # noqa: F401
|
||||
from composio.core.models.tools import Tools # noqa: F401
|
||||
from composio.core.types import ToolkitVersion # noqa: F401
|
||||
from composio.types import ToolkitVersionParam # noqa: F401
|
||||
|
||||
# If we get here, no circular import occurred
|
||||
assert True
|
||||
except ImportError as e:
|
||||
pytest.fail(f"Circular import detected: {e}")
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Tests for :func:`composio.utils.json_schema.dereference_json_schema`.
|
||||
|
||||
Mirrors the TypeScript SDK's ``jsonSchema.test.ts`` so the two SDKs share one
|
||||
behavioral contract for inlining JSON Schema ``$ref`` pointers.
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.exceptions import JSONSchemaRefResolutionError
|
||||
from composio.utils import json_schema
|
||||
from composio.utils.json_schema import (
|
||||
MAX_REF_CHAIN_DEPTH,
|
||||
UNRESOLVED_REF_DESCRIPTION,
|
||||
dereference_json_schema,
|
||||
)
|
||||
|
||||
|
||||
def _contains_ref(value: t.Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
if "$ref" in value:
|
||||
return True
|
||||
return any(_contains_ref(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_ref(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
PERMISSIVE: t.Dict[str, t.Any] = {"type": "object", "additionalProperties": True}
|
||||
PERMISSIVE_WITH_HINT: t.Dict[str, t.Any] = {
|
||||
**PERMISSIVE,
|
||||
"description": UNRESOLVED_REF_DESCRIPTION,
|
||||
}
|
||||
|
||||
|
||||
class TestDereferenceJsonSchema:
|
||||
def test_inlines_single_internal_ref(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"user": {"$ref": "#/$defs/User"}},
|
||||
"required": ["user"],
|
||||
"$defs": {"User": {"type": "string"}},
|
||||
}
|
||||
)
|
||||
assert out == {
|
||||
"type": "object",
|
||||
"properties": {"user": {"type": "string"}},
|
||||
"required": ["user"],
|
||||
}
|
||||
assert _contains_ref(out) is False
|
||||
|
||||
def test_resolves_chain_a_b_c(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/A"}},
|
||||
"$defs": {
|
||||
"A": {"$ref": "#/$defs/B"},
|
||||
"B": {"$ref": "#/$defs/C"},
|
||||
"C": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert _contains_ref(out) is False
|
||||
assert out["properties"]["v"] == {"type": "integer"}
|
||||
|
||||
def test_walks_containers_reflectively(self):
|
||||
"""Refs are resolved in keywords the file walkers never special-cased."""
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "array", "items": {"$ref": "#/$defs/Leaf"}},
|
||||
"b": {"oneOf": [{"$ref": "#/$defs/Leaf"}, {"type": "null"}]},
|
||||
"c": {"anyOf": [{"$ref": "#/$defs/Leaf"}]},
|
||||
"d": {"allOf": [{"$ref": "#/$defs/Leaf"}]},
|
||||
"e": {"not": {"$ref": "#/$defs/Leaf"}},
|
||||
"f": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"$ref": "#/$defs/Leaf"},
|
||||
},
|
||||
"g": {
|
||||
"type": "object",
|
||||
"patternProperties": {"^x_": {"$ref": "#/$defs/Leaf"}},
|
||||
},
|
||||
"h": {"type": "array", "prefixItems": [{"$ref": "#/$defs/Leaf"}]},
|
||||
},
|
||||
"$defs": {"Leaf": {"type": "string"}},
|
||||
}
|
||||
)
|
||||
assert _contains_ref(out) is False
|
||||
|
||||
def test_resolves_legacy_definitions(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"name": {"$ref": "#/definitions/Name"}},
|
||||
"definitions": {"Name": {"type": "string", "minLength": 1}},
|
||||
}
|
||||
)
|
||||
assert _contains_ref(out) is False
|
||||
assert out["properties"]["name"] == {"type": "string", "minLength": 1}
|
||||
|
||||
def test_mixes_defs_and_definitions_transitively(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/definitions/A"}},
|
||||
"definitions": {"A": {"$ref": "#/$defs/B"}},
|
||||
"$defs": {"B": {"type": "boolean"}},
|
||||
}
|
||||
)
|
||||
assert _contains_ref(out) is False
|
||||
assert out["properties"]["v"] == {"type": "boolean"}
|
||||
|
||||
def test_sibling_keywords_win_on_collision(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"v": {
|
||||
"$ref": "#/$defs/Foo",
|
||||
"description": "caller override",
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Foo": {"type": "string", "description": "target description"}
|
||||
},
|
||||
}
|
||||
)
|
||||
v = out["properties"]["v"]
|
||||
assert v["type"] == "string"
|
||||
assert v["description"] == "caller override"
|
||||
assert v["default"] is None
|
||||
assert _contains_ref(out) is False
|
||||
|
||||
def test_breaks_recursive_ref_cycles_with_sentinel(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"$ref": "#/$defs/Tree",
|
||||
"$defs": {
|
||||
"Tree": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Tree"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert out["type"] == "object"
|
||||
assert out["properties"]["children"]["items"] == PERMISSIVE
|
||||
assert _contains_ref(out) is False
|
||||
|
||||
def test_strips_defs_and_definitions_from_root(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"x": {"$ref": "#/$defs/Foo"}},
|
||||
"$defs": {"Foo": {"type": "integer"}},
|
||||
"definitions": {"Bar": {"type": "string"}},
|
||||
}
|
||||
)
|
||||
assert "$defs" not in out
|
||||
assert "definitions" not in out
|
||||
|
||||
def test_throws_when_target_missing(self):
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/Missing"}},
|
||||
"$defs": {},
|
||||
}
|
||||
)
|
||||
|
||||
def test_throws_when_chain_depth_exceeds_cap(self):
|
||||
defs: t.Dict[str, t.Any] = {}
|
||||
for i in range(200):
|
||||
target = f"#/$defs/A{i + 1}" if i + 1 < 200 else "#/$defs/A0"
|
||||
defs[f"A{i}"] = {"$ref": target}
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/A0"}},
|
||||
"$defs": defs,
|
||||
}
|
||||
)
|
||||
|
||||
def test_throws_on_pathologically_deep_nesting(self):
|
||||
leaf: t.Dict[str, t.Any] = {"type": "string"}
|
||||
for _ in range(1000):
|
||||
leaf = {"type": "object", "properties": {"x": leaf}}
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(leaf)
|
||||
|
||||
def test_breaks_identity_cycles_without_ref(self):
|
||||
"""A live-object cycle that does not flow through a ``$ref``."""
|
||||
root: t.Dict[str, t.Any] = {"type": "object", "properties": {}}
|
||||
root["properties"]["self"] = root
|
||||
out = dereference_json_schema(root)
|
||||
assert out["properties"]["self"] == PERMISSIVE
|
||||
|
||||
def test_leaves_external_ref_untouched_and_warns(self):
|
||||
with patch.object(json_schema.logger, "warning") as warn:
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "https://example.com/Foo"}},
|
||||
}
|
||||
)
|
||||
assert out["properties"]["v"]["$ref"] == "https://example.com/Foo"
|
||||
warn.assert_called_once()
|
||||
assert "https://example.com/Foo" in str(warn.call_args)
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
import copy
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/Foo"}},
|
||||
"$defs": {"Foo": {"type": "string"}},
|
||||
}
|
||||
snapshot = copy.deepcopy(schema)
|
||||
dereference_json_schema(schema)
|
||||
assert schema == snapshot
|
||||
|
||||
def test_decodes_json_pointer_escapes(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"$ref": "#/$defs/with~1slash"},
|
||||
"b": {"$ref": "#/$defs/with~0tilde"},
|
||||
"c": {"$ref": "#/$defs/tilde-then-slash~01"},
|
||||
},
|
||||
"$defs": {
|
||||
"with/slash": {"type": "string"},
|
||||
"with~tilde": {"type": "integer"},
|
||||
"tilde-then-slash~1": {"type": "boolean"},
|
||||
},
|
||||
}
|
||||
)
|
||||
props = out["properties"]
|
||||
assert props["a"]["type"] == "string"
|
||||
assert props["b"]["type"] == "integer"
|
||||
assert props["c"]["type"] == "boolean"
|
||||
|
||||
def test_resolves_array_index_pointers(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/foo/oneOf/0"}},
|
||||
"$defs": {"foo": {"oneOf": [{"type": "string"}, {"type": "number"}]}},
|
||||
}
|
||||
)
|
||||
assert out["properties"]["v"]["type"] == "string"
|
||||
|
||||
def test_passthrough_for_non_dict_input(self):
|
||||
assert dereference_json_schema("not-a-schema") == "not-a-schema"
|
||||
assert dereference_json_schema(None) is None
|
||||
|
||||
|
||||
class TestSentinelMode:
|
||||
def test_replaces_dangling_ref_with_no_defs_block(self):
|
||||
"""Mirrors GMAIL_FETCH_EMAILS: a ``$ref`` into ``#/$defs`` with no defs."""
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"$ref": "#/$defs/FetchEmailsResponse"},
|
||||
"error": {"type": "string"},
|
||||
"successful": {"type": "boolean"},
|
||||
},
|
||||
"required": ["data", "successful"],
|
||||
"title": "FetchEmailsResponseWrapper",
|
||||
},
|
||||
on_unresolved="sentinel",
|
||||
)
|
||||
assert out["properties"]["data"] == PERMISSIVE_WITH_HINT
|
||||
assert out["properties"]["error"] == {"type": "string"}
|
||||
assert out["properties"]["successful"] == {"type": "boolean"}
|
||||
assert out["required"] == ["data", "successful"]
|
||||
assert _contains_ref(out) is False
|
||||
|
||||
def test_replaces_ref_into_empty_defs(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/Missing"}},
|
||||
"$defs": {},
|
||||
},
|
||||
on_unresolved="sentinel",
|
||||
)
|
||||
assert out["properties"]["v"] == PERMISSIVE_WITH_HINT
|
||||
assert _contains_ref(out) is False
|
||||
|
||||
def test_on_replace_called_once_per_replacement(self):
|
||||
calls: t.List[t.Tuple[str, str]] = []
|
||||
dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"$ref": "#/$defs/Missing"},
|
||||
"b": {"$ref": "#/$defs/Missing"},
|
||||
"c": {"$ref": "#/$defs/AlsoMissing"},
|
||||
},
|
||||
},
|
||||
on_unresolved="sentinel",
|
||||
on_replace=lambda ref, reason: calls.append((ref, reason)),
|
||||
)
|
||||
assert calls == [
|
||||
("#/$defs/Missing", "missing-target"),
|
||||
("#/$defs/Missing", "missing-target"),
|
||||
("#/$defs/AlsoMissing", "missing-target"),
|
||||
]
|
||||
|
||||
def test_on_replace_malformed_pointer(self):
|
||||
calls: t.List[t.Tuple[str, str]] = []
|
||||
out = dereference_json_schema(
|
||||
{"type": "object", "properties": {"v": {"$ref": "#bar"}}},
|
||||
on_unresolved="sentinel",
|
||||
on_replace=lambda ref, reason: calls.append((ref, reason)),
|
||||
)
|
||||
assert calls == [("#bar", "malformed-pointer")]
|
||||
assert out["properties"]["v"] == PERMISSIVE_WITH_HINT
|
||||
|
||||
def test_still_throws_on_chain_depth_cap(self):
|
||||
defs: t.Dict[str, t.Any] = {}
|
||||
for i in range(200):
|
||||
target = f"#/$defs/A{i + 1}" if i + 1 < 200 else "#/$defs/A0"
|
||||
defs[f"A{i}"] = {"$ref": target}
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/A0"}},
|
||||
"$defs": defs,
|
||||
},
|
||||
on_unresolved="sentinel",
|
||||
)
|
||||
|
||||
def test_still_throws_on_node_depth_cap(self):
|
||||
leaf: t.Dict[str, t.Any] = {"type": "string"}
|
||||
for _ in range(1000):
|
||||
leaf = {"type": "object", "properties": {"x": leaf}}
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(leaf, on_unresolved="sentinel")
|
||||
|
||||
def test_explicit_throw_matches_default(self):
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/Missing"}},
|
||||
"$defs": {},
|
||||
},
|
||||
on_unresolved="throw",
|
||||
)
|
||||
|
||||
def test_does_not_call_on_replace_in_strict_mode(self):
|
||||
calls: t.List[t.Any] = []
|
||||
with pytest.raises(JSONSchemaRefResolutionError):
|
||||
dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"v": {"$ref": "#/$defs/Missing"}},
|
||||
"$defs": {},
|
||||
},
|
||||
on_unresolved="throw",
|
||||
on_replace=lambda ref, reason: calls.append((ref, reason)),
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
def test_injects_default_description_when_no_sibling(self):
|
||||
out = dereference_json_schema(
|
||||
{"type": "object", "properties": {"v": {"$ref": "#/$defs/Missing"}}},
|
||||
on_unresolved="sentinel",
|
||||
)
|
||||
description = out["properties"]["v"]["description"]
|
||||
assert "Schema shape unresolved at the source" in description
|
||||
assert "https://github.com/ComposioHQ/composio/issues/3307" in description
|
||||
|
||||
def test_preserves_caller_description_sibling(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"v": {
|
||||
"$ref": "#/$defs/Missing",
|
||||
"description": "caller-supplied prose context",
|
||||
},
|
||||
},
|
||||
},
|
||||
on_unresolved="sentinel",
|
||||
)
|
||||
assert out["properties"]["v"]["description"] == "caller-supplied prose context"
|
||||
assert out["properties"]["v"]["type"] == "object"
|
||||
|
||||
def test_preserves_resolvable_defs_while_replacing_dangling_branch(self):
|
||||
out = dereference_json_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resolved": {"$ref": "#/$defs/Real"},
|
||||
"dangling": {"$ref": "#/$defs/Ghost"},
|
||||
},
|
||||
"$defs": {"Real": {"type": "integer"}},
|
||||
},
|
||||
on_unresolved="sentinel",
|
||||
)
|
||||
assert out["properties"]["resolved"] == {"type": "integer"}
|
||||
assert out["properties"]["dangling"] == PERMISSIVE_WITH_HINT
|
||||
|
||||
|
||||
def test_max_ref_chain_depth_is_exposed():
|
||||
"""Guard against accidental removal of the safety cap constant."""
|
||||
assert MAX_REF_CHAIN_DEPTH == 100
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for the mimetypes utility module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.utils.mimetypes import (
|
||||
_default,
|
||||
get_extension_from_mime_type,
|
||||
guess,
|
||||
)
|
||||
|
||||
|
||||
class TestGuess:
|
||||
"""Test cases for guess() (filename/extension -> MIME type)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("a.json", "application/json"),
|
||||
("a.html", "text/html"),
|
||||
("a.txt", "text/plain"),
|
||||
("a.pdf", "application/pdf"),
|
||||
("a.css", "text/css"),
|
||||
("a.js", "application/javascript"),
|
||||
("a.png", "image/png"),
|
||||
("a.gif", "image/gif"),
|
||||
("a.mp4", "video/mp4"),
|
||||
("a.zip", "application/zip"),
|
||||
],
|
||||
)
|
||||
def test_known_extensions(self, filename: str, expected: str) -> None:
|
||||
"""A known extension resolves to its MIME type."""
|
||||
assert guess(filename) == expected
|
||||
|
||||
def test_extension_lookup_is_case_sensitive(self) -> None:
|
||||
"""The extension table is lower-case, so an upper-case suffix misses."""
|
||||
assert guess("photo.PNG") == _default
|
||||
|
||||
def test_unknown_extension_returns_default(self) -> None:
|
||||
assert guess("file.unknownext") == _default
|
||||
|
||||
def test_no_extension_returns_default(self) -> None:
|
||||
assert guess("noextension") == _default
|
||||
|
||||
def test_uses_only_the_final_suffix(self) -> None:
|
||||
# Path().suffix is the last component, so ".gz" here, not ".tar.gz".
|
||||
assert guess("archive.tar.gz") == guess("archive.gz")
|
||||
|
||||
def test_accepts_path_objects(self) -> None:
|
||||
assert guess(Path("dir/sub/a.png")) == "image/png"
|
||||
|
||||
|
||||
class TestGetExtensionFromMimeType:
|
||||
"""Test cases for get_extension_from_mime_type() (MIME type -> extension)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mime_type", "expected"),
|
||||
[
|
||||
("image/png", "png"),
|
||||
("application/json", "json"),
|
||||
("text/html", "html"),
|
||||
("application/pdf", "pdf"),
|
||||
("image/jpeg", "jpg"),
|
||||
("video/mp4", "mp4"),
|
||||
("application/vnd.ms-excel", "xls"),
|
||||
],
|
||||
)
|
||||
def test_direct_mapping(self, mime_type: str, expected: str) -> None:
|
||||
assert get_extension_from_mime_type(mime_type) == expected
|
||||
|
||||
def test_strips_parameters(self) -> None:
|
||||
assert get_extension_from_mime_type("text/plain; charset=utf-8") == "txt"
|
||||
|
||||
def test_is_case_insensitive(self) -> None:
|
||||
assert get_extension_from_mime_type("APPLICATION/JSON") == "json"
|
||||
|
||||
def test_surrounding_whitespace_is_ignored(self) -> None:
|
||||
assert get_extension_from_mime_type(" image/png ") == "png"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mime_type", "expected"),
|
||||
[
|
||||
("application/atom+xml", "atom"),
|
||||
("application/rss+xml", "rss"),
|
||||
],
|
||||
)
|
||||
def test_structured_suffix_known_prefix(
|
||||
self, mime_type: str, expected: str
|
||||
) -> None:
|
||||
"""For known prefixes the prefix wins over the suffix."""
|
||||
assert get_extension_from_mime_type(mime_type) == expected
|
||||
|
||||
def test_structured_suffix_uses_suffix(self) -> None:
|
||||
"""A '+json'/'+xml' style suffix maps to that structured format."""
|
||||
assert get_extension_from_mime_type("application/vnd.api+json") == "json"
|
||||
|
||||
def test_structured_suffix_unknown_falls_back_to_suffix(self) -> None:
|
||||
assert get_extension_from_mime_type("application/foo+bar") == "bar"
|
||||
|
||||
def test_unknown_type_uses_subtype(self) -> None:
|
||||
assert get_extension_from_mime_type("application/x-custom") == "x-custom"
|
||||
assert get_extension_from_mime_type("totally/unknown") == "unknown"
|
||||
|
||||
def test_empty_subtype_defaults_to_txt(self) -> None:
|
||||
assert get_extension_from_mime_type("application/") == "txt"
|
||||
|
||||
def test_no_subtype_defaults_to_bin(self) -> None:
|
||||
assert get_extension_from_mime_type("notamimetype") == "bin"
|
||||
assert get_extension_from_mime_type("") == "bin"
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Non-idempotent tool writes (``tools.execute`` / ``tools.proxy``) must not retry.
|
||||
|
||||
A POST that times out while the backend is still processing it is unsafe to
|
||||
retry: the request may already have taken effect, so a silent re-send can
|
||||
duplicate the side effect (e.g. send an email twice). ``Tools.execute`` and
|
||||
``Tools.proxy`` therefore route through ``client.without_retries`` (a
|
||||
retry-disabled clone), while reads keep the default retry behaviour.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import typing as t
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from composio_client import DEFAULT_MAX_RETRIES, APIError
|
||||
from composio_client import Composio as BaseComposio
|
||||
|
||||
from composio.client import HttpClient
|
||||
from composio.core.models.base import allow_tracking
|
||||
from composio.core.models.tools import Tools
|
||||
|
||||
|
||||
@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 no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Neutralise retry backoff so retry tests run instantly."""
|
||||
import composio_client._base_client as base
|
||||
|
||||
monkeypatch.setattr(base.time, "sleep", lambda *args, **kwargs: None)
|
||||
|
||||
|
||||
def _client_with_transport(
|
||||
handler: t.Callable[[httpx.Request], httpx.Response],
|
||||
) -> HttpClient:
|
||||
"""Build an ``HttpClient`` whose HTTP requests are served by ``handler``.
|
||||
|
||||
``base_url`` is passed explicitly so the test does not depend on
|
||||
``COMPOSIO_BASE_URL`` possibly being set in the environment.
|
||||
"""
|
||||
return HttpClient(
|
||||
provider="test",
|
||||
api_key="sk-test",
|
||||
base_url="https://backend.invalid",
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
|
||||
class TestCopyOverride:
|
||||
"""Guards the ``HttpClient.copy()`` override that makes ``with_options`` work."""
|
||||
|
||||
def test_with_options_does_not_raise_and_overrides_max_retries(self) -> None:
|
||||
client = HttpClient(
|
||||
provider="myprovider",
|
||||
api_key="sk-test",
|
||||
base_url="https://backend.invalid",
|
||||
)
|
||||
|
||||
# Without the copy() override this raises:
|
||||
# TypeError: missing required keyword-only argument 'provider'.
|
||||
clone = client.with_options(max_retries=0)
|
||||
|
||||
assert isinstance(clone, HttpClient)
|
||||
assert clone.max_retries == 0
|
||||
assert clone.provider == "myprovider"
|
||||
assert clone.request_ctx.get()["provider"] == "myprovider"
|
||||
# The original client keeps its retries.
|
||||
assert client.max_retries == DEFAULT_MAX_RETRIES
|
||||
|
||||
def test_without_retries_is_a_cached_zero_retry_sibling(self) -> None:
|
||||
client = HttpClient(
|
||||
provider="test",
|
||||
api_key="sk-test",
|
||||
base_url="https://backend.invalid",
|
||||
)
|
||||
|
||||
assert client.without_retries.max_retries == 0
|
||||
# Cached: repeated access returns the same instance (no per-call clone).
|
||||
assert client.without_retries is client.without_retries
|
||||
# Reads on the original client keep retrying.
|
||||
assert client.max_retries == DEFAULT_MAX_RETRIES
|
||||
|
||||
def test_clone_preserves_strict_response_validation(self) -> None:
|
||||
# The generated `copy` drops `_strict_response_validation`; the override
|
||||
# re-injects it so the sibling differs from the parent only in retries.
|
||||
client = HttpClient(
|
||||
provider="test",
|
||||
api_key="sk-test",
|
||||
base_url="https://backend.invalid",
|
||||
_strict_response_validation=True,
|
||||
)
|
||||
|
||||
assert client.without_retries._strict_response_validation is True
|
||||
|
||||
|
||||
class TestStainlessCopyContract:
|
||||
"""Pin the generated-client internals the ``copy()`` override depends on.
|
||||
|
||||
These guard the contract so a ``composio_client`` regen that breaks it fails
|
||||
as an obvious assertion here rather than a cryptic ``TypeError`` raised deep
|
||||
inside ``with_options`` at runtime.
|
||||
"""
|
||||
|
||||
def test_base_copy_accepts_extra_kwargs(self) -> None:
|
||||
# The override threads `provider` (and `_strict_response_validation`)
|
||||
# through `_extra_kwargs`; the base `copy` must still accept it.
|
||||
assert "_extra_kwargs" in inspect.signature(BaseComposio.copy).parameters
|
||||
|
||||
def test_with_options_is_aliased_to_our_copy_override(self) -> None:
|
||||
# The base binds `with_options = copy` at class-definition time, so the
|
||||
# subclass must re-alias it to the override that re-injects `provider`.
|
||||
# (Accessing these bound methods via the generic class trips mypy's
|
||||
# "generic instance variable via class" check; the identity assert is
|
||||
# intentional.)
|
||||
assert HttpClient.__dict__["with_options"] is HttpClient.__dict__["copy"] # type: ignore[misc]
|
||||
|
||||
|
||||
class TestWritePathDoesNotRetry:
|
||||
"""``execute`` / ``proxy`` must reach the transport exactly once."""
|
||||
|
||||
def test_execute_does_not_retry_on_transient_error(self, no_sleep: None) -> None:
|
||||
attempts: t.List[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts.append(request)
|
||||
return httpx.Response(500, json={"error": {"message": "boom"}})
|
||||
|
||||
client = _client_with_transport(handler)
|
||||
tools = Tools(client=client, provider=Mock())
|
||||
|
||||
# Avoid the (read) tool-schema lookup hitting the transport.
|
||||
mock_tool = Mock()
|
||||
mock_tool.toolkit.slug = "gmail"
|
||||
with patch.object(
|
||||
tools, "get_raw_composio_tool_by_slug", return_value=mock_tool
|
||||
):
|
||||
with pytest.raises(APIError):
|
||||
tools._execute_tool(
|
||||
slug="GMAIL_SEND_EMAIL",
|
||||
arguments={},
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
assert len(attempts) == 1
|
||||
|
||||
def test_proxy_does_not_retry_on_transient_error(self, no_sleep: None) -> None:
|
||||
attempts: t.List[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts.append(request)
|
||||
return httpx.Response(500, json={"error": {"message": "boom"}})
|
||||
|
||||
client = _client_with_transport(handler)
|
||||
tools = Tools(client=client, provider=Mock())
|
||||
|
||||
# `proxy` does no tool-schema lookup, so the only transport hit is the
|
||||
# write itself — no read to patch out (unlike the execute test above).
|
||||
with pytest.raises(APIError):
|
||||
tools.proxy(endpoint="/any", method="POST")
|
||||
|
||||
assert len(attempts) == 1
|
||||
|
||||
|
||||
class TestReadPathStillRetries:
|
||||
"""Reads keep the default retry behaviour — only writes are scoped to no-retry."""
|
||||
|
||||
def test_read_retries_then_succeeds(self, no_sleep: None) -> None:
|
||||
attempts: t.List[httpx.Request] = []
|
||||
responses = [
|
||||
httpx.Response(503, json={"error": {"message": "transient"}}),
|
||||
httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"current_page": 1,
|
||||
"items": [],
|
||||
"total_items": 0,
|
||||
"total_pages": 1,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts.append(request)
|
||||
return responses[len(attempts) - 1]
|
||||
|
||||
client = _client_with_transport(handler)
|
||||
|
||||
result = client.tools.list()
|
||||
|
||||
# First attempt 503, retried, second attempt 200 -> success.
|
||||
assert len(attempts) == 2
|
||||
assert result.total_items == 0
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for normalize_tool_arguments (issue #2406)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.exceptions import InvalidParams
|
||||
from composio.utils.shared import normalize_tool_arguments
|
||||
|
||||
pytestmark = pytest.mark.core
|
||||
|
||||
|
||||
class TestNormalizeToolArguments:
|
||||
def test_dict_is_returned_unchanged(self):
|
||||
payload = {"to": "a@b.com", "subject": "hi"}
|
||||
assert normalize_tool_arguments(payload) is payload
|
||||
|
||||
def test_json_string_is_parsed(self):
|
||||
payload = {"to": "a@b.com", "subject": "hi", "body": "Hello"}
|
||||
assert (
|
||||
normalize_tool_arguments(
|
||||
'{"to": "a@b.com", "subject": "hi", "body": "Hello"}'
|
||||
)
|
||||
== payload
|
||||
)
|
||||
|
||||
def test_none_becomes_empty_dict(self):
|
||||
assert normalize_tool_arguments(None) == {}
|
||||
|
||||
@pytest.mark.parametrize("value", ["", " ", "\n\t "])
|
||||
def test_empty_string_becomes_empty_dict(self, value):
|
||||
assert normalize_tool_arguments(value) == {}
|
||||
|
||||
def test_malformed_json_string_raises(self):
|
||||
with pytest.raises(InvalidParams, match="not valid JSON"):
|
||||
normalize_tool_arguments('{"to": "a@b.com"')
|
||||
|
||||
@pytest.mark.parametrize("value", ["[1, 2, 3]", "42", '"hello"'])
|
||||
def test_non_object_json_raises(self, value):
|
||||
with pytest.raises(InvalidParams, match="must resolve to an object"):
|
||||
normalize_tool_arguments(value)
|
||||
|
||||
@pytest.mark.parametrize("value", [[1, 2, 3], 42, True])
|
||||
def test_non_dict_value_raises(self, value):
|
||||
with pytest.raises(InvalidParams, match="must resolve to an object"):
|
||||
normalize_tool_arguments(value)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for :func:`composio.utils.openapi.function_signature_from_jsonschema`.
|
||||
|
||||
This builder is what the ``google_adk`` provider uses to attach a
|
||||
``__signature__`` to wrapped tools, so a crash here fails tool wrapping. The
|
||||
top-level "no type" property already falls back to ``Any``; these tests cover
|
||||
the nested combiner options (``oneOf``/``anyOf``/``allOf``) that used to raise.
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.utils.openapi import function_signature_from_jsonschema
|
||||
|
||||
|
||||
def _annotation(prop: t.Dict[str, t.Any]) -> t.Any:
|
||||
params = function_signature_from_jsonschema({"properties": {"x": prop}})
|
||||
return params[0].annotation
|
||||
|
||||
|
||||
class TestTypelessCombinerOptions:
|
||||
"""A combiner option without a ``type`` key should default to Any, not raise."""
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_typeless_any_of_option_defaults_to_any(self):
|
||||
annotation = _annotation(
|
||||
{"anyOf": [{"description": "free form"}, {"type": "string"}]}
|
||||
)
|
||||
assert annotation == t.Union[t.Any, str]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_typeless_one_of_option_defaults_to_any(self):
|
||||
annotation = _annotation(
|
||||
{"oneOf": [{"type": "string"}, {"description": "free form"}]}
|
||||
)
|
||||
assert annotation == t.Union[str, t.Any]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_empty_all_of_resolves_to_any(self):
|
||||
assert _annotation({"allOf": []}) is t.Any
|
||||
|
||||
|
||||
class TestEmptyCombinerLists:
|
||||
"""An empty oneOf/anyOf has no options to union; fall back to Any."""
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_empty_any_of(self):
|
||||
assert _annotation({"anyOf": []}) is t.Any
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_empty_one_of(self):
|
||||
assert _annotation({"oneOf": []}) is t.Any
|
||||
|
||||
|
||||
class TestExistingShapesUnchanged:
|
||||
"""Shapes that already worked must keep resolving identically."""
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_nullable_any_of_is_optional(self):
|
||||
assert (
|
||||
_annotation({"anyOf": [{"type": "string"}, {"type": "null"}]})
|
||||
== t.Optional[str]
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_multi_member_one_of(self):
|
||||
annotation = _annotation(
|
||||
{
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
{"type": "boolean"},
|
||||
{"type": "number"},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert annotation == t.Union[str, int, bool, float]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_enum(self):
|
||||
assert (
|
||||
_annotation({"type": "string", "enum": ["a", "b"]}) == t.Literal["a", "b"]
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_array(self):
|
||||
assert (
|
||||
_annotation({"type": "array", "items": {"type": "string"}}) == t.List[str]
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_object(self):
|
||||
assert _annotation({"type": "object"}) == t.Dict[str, t.Any]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
def test_typeless_property_is_any(self):
|
||||
assert _annotation({"description": "free form"}) is t.Any
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
"""Test SDK functionality."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from composio import Composio, exceptions
|
||||
from composio.core.types import ToolkitVersionParam
|
||||
|
||||
|
||||
class TestComposioSDK:
|
||||
"""Test cases for Composio SDK."""
|
||||
|
||||
def test_sdk_requires_api_key(self):
|
||||
"""Test that SDK requires an API key."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with pytest.raises(exceptions.ApiKeyNotProvidedError):
|
||||
Composio()
|
||||
|
||||
def test_sdk_accepts_api_key_from_env(self):
|
||||
"""Test that SDK accepts API key from environment."""
|
||||
with patch.dict(os.environ, {"COMPOSIO_API_KEY": "test-key"}):
|
||||
with patch("composio.core.models.Tools"):
|
||||
with patch("composio.core.models.Toolkits"):
|
||||
with patch("composio.core.models.Triggers"):
|
||||
with patch("composio.core.models.AuthConfigs"):
|
||||
with patch("composio.core.models.ConnectedAccounts"):
|
||||
sdk = Composio()
|
||||
assert sdk is not None
|
||||
|
||||
def test_sdk_accepts_api_key_as_parameter(self):
|
||||
"""Test that SDK accepts API key as parameter."""
|
||||
with patch("composio.core.models.Tools"):
|
||||
with patch("composio.core.models.Toolkits"):
|
||||
with patch("composio.core.models.Triggers"):
|
||||
with patch("composio.core.models.AuthConfigs"):
|
||||
with patch("composio.core.models.ConnectedAccounts"):
|
||||
sdk = Composio(api_key="test-key")
|
||||
assert sdk is not None
|
||||
|
||||
def test_sdk_config_types(self):
|
||||
"""Test SDK configuration types."""
|
||||
from composio.sdk import SDKConfig
|
||||
|
||||
# Test that SDKConfig is a TypedDict
|
||||
assert hasattr(SDKConfig, "__annotations__")
|
||||
|
||||
# Test that all expected fields are present
|
||||
expected_fields = {
|
||||
"environment",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"timeout",
|
||||
"max_retries",
|
||||
"allow_tracking",
|
||||
"file_download_dir",
|
||||
"toolkit_versions",
|
||||
"dangerously_allow_auto_upload_download_files",
|
||||
"sensitive_file_upload_protection",
|
||||
"file_upload_path_deny_segments",
|
||||
"file_upload_dirs",
|
||||
}
|
||||
assert set(SDKConfig.__annotations__.keys()) == expected_fields
|
||||
|
||||
def test_toolkit_version_param_types(self):
|
||||
"""Test toolkit version parameter types."""
|
||||
from composio.core.types import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersions,
|
||||
)
|
||||
|
||||
# Test that types are defined correctly
|
||||
assert ToolkitLatestVersion is not None
|
||||
assert ToolkitVersion is not None
|
||||
assert ToolkitVersions is not None
|
||||
assert ToolkitVersionParam is not None
|
||||
|
||||
def test_sdk_has_required_attributes(self):
|
||||
"""Test that SDK has required attributes after initialization."""
|
||||
with patch.dict(os.environ, {"COMPOSIO_API_KEY": "test-key"}):
|
||||
with patch("composio.core.models.Tools"):
|
||||
with patch("composio.core.models.Toolkits"):
|
||||
with patch("composio.core.models.Triggers"):
|
||||
with patch("composio.core.models.AuthConfigs"):
|
||||
with patch("composio.core.models.ConnectedAccounts"):
|
||||
sdk = Composio()
|
||||
|
||||
# Check that all required attributes are present
|
||||
assert hasattr(sdk, "tools")
|
||||
assert hasattr(sdk, "toolkits")
|
||||
assert hasattr(sdk, "triggers")
|
||||
assert hasattr(sdk, "auth_configs")
|
||||
assert hasattr(sdk, "connected_accounts")
|
||||
assert hasattr(sdk, "provider")
|
||||
assert hasattr(sdk, "client")
|
||||
|
||||
def test_sdk_default_provider(self):
|
||||
"""Test that SDK uses default provider."""
|
||||
with patch.dict(os.environ, {"COMPOSIO_API_KEY": "test-key"}):
|
||||
with patch("composio.core.models.Tools"):
|
||||
with patch("composio.core.models.Toolkits"):
|
||||
with patch("composio.core.models.Triggers"):
|
||||
with patch("composio.core.models.AuthConfigs"):
|
||||
with patch("composio.core.models.ConnectedAccounts"):
|
||||
sdk = Composio()
|
||||
|
||||
# Check that provider is set
|
||||
assert sdk.provider is not None
|
||||
assert hasattr(sdk.provider, "name")
|
||||
|
||||
def test_toolkit_versions_processing(self):
|
||||
"""Test toolkit versions parameter processing."""
|
||||
with patch.dict(os.environ, {"COMPOSIO_API_KEY": "test-key"}):
|
||||
with patch("composio.core.models.Tools"):
|
||||
with patch("composio.core.models.Toolkits"):
|
||||
with patch("composio.core.models.Triggers"):
|
||||
with patch("composio.core.models.AuthConfigs"):
|
||||
with patch("composio.core.models.ConnectedAccounts"):
|
||||
with patch(
|
||||
"composio.sdk.get_toolkit_versions"
|
||||
) as mock_get_versions:
|
||||
mock_get_versions.return_value = "latest"
|
||||
|
||||
# Test with string version
|
||||
Composio(toolkit_versions="v1.0.0")
|
||||
mock_get_versions.assert_called_once()
|
||||
|
||||
# Reset mock for next test
|
||||
mock_get_versions.reset_mock()
|
||||
|
||||
# Test with dict version
|
||||
versions_dict = {
|
||||
"github": "v1.0.0",
|
||||
"slack": "latest",
|
||||
}
|
||||
Composio(toolkit_versions=versions_dict)
|
||||
mock_get_versions.assert_called_once()
|
||||
|
||||
# Reset mock for next test
|
||||
mock_get_versions.reset_mock()
|
||||
|
||||
# Test with None (default)
|
||||
Composio()
|
||||
mock_get_versions.assert_called_once()
|
||||
|
||||
def test_sdk_env_var_integration(self):
|
||||
"""Test that SDK properly integrates with environment variables for toolkit versions."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COMPOSIO_API_KEY": "test-key",
|
||||
"COMPOSIO_TOOLKIT_VERSION_GITHUB": "v1.0.0",
|
||||
"COMPOSIO_TOOLKIT_VERSION_SLACK": "v2.0.0",
|
||||
},
|
||||
):
|
||||
with patch("composio.sdk.Tools") as mock_tools_class:
|
||||
with patch("composio.sdk.Toolkits"):
|
||||
with patch("composio.sdk.Triggers"):
|
||||
with patch("composio.sdk.AuthConfigs"):
|
||||
with patch("composio.sdk.ConnectedAccounts"):
|
||||
# Create SDK instance without explicit toolkit versions
|
||||
Composio()
|
||||
|
||||
# Verify that Tools was initialized with processed versions
|
||||
mock_tools_class.assert_called_once()
|
||||
call_args = mock_tools_class.call_args
|
||||
|
||||
# The toolkit_versions should be a dict from env vars
|
||||
toolkit_versions = call_args.kwargs.get(
|
||||
"toolkit_versions"
|
||||
)
|
||||
expected = {"github": "v1.0.0", "slack": "v2.0.0"}
|
||||
assert toolkit_versions == expected
|
||||
|
||||
def test_sdk_user_override_env_vars(self):
|
||||
"""Test that user-provided toolkit versions override environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COMPOSIO_API_KEY": "test-key",
|
||||
"COMPOSIO_TOOLKIT_VERSION_GITHUB": "env_version",
|
||||
"COMPOSIO_TOOLKIT_VERSION_SLACK": "env_slack",
|
||||
},
|
||||
):
|
||||
with patch("composio.sdk.Tools") as mock_tools_class:
|
||||
with patch("composio.sdk.Toolkits"):
|
||||
with patch("composio.sdk.Triggers"):
|
||||
with patch("composio.sdk.AuthConfigs"):
|
||||
with patch("composio.sdk.ConnectedAccounts"):
|
||||
# User provides override
|
||||
user_versions = {
|
||||
"github": "user_override",
|
||||
"jira": "user_jira",
|
||||
}
|
||||
Composio(toolkit_versions=user_versions)
|
||||
|
||||
# Verify Tools was initialized with merged versions
|
||||
mock_tools_class.assert_called_once()
|
||||
call_args = mock_tools_class.call_args
|
||||
|
||||
toolkit_versions = call_args.kwargs.get(
|
||||
"toolkit_versions"
|
||||
)
|
||||
expected = {
|
||||
"github": "user_override", # User override
|
||||
"slack": "env_slack", # From env
|
||||
"jira": "user_jira", # User provided
|
||||
}
|
||||
assert toolkit_versions == expected
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for composio.utils.sensitive_file_upload_paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.exceptions import SensitiveFilePathBlockedError
|
||||
from composio.utils.sensitive_file_upload_paths import (
|
||||
assert_safe_local_file_upload_path,
|
||||
is_blocked_sensitive_file_upload_path,
|
||||
)
|
||||
|
||||
|
||||
def test_allows_normal_project_files() -> None:
|
||||
p = Path("/tmp") / "composio-test" / "document.pdf"
|
||||
assert is_blocked_sensitive_file_upload_path(p) is False
|
||||
assert_safe_local_file_upload_path(p)
|
||||
|
||||
|
||||
def test_blocks_common_credential_directory_segments() -> None:
|
||||
home = Path.home()
|
||||
assert is_blocked_sensitive_file_upload_path(home / ".aws" / "credentials") is True
|
||||
assert is_blocked_sensitive_file_upload_path(home / ".ssh" / "id_ed25519") is True
|
||||
assert (
|
||||
is_blocked_sensitive_file_upload_path(home / ".claude" / "settings.json")
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_env_style_basenames() -> None:
|
||||
assert is_blocked_sensitive_file_upload_path(Path("/app/repo/.env")) is True
|
||||
assert is_blocked_sensitive_file_upload_path(Path("/app/repo/.env.local")) is True
|
||||
|
||||
|
||||
def test_blocks_default_private_key_basenames() -> None:
|
||||
assert is_blocked_sensitive_file_upload_path(Path("/tmp/id_ed25519")) is True
|
||||
|
||||
|
||||
def test_allows_public_key_by_basename() -> None:
|
||||
assert is_blocked_sensitive_file_upload_path(Path("/tmp/id_ed25519.pub")) is False
|
||||
|
||||
|
||||
def test_honors_additional_deny_segments() -> None:
|
||||
assert (
|
||||
is_blocked_sensitive_file_upload_path(
|
||||
Path("/data/secrets/x.txt"), additional_deny_segments=["secrets"]
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
is_blocked_sensitive_file_upload_path(
|
||||
Path("/data/ok/x.txt"), additional_deny_segments=["secrets"]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_after_symlink_resolves_to_sensitive_dir() -> None:
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root_p = Path(root)
|
||||
aws_dir = root_p / "nested" / ".aws"
|
||||
aws_dir.mkdir(parents=True)
|
||||
target = aws_dir / "creds"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
link = root_p / "innocent-name"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except OSError:
|
||||
pytest.skip("symlinks not supported")
|
||||
assert is_blocked_sensitive_file_upload_path(link) is True
|
||||
|
||||
|
||||
def test_assert_safe_raises() -> None:
|
||||
p = Path.home() / ".ssh" / "id_rsa"
|
||||
with pytest.raises(SensitiveFilePathBlockedError):
|
||||
assert_safe_local_file_upload_path(p)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
"""Tests for ToolRouterSessionFilesMount and RemoteFile."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from composio.core.models.tool_router_session_files import (
|
||||
RemoteFile,
|
||||
ToolRouterSessionFilesMount,
|
||||
)
|
||||
from composio.exceptions import RemoteFileDownloadError, ValidationError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock HTTP client with files API."""
|
||||
client = MagicMock()
|
||||
client.api_key = "test-api-key"
|
||||
|
||||
# Mock files.list
|
||||
mock_list_response = MagicMock()
|
||||
mock_list_response.items = []
|
||||
mock_list_response.next_cursor = None
|
||||
client.tool_router.session.files.list.return_value = mock_list_response
|
||||
|
||||
# Mock files.create_upload_url
|
||||
mock_upload_url_response = MagicMock()
|
||||
mock_upload_url_response.upload_url = "https://s3.example.com/upload"
|
||||
mock_upload_url_response.mount_relative_path = "test.txt"
|
||||
mock_upload_url_response.expires_at = "2026-01-01T00:00:00Z"
|
||||
mock_upload_url_response.sandbox_mount_prefix = "/mnt/files"
|
||||
client.tool_router.session.files.create_upload_url.return_value = (
|
||||
mock_upload_url_response
|
||||
)
|
||||
|
||||
# Mock files.create_download_url
|
||||
mock_download_response = MagicMock()
|
||||
mock_download_response.download_url = "https://s3.example.com/download"
|
||||
mock_download_response.expires_at = "2026-01-01T00:00:00Z"
|
||||
mock_download_response.mount_relative_path = "output/test.txt"
|
||||
mock_download_response.sandbox_mount_prefix = "/mnt/files"
|
||||
client.tool_router.session.files.create_download_url.return_value = (
|
||||
mock_download_response
|
||||
)
|
||||
|
||||
# Mock files.delete
|
||||
mock_delete_response = MagicMock()
|
||||
mock_delete_response.mount_relative_path = "deleted.txt"
|
||||
mock_delete_response.sandbox_mount_prefix = "/mnt/files"
|
||||
client.tool_router.session.files.delete.return_value = mock_delete_response
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files_mount(mock_client):
|
||||
"""Create ToolRouterSessionFilesMount with mocked client."""
|
||||
return ToolRouterSessionFilesMount(mock_client, "session_123")
|
||||
|
||||
|
||||
class TestToolRouterSessionFilesMount:
|
||||
"""Test ToolRouterSessionFilesMount."""
|
||||
|
||||
def test_list_root(self, files_mount, mock_client):
|
||||
"""Test listing root directory."""
|
||||
result = files_mount.list()
|
||||
|
||||
mock_client.tool_router.session.files.list.assert_called_once()
|
||||
call_args = mock_client.tool_router.session.files.list.call_args
|
||||
assert call_args[0][0] == "files" # mount_id positional
|
||||
assert call_args[1]["session_id"] == "session_123"
|
||||
assert result.items == []
|
||||
assert result.next_cursor is None
|
||||
|
||||
def test_list_with_path_and_pagination(self, files_mount, mock_client):
|
||||
"""Test list with path and pagination params."""
|
||||
files_mount.list(path="/documents", cursor="c123", limit=10)
|
||||
|
||||
call_kwargs = mock_client.tool_router.session.files.list.call_args[1]
|
||||
assert call_kwargs.get("mount_relative_prefix") == "documents"
|
||||
assert call_kwargs.get("cursor") == "c123"
|
||||
assert call_kwargs.get("limit") == 10.0
|
||||
|
||||
def test_upload_from_bytes_requires_mimetype_or_remote_path(self, files_mount):
|
||||
"""Test that buffer upload requires mimetype or remote_path."""
|
||||
with pytest.raises(ValidationError, match="mimetype or remote_path"):
|
||||
files_mount.upload(b"content")
|
||||
|
||||
def test_upload_from_bytes_with_remote_path(self, files_mount, mock_client):
|
||||
"""Test upload from bytes with remote_path."""
|
||||
with patch("requests.put") as mock_put:
|
||||
mock_put.return_value.status_code = 200
|
||||
mock_put.return_value.ok = True
|
||||
|
||||
result = files_mount.upload(
|
||||
b"hello world",
|
||||
remote_path="data.txt",
|
||||
mimetype="text/plain",
|
||||
)
|
||||
|
||||
assert isinstance(result, RemoteFile)
|
||||
assert result.mount_relative_path == "output/test.txt"
|
||||
mock_put.assert_called_once_with(
|
||||
"https://s3.example.com/upload",
|
||||
data=b"hello world",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
timeout=(5, 60),
|
||||
)
|
||||
mock_client.tool_router.session.files.create_upload_url.assert_called_once()
|
||||
mock_client.tool_router.session.files.create_download_url.assert_called_once()
|
||||
|
||||
def test_upload_raises_validation_error_on_timeout(self, files_mount):
|
||||
"""Test upload converts request timeouts to ValidationError."""
|
||||
with patch("requests.put", side_effect=requests.exceptions.Timeout("timeout")):
|
||||
with pytest.raises(ValidationError, match="Failed to upload file"):
|
||||
files_mount.upload(
|
||||
b"hello world",
|
||||
remote_path="data.txt",
|
||||
mimetype="text/plain",
|
||||
)
|
||||
|
||||
def test_upload_from_local_file(self, files_mount, mock_client, tmp_path):
|
||||
"""Test upload from local file path."""
|
||||
test_file = tmp_path / "report.pdf"
|
||||
test_file.write_bytes(b"pdf content")
|
||||
|
||||
with patch("requests.put") as mock_put:
|
||||
mock_put.return_value.status_code = 200
|
||||
mock_put.return_value.ok = True
|
||||
|
||||
result = files_mount.upload(str(test_file))
|
||||
|
||||
assert isinstance(result, RemoteFile)
|
||||
call_kwargs = (
|
||||
mock_client.tool_router.session.files.create_upload_url.call_args[1]
|
||||
)
|
||||
assert call_kwargs["mount_relative_path"] == "report.pdf"
|
||||
|
||||
def test_download(self, files_mount, mock_client):
|
||||
"""Test download returns RemoteFile."""
|
||||
result = files_mount.download("/output/report.pdf")
|
||||
|
||||
assert isinstance(result, RemoteFile)
|
||||
assert result.download_url == "https://s3.example.com/download"
|
||||
assert result.mount_relative_path == "output/test.txt"
|
||||
mock_client.tool_router.session.files.create_download_url.assert_called_once_with(
|
||||
"files",
|
||||
session_id="session_123",
|
||||
mount_relative_path="/output/report.pdf",
|
||||
)
|
||||
|
||||
def test_delete(self, files_mount, mock_client):
|
||||
"""Test delete calls API."""
|
||||
result = files_mount.delete("/temp/cache.json")
|
||||
|
||||
assert result.mount_relative_path == "deleted.txt"
|
||||
mock_client.tool_router.session.files.delete.assert_called_once_with(
|
||||
"files",
|
||||
session_id="session_123",
|
||||
mount_relative_path="/temp/cache.json",
|
||||
)
|
||||
|
||||
|
||||
class TestRemoteFile:
|
||||
"""Test RemoteFile."""
|
||||
|
||||
def test_filename_property(self):
|
||||
"""Test filename extracted from mount path."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="output/report.pdf",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
assert rf.filename == "report.pdf"
|
||||
|
||||
def test_buffer_success(self):
|
||||
"""Test buffer() fetches content."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="test.txt",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
with patch("requests.get") as mock_get:
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.ok = True
|
||||
mock_get.return_value.content = b"file content"
|
||||
|
||||
result = rf.buffer()
|
||||
assert result == b"file content"
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/file",
|
||||
timeout=(5, 60),
|
||||
)
|
||||
|
||||
def test_buffer_failure_raises_remote_file_download_error(self):
|
||||
"""Test buffer() raises RemoteFileDownloadError on HTTP error."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="test.txt",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
with patch("requests.get") as mock_get:
|
||||
mock_get.return_value.status_code = 404
|
||||
mock_get.return_value.ok = False
|
||||
mock_get.return_value.reason = "Not Found"
|
||||
|
||||
with pytest.raises(RemoteFileDownloadError) as exc_info:
|
||||
rf.buffer()
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.filename == "test.txt"
|
||||
|
||||
def test_buffer_timeout_raises_remote_file_download_error(self):
|
||||
"""Test buffer() converts request timeouts to RemoteFileDownloadError."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="test.txt",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
with patch("requests.get", side_effect=requests.exceptions.Timeout("timeout")):
|
||||
with pytest.raises(RemoteFileDownloadError) as exc_info:
|
||||
rf.buffer()
|
||||
|
||||
assert exc_info.value.filename == "test.txt"
|
||||
assert exc_info.value.download_url == "https://example.com/file"
|
||||
|
||||
def test_text(self):
|
||||
"""Test text() decodes UTF-8."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="test.txt",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
with patch.object(rf, "buffer", return_value=b"hello world"):
|
||||
assert rf.text() == "hello world"
|
||||
|
||||
def test_save_to_path(self, tmp_path):
|
||||
"""Test save() writes to specified path."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="test.txt",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
with patch.object(rf, "buffer", return_value=b"saved content"):
|
||||
out_path = rf.save(str(tmp_path / "output.txt"))
|
||||
|
||||
assert Path(out_path).read_bytes() == b"saved content"
|
||||
assert out_path.endswith("output.txt")
|
||||
|
||||
def test_save_default_location(self, tmp_path):
|
||||
"""Test save() without path uses default directory."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="report.pdf",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
with patch.object(rf, "buffer", return_value=b"pdf content"):
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
out_path = rf.save()
|
||||
|
||||
expected = tmp_path / ".composio" / "files" / "report.pdf"
|
||||
assert Path(out_path) == expected
|
||||
assert expected.read_bytes() == b"pdf content"
|
||||
|
||||
def test_save_default_location_rejects_dotdot_filename(self, tmp_path):
|
||||
"""SEC-316 defense-in-depth: a server-controlled ``mount_relative_path``
|
||||
whose basename is ``..`` (e.g. ``"foo/.."``) must be rejected before
|
||||
any bytes touch the disk, not silently fail with ``IsADirectoryError``."""
|
||||
rf = RemoteFile(
|
||||
expires_at="2026-01-01",
|
||||
mount_relative_path="foo/..",
|
||||
sandbox_mount_prefix="/mnt/files",
|
||||
download_url="https://example.com/file",
|
||||
)
|
||||
assert rf.filename == ".." # `Path("foo/..").name == ".."`
|
||||
|
||||
with patch.object(rf, "buffer", return_value=b"should not be written"):
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
with pytest.raises(ValidationError, match="Path traversal detected"):
|
||||
rf.save()
|
||||
|
||||
# The check raises before mkdir/write, so nothing was written under tmp_path.
|
||||
assert not (tmp_path / ".composio").exists()
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Tests for provider-facing tool schema aliases."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import importlib.util
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.exceptions import InvalidSchemaError
|
||||
from composio.utils.shared import (
|
||||
alias_tool_input_schema,
|
||||
json_schema_to_model,
|
||||
substitute_reserved_python_keywords,
|
||||
)
|
||||
|
||||
|
||||
PYTHON_ROOT = Path(__file__).resolve().parents[1]
|
||||
ANTHROPIC_PROPERTY_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$")
|
||||
|
||||
|
||||
def _load_module(monkeypatch, module_name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_alias_tool_input_schema_restores_nested_aliases_without_mutating_schema():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {"type": "string"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {"class": {"type": "string"}},
|
||||
"required": ["class"],
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"for": {"type": "string"}},
|
||||
"required": ["for"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["from", "payload"],
|
||||
}
|
||||
original = copy.deepcopy(schema)
|
||||
|
||||
aliases = alias_tool_input_schema(schema)
|
||||
|
||||
assert schema == original
|
||||
assert list(aliases.schema["properties"]) == ["from_rs", "payload", "items"]
|
||||
assert aliases.schema["required"] == ["from_rs", "payload"]
|
||||
assert aliases.schema["properties"]["payload"]["required"] == ["class_rs"]
|
||||
assert aliases.schema["properties"]["items"]["items"]["required"] == ["for_rs"]
|
||||
|
||||
arguments = {
|
||||
"from_rs": "sender@example.com",
|
||||
"payload": {"class_rs": "primary"},
|
||||
"items": [{"for_rs": "recipient@example.com"}],
|
||||
}
|
||||
assert aliases.restore_arguments(arguments) == {
|
||||
"from": "sender@example.com",
|
||||
"payload": {"class": "primary"},
|
||||
"items": [{"for": "recipient@example.com"}],
|
||||
}
|
||||
|
||||
|
||||
def test_alias_tool_input_schema_rejects_duplicate_aliases():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {"type": "string"},
|
||||
"from_rs": {"type": "string"},
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(InvalidSchemaError, match="duplicate Python parameter alias"):
|
||||
alias_tool_input_schema(schema)
|
||||
|
||||
|
||||
def test_legacy_keyword_helpers_use_tool_schema_aliases():
|
||||
long_name = "x" * 80
|
||||
schema = {
|
||||
"title": "ODataParams",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$top": {"type": "integer"},
|
||||
"@microsoft.graph.conflictBehavior": {"type": "string"},
|
||||
long_name: {"type": "string"},
|
||||
},
|
||||
"required": ["$top", long_name],
|
||||
}
|
||||
|
||||
aliased_schema, aliases = substitute_reserved_python_keywords(schema)
|
||||
|
||||
aliased_names = list(aliased_schema["properties"])
|
||||
assert aliased_names[0] == "param_top"
|
||||
assert aliased_names[1] == "param_microsoft_graph_conflictBehavior"
|
||||
assert len(aliased_names[2]) == 64
|
||||
assert all(ANTHROPIC_PROPERTY_RE.fullmatch(name) for name in aliased_names)
|
||||
assert aliased_schema["required"] == ["param_top", aliased_names[2]]
|
||||
assert aliases["param_top"] == "$top"
|
||||
assert aliases["param_microsoft_graph_conflictBehavior"] == (
|
||||
"@microsoft.graph.conflictBehavior"
|
||||
)
|
||||
assert aliases[aliased_names[2]] == long_name
|
||||
model = json_schema_to_model(aliased_schema)
|
||||
assert "param_top" in model.model_fields
|
||||
|
||||
|
||||
def test_alias_tool_input_schema_dereferences_refs_before_aliasing():
|
||||
schema = {
|
||||
"$ref": "#/$defs/SearchParams",
|
||||
"$defs": {
|
||||
"SearchParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {"$ref": "#/$defs/ODataFilter"},
|
||||
},
|
||||
"required": ["filter"],
|
||||
},
|
||||
"ODataFilter": {
|
||||
"type": "object",
|
||||
"properties": {"$top": {"type": "integer"}},
|
||||
"required": ["$top"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
aliases = alias_tool_input_schema(schema)
|
||||
|
||||
assert "$defs" not in aliases.schema
|
||||
assert aliases.schema["required"] == ["filter"]
|
||||
filter_schema = aliases.schema["properties"]["filter"]
|
||||
assert list(filter_schema["properties"]) == ["param_top"]
|
||||
assert filter_schema["required"] == ["param_top"]
|
||||
assert aliases.restore_arguments({"filter": {"param_top": 10}}) == {
|
||||
"filter": {"$top": 10}
|
||||
}
|
||||
|
||||
|
||||
def test_gemini_manual_response_restores_provider_visible_aliases(monkeypatch):
|
||||
google_module = types.ModuleType("google")
|
||||
genai_module = types.ModuleType("google.genai")
|
||||
genai_types_module = types.ModuleType("google.genai.types")
|
||||
|
||||
class FunctionResponse:
|
||||
def __init__(self, name, response):
|
||||
self.name = name
|
||||
self.response = response
|
||||
|
||||
class Part:
|
||||
def __init__(self, function_response):
|
||||
self.function_response = function_response
|
||||
|
||||
genai_types_module.FunctionResponse = FunctionResponse
|
||||
genai_types_module.Part = Part
|
||||
genai_module.types = genai_types_module
|
||||
google_module.genai = genai_module
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.genai", genai_module)
|
||||
monkeypatch.setitem(sys.modules, "google.genai.types", genai_types_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_gemini_provider",
|
||||
PYTHON_ROOT / "providers/gemini/composio_gemini/provider.py",
|
||||
)
|
||||
provider = provider_module.GeminiProvider()
|
||||
execute_tool = Mock(return_value={"successful": True, "data": {"ok": True}})
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_RESERVED",
|
||||
description="Tool with reserved parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"for": {"type": "string"}},
|
||||
"required": ["for"],
|
||||
},
|
||||
)
|
||||
provider.wrap_tools([tool], execute_tool)
|
||||
|
||||
response = SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
content=SimpleNamespace(
|
||||
parts=[
|
||||
SimpleNamespace(
|
||||
function_call=SimpleNamespace(
|
||||
name="TOOL_WITH_RESERVED",
|
||||
args={"for_rs": "recipient@example.com"},
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
function_responses, executed = provider.handle_response(response)
|
||||
|
||||
assert executed is True
|
||||
assert function_responses[0].function_response.name == "TOOL_WITH_RESERVED"
|
||||
execute_tool.assert_called_once_with(
|
||||
slug="TOOL_WITH_RESERVED", arguments={"for": "recipient@example.com"}
|
||||
)
|
||||
|
||||
|
||||
def test_google_adk_wrap_tool_aliases_signature_and_restores_arguments(monkeypatch):
|
||||
google_module = types.ModuleType("google")
|
||||
adk_module = types.ModuleType("google.adk")
|
||||
tools_module = types.ModuleType("google.adk.tools")
|
||||
|
||||
class FunctionTool:
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
|
||||
tools_module.FunctionTool = FunctionTool
|
||||
adk_module.tools = tools_module
|
||||
google_module.adk = adk_module
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.adk", adk_module)
|
||||
monkeypatch.setitem(sys.modules, "google.adk.tools", tools_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_google_adk_provider",
|
||||
PYTHON_ROOT / "providers/google_adk/composio_google_adk/provider.py",
|
||||
)
|
||||
provider = provider_module.GoogleAdkProvider()
|
||||
execute_tool = Mock(return_value={"successful": True, "data": {"ok": True}})
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_RESERVED",
|
||||
description="Tool with reserved parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {"type": "string", "description": "Sender"},
|
||||
"limit": {"type": "integer", "description": "Limit"},
|
||||
},
|
||||
"required": ["from"],
|
||||
},
|
||||
)
|
||||
|
||||
wrapped = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
assert list(inspect.signature(wrapped.func).parameters) == ["from_rs", "limit"]
|
||||
assert "from_rs: Sender" in (wrapped.func.__doc__ or "")
|
||||
wrapped.func(from_rs="sender@example.com", limit=10)
|
||||
execute_tool.assert_called_once_with(
|
||||
slug="TOOL_WITH_RESERVED",
|
||||
arguments={"from": "sender@example.com", "limit": 10},
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_wrap_tool_aliases_schema_and_restores_arguments(monkeypatch):
|
||||
anthropic_module = types.ModuleType("anthropic")
|
||||
types_module = types.ModuleType("anthropic.types")
|
||||
beta_module = types.ModuleType("anthropic.types.beta")
|
||||
beta_tool_use_module = types.ModuleType("anthropic.types.beta.beta_tool_use_block")
|
||||
message_module = types.ModuleType("anthropic.types.message")
|
||||
tool_param_module = types.ModuleType("anthropic.types.tool_param")
|
||||
tool_use_module = types.ModuleType("anthropic.types.tool_use_block")
|
||||
|
||||
class BetaToolUseBlock:
|
||||
pass
|
||||
|
||||
class Message:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
class ToolUseBlock:
|
||||
pass
|
||||
|
||||
beta_tool_use_module.BetaToolUseBlock = BetaToolUseBlock
|
||||
message_module.Message = Message
|
||||
tool_param_module.ToolParam = dict
|
||||
tool_use_module.ToolUseBlock = ToolUseBlock
|
||||
monkeypatch.setitem(sys.modules, "anthropic", anthropic_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types", types_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.beta", beta_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"anthropic.types.beta.beta_tool_use_block",
|
||||
beta_tool_use_module,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.message", message_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.tool_param", tool_param_module)
|
||||
monkeypatch.setitem(sys.modules, "anthropic.types.tool_use_block", tool_use_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_anthropic_provider",
|
||||
PYTHON_ROOT / "providers/anthropic/composio_anthropic/provider.py",
|
||||
)
|
||||
provider = provider_module.AnthropicProvider()
|
||||
provider.execute_tool = Mock(return_value={"successful": True})
|
||||
long_name = "x" * 80
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_ODATA",
|
||||
description="Tool with OData parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$top": {"type": "integer"},
|
||||
"@microsoft.graph.conflictBehavior": {"type": "string"},
|
||||
long_name: {"type": "string"},
|
||||
},
|
||||
"required": ["$top", long_name],
|
||||
},
|
||||
)
|
||||
|
||||
wrapped = provider.wrap_tool(tool)
|
||||
|
||||
aliased_names = list(wrapped["input_schema"]["properties"])
|
||||
assert aliased_names[0] == "param_top"
|
||||
assert aliased_names[1] == "param_microsoft_graph_conflictBehavior"
|
||||
assert len(aliased_names[2]) == 64
|
||||
assert all(ANTHROPIC_PROPERTY_RE.fullmatch(name) for name in aliased_names)
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
name="TOOL_WITH_ODATA",
|
||||
input={
|
||||
"param_top": 10,
|
||||
"param_microsoft_graph_conflictBehavior": "rename",
|
||||
aliased_names[2]: "value",
|
||||
},
|
||||
)
|
||||
provider.execute_tool_call(user_id="user", tool_call=tool_call)
|
||||
|
||||
provider.execute_tool.assert_called_once_with(
|
||||
slug="TOOL_WITH_ODATA",
|
||||
arguments={
|
||||
"$top": 10,
|
||||
"@microsoft.graph.conflictBehavior": "rename",
|
||||
long_name: "value",
|
||||
},
|
||||
modifiers=None,
|
||||
user_id="user",
|
||||
)
|
||||
|
||||
|
||||
def test_claude_agent_sdk_wrap_tool_aliases_schema_and_restores_arguments(monkeypatch):
|
||||
claude_agent_sdk_module = types.ModuleType("claude_agent_sdk")
|
||||
|
||||
def sdk_tool(name, description, input_schema):
|
||||
def decorator(fn):
|
||||
fn._tool_name = name
|
||||
fn._tool_description = description
|
||||
fn._input_schema = input_schema
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
claude_agent_sdk_module.McpSdkServerConfig = dict
|
||||
claude_agent_sdk_module.SdkMcpTool = object
|
||||
claude_agent_sdk_module.create_sdk_mcp_server = Mock()
|
||||
claude_agent_sdk_module.tool = sdk_tool
|
||||
monkeypatch.setitem(sys.modules, "claude_agent_sdk", claude_agent_sdk_module)
|
||||
|
||||
provider_module = _load_module(
|
||||
monkeypatch,
|
||||
"test_composio_claude_agent_sdk_provider",
|
||||
PYTHON_ROOT
|
||||
/ "providers/claude_agent_sdk/composio_claude_agent_sdk/provider.py",
|
||||
)
|
||||
provider = provider_module.ClaudeAgentSDKProvider()
|
||||
execute_tool = Mock(return_value={"successful": True})
|
||||
tool = SimpleNamespace(
|
||||
slug="TOOL_WITH_ODATA",
|
||||
description="Tool with OData parameters",
|
||||
input_parameters={
|
||||
"type": "object",
|
||||
"properties": {"$top": {"type": "integer"}},
|
||||
"required": ["$top"],
|
||||
},
|
||||
)
|
||||
|
||||
wrapped = provider.wrap_tool(tool, execute_tool)
|
||||
|
||||
assert list(wrapped._input_schema["properties"]) == ["param_top"]
|
||||
assert wrapped._input_schema["required"] == ["param_top"]
|
||||
result = asyncio.run(wrapped({"param_top": 5}))
|
||||
|
||||
assert result["content"][0]["type"] == "text"
|
||||
execute_tool.assert_called_once_with("TOOL_WITH_ODATA", {"$top": 5})
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Test toolkit version utilities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.core.types import ToolkitVersionParam
|
||||
from composio.utils.toolkit_version import get_toolkit_version, get_toolkit_versions
|
||||
|
||||
|
||||
class TestToolkitVersion:
|
||||
"""Test cases for toolkit version utilities."""
|
||||
|
||||
def test_get_toolkit_version_with_string(self):
|
||||
"""Test get_toolkit_version with string parameter."""
|
||||
result = get_toolkit_version("github", "v1.0.0")
|
||||
assert result == "v1.0.0"
|
||||
|
||||
def test_get_toolkit_version_with_dict(self):
|
||||
"""Test get_toolkit_version with dict parameter."""
|
||||
versions = {"github": "v1.0.0", "slack": "v2.0.0"}
|
||||
result = get_toolkit_version("github", versions)
|
||||
assert result == "v1.0.0"
|
||||
|
||||
def test_get_toolkit_version_with_dict_missing_key(self):
|
||||
"""Test get_toolkit_version with dict parameter missing key."""
|
||||
versions = {"slack": "v2.0.0"}
|
||||
result = get_toolkit_version("github", versions)
|
||||
assert result == "latest"
|
||||
|
||||
def test_get_toolkit_version_with_none(self):
|
||||
"""Test get_toolkit_version with None parameter."""
|
||||
result = get_toolkit_version("github", None)
|
||||
assert result == "latest"
|
||||
|
||||
def test_get_toolkit_version_with_env_var(self):
|
||||
"""Test get_toolkit_version with environment variable."""
|
||||
with pytest.MonkeyPatch().context():
|
||||
# The function doesn't directly use COMPOSIO_TOOLKIT_VERSION env var
|
||||
# It only looks at the toolkit_versions parameter
|
||||
result = get_toolkit_version("github", None)
|
||||
assert result == "latest"
|
||||
|
||||
def test_get_toolkit_versions_with_string(self):
|
||||
"""Test get_toolkit_versions with string parameter."""
|
||||
result = get_toolkit_versions("v1.0.0")
|
||||
assert result == "v1.0.0"
|
||||
|
||||
def test_get_toolkit_versions_with_dict(self):
|
||||
"""Test get_toolkit_versions with dict parameter."""
|
||||
versions = {"github": "v1.0.0", "slack": "v2.0.0"}
|
||||
result = get_toolkit_versions(versions)
|
||||
assert result == versions
|
||||
|
||||
def test_get_toolkit_versions_with_none(self):
|
||||
"""Test get_toolkit_versions with None parameter."""
|
||||
result = get_toolkit_versions(None)
|
||||
assert result == "latest"
|
||||
|
||||
def test_get_toolkit_versions_with_env_toolkit_specific(self):
|
||||
"""Test get_toolkit_versions with toolkit-specific environment variables."""
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v2.0.0")
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "v1.5.0")
|
||||
result = get_toolkit_versions(None)
|
||||
expected = {"github": "v2.0.0", "slack": "v1.5.0"}
|
||||
assert result == expected
|
||||
|
||||
def test_get_toolkit_versions_env_and_user_override(self):
|
||||
"""Test that user-provided versions override environment variables."""
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
# Set environment variables
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v1.0.0")
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "v2.0.0")
|
||||
|
||||
# User overrides should take precedence
|
||||
user_versions = {"github": "v3.0.0", "jira": "v4.0.0"}
|
||||
result = get_toolkit_versions(user_versions)
|
||||
|
||||
expected = {
|
||||
"github": "v3.0.0", # User override
|
||||
"slack": "v2.0.0", # From env
|
||||
"jira": "v4.0.0", # User provided
|
||||
}
|
||||
assert result == expected
|
||||
|
||||
def test_get_toolkit_version_with_env_specific_toolkit(self):
|
||||
"""Test get_toolkit_version works with environment-configured versions."""
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v2.5.0")
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "v1.8.0")
|
||||
|
||||
# Get versions from environment
|
||||
env_versions = get_toolkit_versions(None)
|
||||
|
||||
# Test specific toolkit version retrieval
|
||||
github_version = get_toolkit_version("github", env_versions)
|
||||
slack_version = get_toolkit_version("slack", env_versions)
|
||||
unknown_version = get_toolkit_version("unknown", env_versions)
|
||||
|
||||
assert github_version == "v2.5.0"
|
||||
assert slack_version == "v1.8.0"
|
||||
assert unknown_version == "latest"
|
||||
|
||||
def test_mixed_case_env_vars_normalized(self):
|
||||
"""Test that mixed case environment variable names are normalized."""
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
# Environment variables are typically uppercase
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v1.0.0")
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_OPENAI", "v2.0.0")
|
||||
|
||||
result = get_toolkit_versions(None)
|
||||
|
||||
# Should be normalized to lowercase
|
||||
assert "github" in result
|
||||
assert "openai" in result
|
||||
assert result["github"] == "v1.0.0"
|
||||
assert result["openai"] == "v2.0.0"
|
||||
|
||||
def test_user_dict_case_normalization(self):
|
||||
"""Test that user-provided dictionary keys are normalized to lowercase."""
|
||||
user_versions = {"GitHub": "v1.0.0", "SLACK": "v2.0.0", "OpenAI": "v3.0.0"}
|
||||
|
||||
result = get_toolkit_versions(user_versions)
|
||||
|
||||
expected = {"github": "v1.0.0", "slack": "v2.0.0", "openai": "v3.0.0"}
|
||||
assert result == expected
|
||||
|
||||
def test_get_toolkit_version_lookup_is_case_insensitive(self):
|
||||
"""The lookup slug is matched case-insensitively.
|
||||
|
||||
Version maps are keyed by normalized (lowercase) slugs (write side
|
||||
covered by test_user_dict_case_normalization), so the read side must
|
||||
resolve any-cased slugs instead of silently returning 'latest'.
|
||||
"""
|
||||
versions = {"github": "v1.0.0"}
|
||||
|
||||
assert get_toolkit_version("GITHUB", versions) == "v1.0.0"
|
||||
assert get_toolkit_version("GitHub", versions) == "v1.0.0"
|
||||
assert get_toolkit_version("github", versions) == "v1.0.0"
|
||||
|
||||
def test_priority_order_matches_typescript(self):
|
||||
"""Test that priority order matches TypeScript implementation.
|
||||
|
||||
Priority order should be:
|
||||
1. String global version (overrides everything)
|
||||
2. User-provided dict overrides env vars
|
||||
3. Environment variables
|
||||
4. Fallback to 'latest'
|
||||
"""
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
# Test 1: String global version overrides everything
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "env_version")
|
||||
result = get_toolkit_version("github", "global_version")
|
||||
assert result == "global_version"
|
||||
|
||||
# Test 2: User dict overrides env vars
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "env_version")
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "env_slack")
|
||||
user_dict = {"github": "user_override"}
|
||||
result = get_toolkit_versions(user_dict)
|
||||
expected = {
|
||||
"github": "user_override", # User override
|
||||
"slack": "env_slack", # From env
|
||||
}
|
||||
assert result == expected
|
||||
|
||||
# Test 3: Environment variables when no user input
|
||||
result = get_toolkit_versions(None)
|
||||
expected = {"github": "env_version", "slack": "env_slack"}
|
||||
assert result == expected
|
||||
|
||||
def test_empty_user_dict_uses_env_vars(self):
|
||||
"""Test that empty user dict still uses environment variables."""
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v1.0.0")
|
||||
|
||||
# Empty dict should still use env vars
|
||||
result = get_toolkit_versions({})
|
||||
expected = {"github": "v1.0.0"}
|
||||
assert result == expected
|
||||
|
||||
def test_toolkit_version_param_type_annotation(self):
|
||||
"""Test ToolkitVersionParam type annotation."""
|
||||
import typing
|
||||
|
||||
# Test that ToolkitVersionParam is a Union type
|
||||
assert hasattr(ToolkitVersionParam, "__origin__")
|
||||
assert ToolkitVersionParam.__origin__ is typing.Union
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Type inference verification tests for the Composio SDK.
|
||||
|
||||
This file verifies that type checkers (mypy) correctly infer
|
||||
provider-specific return types from `Composio.tools.get()`.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- openai (for type stubs)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from composio import Composio
|
||||
from composio.core.provider._openai import (
|
||||
OpenAIProvider,
|
||||
OpenAITool,
|
||||
OpenAIToolCollection,
|
||||
)
|
||||
from composio.core.provider._openai_responses import (
|
||||
OpenAIResponsesProvider,
|
||||
ResponsesTool,
|
||||
ResponsesToolCollection,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam
|
||||
|
||||
|
||||
def test_openai_provider_explicit() -> None:
|
||||
"""Verify OpenAI provider with explicit type returns list[ChatCompletionToolParam]."""
|
||||
composio: Composio[OpenAITool, OpenAIToolCollection] = Composio(
|
||||
provider=OpenAIProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[ChatCompletionToolParam]
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[ChatCompletionToolParam])
|
||||
|
||||
|
||||
def test_openai_provider_inferred() -> None:
|
||||
"""Verify OpenAI provider with inferred type returns list[ChatCompletionToolParam]."""
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[ChatCompletionToolParam]
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[ChatCompletionToolParam])
|
||||
|
||||
|
||||
def test_default_provider() -> None:
|
||||
"""Verify default provider (OpenAI) returns list[ChatCompletionToolParam]."""
|
||||
# Default provider is OpenAIProvider
|
||||
composio = Composio()
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[ChatCompletionToolParam]
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[ChatCompletionToolParam])
|
||||
|
||||
|
||||
def test_openai_provider_slug_parameter() -> None:
|
||||
"""Verify slug parameter returns same type."""
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[ChatCompletionToolParam])
|
||||
|
||||
|
||||
def test_openai_provider_tools_parameter() -> None:
|
||||
"""Verify tools parameter returns same type."""
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
tools = composio.tools.get(user_id="test", tools=["GITHUB_CREATE_REPO"])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[ChatCompletionToolParam])
|
||||
|
||||
|
||||
def test_openai_provider_search_parameter() -> None:
|
||||
"""Verify search parameter returns same type."""
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
tools = composio.tools.get(user_id="test", search="github")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[ChatCompletionToolParam])
|
||||
|
||||
|
||||
def test_openai_responses_provider() -> None:
|
||||
"""Verify OpenAI Responses provider returns list[dict]."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=OpenAIResponsesProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[Dict[str, Any]]
|
||||
# Note: ResponsesTool is typed as Dict[str, Any]
|
||||
assert_type(tools, list[dict[str, Any]])
|
||||
|
||||
|
||||
def test_openai_responses_provider_explicit() -> None:
|
||||
"""Verify OpenAI Responses provider with explicit generic types."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[ResponsesTool, ResponsesToolCollection] = Composio(
|
||||
provider=OpenAIResponsesProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
assert_type(tools, list[dict[str, Any]])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for Anthropic provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[ToolParam]`
|
||||
when using the Anthropic provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_anthropic.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-anthropic
|
||||
- anthropic
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from anthropic.types.tool_param import ToolParam
|
||||
|
||||
from composio_anthropic import AnthropicProvider
|
||||
|
||||
|
||||
def test_anthropic_provider_toolkits() -> None:
|
||||
"""Verify Anthropic provider returns list[ToolParam] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[ToolParam, list[ToolParam]] = Composio(
|
||||
provider=AnthropicProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[ToolParam]
|
||||
assert_type(tools, list[ToolParam])
|
||||
|
||||
|
||||
def test_anthropic_provider_slug() -> None:
|
||||
"""Verify Anthropic provider returns list[ToolParam] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[ToolParam, list[ToolParam]] = Composio(
|
||||
provider=AnthropicProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[ToolParam])
|
||||
|
||||
|
||||
def test_anthropic_provider_tools_list() -> None:
|
||||
"""Verify Anthropic provider returns list[ToolParam] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[ToolParam, list[ToolParam]] = Composio(
|
||||
provider=AnthropicProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[ToolParam])
|
||||
|
||||
|
||||
def test_anthropic_provider_search() -> None:
|
||||
"""Verify Anthropic provider returns list[ToolParam] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[ToolParam, list[ToolParam]] = Composio(
|
||||
provider=AnthropicProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[ToolParam])
|
||||
|
||||
|
||||
def test_anthropic_provider_inferred() -> None:
|
||||
"""Verify Anthropic provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=AnthropicProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[ToolParam] from provider type
|
||||
assert_type(tools, list[ToolParam])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for Autogen provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[FunctionTool]`
|
||||
when using the Autogen provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_autogen.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-autogen
|
||||
- autogen-core
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from autogen_core.tools import FunctionTool
|
||||
|
||||
from composio_autogen import AutogenProvider
|
||||
|
||||
|
||||
def test_autogen_provider_toolkits() -> None:
|
||||
"""Verify Autogen provider returns list[FunctionTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=AutogenProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[FunctionTool]
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_autogen_provider_slug() -> None:
|
||||
"""Verify Autogen provider returns list[FunctionTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=AutogenProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_autogen_provider_tools_list() -> None:
|
||||
"""Verify Autogen provider returns list[FunctionTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=AutogenProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_autogen_provider_search() -> None:
|
||||
"""Verify Autogen provider returns list[FunctionTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=AutogenProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_autogen_provider_inferred() -> None:
|
||||
"""Verify Autogen provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=AutogenProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[FunctionTool] from provider type
|
||||
assert_type(tools, list[FunctionTool])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for Claude Agent SDK provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[SdkMcpTool]`
|
||||
when using the Claude Agent SDK provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_claude_agent_sdk.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-claude-agent-sdk
|
||||
- claude-agent-sdk
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from claude_agent_sdk import SdkMcpTool
|
||||
|
||||
from composio_claude_agent_sdk import ClaudeAgentSDKProvider
|
||||
|
||||
|
||||
def test_claude_agent_sdk_provider_toolkits() -> None:
|
||||
"""Verify Claude Agent SDK provider returns list[SdkMcpTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[SdkMcpTool, list[SdkMcpTool]] = Composio(
|
||||
provider=ClaudeAgentSDKProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[SdkMcpTool]
|
||||
assert_type(tools, list[SdkMcpTool])
|
||||
|
||||
|
||||
def test_claude_agent_sdk_provider_slug() -> None:
|
||||
"""Verify Claude Agent SDK provider returns list[SdkMcpTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[SdkMcpTool, list[SdkMcpTool]] = Composio(
|
||||
provider=ClaudeAgentSDKProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[SdkMcpTool])
|
||||
|
||||
|
||||
def test_claude_agent_sdk_provider_tools_list() -> None:
|
||||
"""Verify Claude Agent SDK provider returns list[SdkMcpTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[SdkMcpTool, list[SdkMcpTool]] = Composio(
|
||||
provider=ClaudeAgentSDKProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[SdkMcpTool])
|
||||
|
||||
|
||||
def test_claude_agent_sdk_provider_search() -> None:
|
||||
"""Verify Claude Agent SDK provider returns list[SdkMcpTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[SdkMcpTool, list[SdkMcpTool]] = Composio(
|
||||
provider=ClaudeAgentSDKProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[SdkMcpTool])
|
||||
|
||||
|
||||
def test_claude_agent_sdk_provider_inferred() -> None:
|
||||
"""Verify Claude Agent SDK provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=ClaudeAgentSDKProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[SdkMcpTool] from provider type
|
||||
assert_type(tools, list[SdkMcpTool])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for CrewAI provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[BaseTool]`
|
||||
when using the CrewAI provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_crewai.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-crewai
|
||||
- crewai
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
from composio_crewai import CrewAIProvider
|
||||
|
||||
|
||||
def test_crewai_provider_toolkits() -> None:
|
||||
"""Verify CrewAI provider returns list[BaseTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[BaseTool, list[BaseTool]] = Composio(
|
||||
provider=CrewAIProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[BaseTool]
|
||||
assert_type(tools, list[BaseTool])
|
||||
|
||||
|
||||
def test_crewai_provider_slug() -> None:
|
||||
"""Verify CrewAI provider returns list[BaseTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[BaseTool, list[BaseTool]] = Composio(
|
||||
provider=CrewAIProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[BaseTool])
|
||||
|
||||
|
||||
def test_crewai_provider_tools_list() -> None:
|
||||
"""Verify CrewAI provider returns list[BaseTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[BaseTool, list[BaseTool]] = Composio(
|
||||
provider=CrewAIProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[BaseTool])
|
||||
|
||||
|
||||
def test_crewai_provider_search() -> None:
|
||||
"""Verify CrewAI provider returns list[BaseTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[BaseTool, list[BaseTool]] = Composio(
|
||||
provider=CrewAIProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[BaseTool])
|
||||
|
||||
|
||||
def test_crewai_provider_inferred() -> None:
|
||||
"""Verify CrewAI provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=CrewAIProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[BaseTool] from provider type
|
||||
assert_type(tools, list[BaseTool])
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Type inference verification for CUSTOM providers.
|
||||
|
||||
This test proves the new generic approach works for user-defined providers.
|
||||
This is the KEY BENEFIT of the two-parameter generic approach over @overload:
|
||||
custom providers get proper type inference without requiring any changes
|
||||
to the Composio SDK.
|
||||
|
||||
Run: mypy tests/test_type_inference_custom_provider.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Sequence
|
||||
import typing as t
|
||||
|
||||
from composio import Composio
|
||||
from composio.client.types import Tool
|
||||
from composio.core.provider.none_agentic import NonAgenticProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
# ============================================
|
||||
# Custom provider defined by a hypothetical user
|
||||
# ============================================
|
||||
|
||||
|
||||
class MyCustomTool(t.TypedDict):
|
||||
"""User's custom tool format."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict
|
||||
|
||||
|
||||
MyCustomToolCollection = t.List[MyCustomTool]
|
||||
|
||||
|
||||
class MyCustomProvider(
|
||||
NonAgenticProvider[MyCustomTool, MyCustomToolCollection], name="my-custom"
|
||||
):
|
||||
"""User-defined custom provider."""
|
||||
|
||||
def wrap_tool(self, tool: Tool) -> MyCustomTool:
|
||||
return MyCustomTool(
|
||||
name=tool.slug,
|
||||
description=tool.description or "",
|
||||
parameters=tool.input_parameters or {},
|
||||
)
|
||||
|
||||
def wrap_tools(self, tools: Sequence[Tool]) -> MyCustomToolCollection:
|
||||
return [self.wrap_tool(tool) for tool in tools]
|
||||
|
||||
|
||||
# ============================================
|
||||
# Type inference tests
|
||||
# ============================================
|
||||
|
||||
|
||||
def test_custom_provider_type_inference() -> None:
|
||||
"""Custom provider correctly infers return type.
|
||||
|
||||
THIS IS THE KEY TEST: Custom providers get proper type inference
|
||||
without requiring any changes to the Composio SDK.
|
||||
"""
|
||||
composio = Composio(provider=MyCustomProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# The return type should be inferred as list[MyCustomTool]
|
||||
assert_type(tools, list[MyCustomTool])
|
||||
|
||||
|
||||
def test_custom_provider_explicit_annotation() -> None:
|
||||
"""Custom provider works with explicit type annotation."""
|
||||
composio: Composio[MyCustomTool, MyCustomToolCollection] = Composio(
|
||||
provider=MyCustomProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools, list[MyCustomTool])
|
||||
|
||||
|
||||
def test_custom_provider_all_parameters() -> None:
|
||||
"""Custom provider type inference works with all get() parameters."""
|
||||
composio = Composio(provider=MyCustomProvider())
|
||||
|
||||
# Test different parameter combinations
|
||||
tools_by_toolkit = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
tools_by_slug = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
tools_by_search = composio.tools.get(user_id="test", search="repository")
|
||||
tools_by_list = composio.tools.get(user_id="test", tools=["GITHUB_CREATE_REPO"])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert_type(tools_by_toolkit, list[MyCustomTool])
|
||||
assert_type(tools_by_slug, list[MyCustomTool])
|
||||
assert_type(tools_by_search, list[MyCustomTool])
|
||||
assert_type(tools_by_list, list[MyCustomTool])
|
||||
|
||||
|
||||
def test_custom_provider_tools_attribute_type() -> None:
|
||||
"""Verify the tools attribute has correct type."""
|
||||
from composio.core.models.tools import Tools
|
||||
|
||||
composio = Composio(provider=MyCustomProvider())
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# The tools attribute should be Tools[MyCustomTool, list[MyCustomTool]]
|
||||
assert_type(composio.tools, Tools[MyCustomTool, list[MyCustomTool]])
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Type inference verification tests for Gemini provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[Any]`
|
||||
when using the Gemini provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_gemini.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-gemini
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from composio_gemini import GeminiProvider
|
||||
|
||||
|
||||
def test_gemini_provider_toolkits() -> None:
|
||||
"""Verify Gemini provider returns list[Callable[..., Any]] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[Callable[..., Any], list[Callable[..., Any]]] = Composio(
|
||||
provider=GeminiProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[Callable[..., Any]]
|
||||
assert_type(tools, list[Callable[..., Any]])
|
||||
|
||||
|
||||
def test_gemini_provider_slug() -> None:
|
||||
"""Verify Gemini provider returns list[Callable[..., Any]] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[Callable[..., Any], list[Callable[..., Any]]] = Composio(
|
||||
provider=GeminiProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[Callable[..., Any]])
|
||||
|
||||
|
||||
def test_gemini_provider_tools_list() -> None:
|
||||
"""Verify Gemini provider returns list[Callable[..., Any]] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[Callable[..., Any], list[Callable[..., Any]]] = Composio(
|
||||
provider=GeminiProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[Callable[..., Any]])
|
||||
|
||||
|
||||
def test_gemini_provider_search() -> None:
|
||||
"""Verify Gemini provider returns list[Callable[..., Any]] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[Callable[..., Any], list[Callable[..., Any]]] = Composio(
|
||||
provider=GeminiProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[Callable[..., Any]])
|
||||
|
||||
|
||||
def test_gemini_provider_inferred() -> None:
|
||||
"""Verify Gemini provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=GeminiProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[Callable[..., Any]] from provider type
|
||||
assert_type(tools, list[Callable[..., Any]])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for Google (Vertex AI) provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[FunctionDeclaration]`
|
||||
when using the Google provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_google.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-google
|
||||
- google-cloud-aiplatform
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from vertexai.generative_models import FunctionDeclaration
|
||||
|
||||
from composio_google import GoogleProvider
|
||||
|
||||
|
||||
def test_google_provider_toolkits() -> None:
|
||||
"""Verify Google provider returns list[FunctionDeclaration] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionDeclaration, list[FunctionDeclaration]] = Composio(
|
||||
provider=GoogleProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[FunctionDeclaration]
|
||||
assert_type(tools, list[FunctionDeclaration])
|
||||
|
||||
|
||||
def test_google_provider_slug() -> None:
|
||||
"""Verify Google provider returns list[FunctionDeclaration] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionDeclaration, list[FunctionDeclaration]] = Composio(
|
||||
provider=GoogleProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[FunctionDeclaration])
|
||||
|
||||
|
||||
def test_google_provider_tools_list() -> None:
|
||||
"""Verify Google provider returns list[FunctionDeclaration] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionDeclaration, list[FunctionDeclaration]] = Composio(
|
||||
provider=GoogleProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[FunctionDeclaration])
|
||||
|
||||
|
||||
def test_google_provider_search() -> None:
|
||||
"""Verify Google provider returns list[FunctionDeclaration] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionDeclaration, list[FunctionDeclaration]] = Composio(
|
||||
provider=GoogleProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[FunctionDeclaration])
|
||||
|
||||
|
||||
def test_google_provider_inferred() -> None:
|
||||
"""Verify Google provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=GoogleProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[FunctionDeclaration] from provider type
|
||||
assert_type(tools, list[FunctionDeclaration])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for Google ADK provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[FunctionTool]`
|
||||
when using the Google ADK provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_google_adk.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-google-adk
|
||||
- google-adk
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from google.adk.tools import FunctionTool
|
||||
|
||||
from composio_google_adk import GoogleAdkProvider
|
||||
|
||||
|
||||
def test_google_adk_provider_toolkits() -> None:
|
||||
"""Verify Google ADK provider returns list[FunctionTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=GoogleAdkProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[FunctionTool]
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_google_adk_provider_slug() -> None:
|
||||
"""Verify Google ADK provider returns list[FunctionTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=GoogleAdkProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_google_adk_provider_tools_list() -> None:
|
||||
"""Verify Google ADK provider returns list[FunctionTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=GoogleAdkProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_google_adk_provider_search() -> None:
|
||||
"""Verify Google ADK provider returns list[FunctionTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=GoogleAdkProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_google_adk_provider_inferred() -> None:
|
||||
"""Verify Google ADK provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=GoogleAdkProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[FunctionTool] from provider type
|
||||
assert_type(tools, list[FunctionTool])
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Type inference verification tests for LangChain provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[StructuredTool]`
|
||||
when using the LangChain provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_langchain.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-langchain
|
||||
- langchain-core
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from composio_langchain.provider import StructuredTool
|
||||
|
||||
from composio_langchain import LangchainProvider
|
||||
|
||||
|
||||
def test_langchain_provider_toolkits() -> None:
|
||||
"""Verify LangChain provider returns list[StructuredTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LangchainProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[StructuredTool]
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langchain_provider_slug() -> None:
|
||||
"""Verify LangChain provider returns list[StructuredTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LangchainProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langchain_provider_tools_list() -> None:
|
||||
"""Verify LangChain provider returns list[StructuredTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LangchainProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langchain_provider_search() -> None:
|
||||
"""Verify LangChain provider returns list[StructuredTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LangchainProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langchain_provider_inferred() -> None:
|
||||
"""Verify LangChain provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=LangchainProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[StructuredTool] from provider type
|
||||
# Note: The provider's StructuredTool is a subclass of langchain_core's StructuredTool
|
||||
assert_type(tools, list[StructuredTool])
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Type inference verification tests for LangGraph provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[StructuredTool]`
|
||||
when using the LangGraph provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_langgraph.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-langgraph
|
||||
- langchain-core
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from composio_langgraph.provider import StructuredTool
|
||||
|
||||
from composio_langgraph import LanggraphProvider
|
||||
|
||||
|
||||
def test_langgraph_provider_toolkits() -> None:
|
||||
"""Verify LangGraph provider returns list[StructuredTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LanggraphProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[StructuredTool]
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langgraph_provider_slug() -> None:
|
||||
"""Verify LangGraph provider returns list[StructuredTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LanggraphProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langgraph_provider_tools_list() -> None:
|
||||
"""Verify LangGraph provider returns list[StructuredTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LanggraphProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langgraph_provider_search() -> None:
|
||||
"""Verify LangGraph provider returns list[StructuredTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[StructuredTool, list[StructuredTool]] = Composio(
|
||||
provider=LanggraphProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[StructuredTool])
|
||||
|
||||
|
||||
def test_langgraph_provider_inferred() -> None:
|
||||
"""Verify LangGraph provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=LanggraphProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[StructuredTool] from provider type
|
||||
# Note: The provider's StructuredTool is a subclass of langchain_core's StructuredTool
|
||||
assert_type(tools, list[StructuredTool])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for LlamaIndex provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[FunctionTool]`
|
||||
when using the LlamaIndex provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_llamaindex.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-llamaindex
|
||||
- llama-index-core
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from llama_index.core.tools import FunctionTool
|
||||
|
||||
from composio_llamaindex import LlamaIndexProvider
|
||||
|
||||
|
||||
def test_llamaindex_provider_toolkits() -> None:
|
||||
"""Verify LlamaIndex provider returns list[FunctionTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=LlamaIndexProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[FunctionTool]
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_llamaindex_provider_slug() -> None:
|
||||
"""Verify LlamaIndex provider returns list[FunctionTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=LlamaIndexProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_llamaindex_provider_tools_list() -> None:
|
||||
"""Verify LlamaIndex provider returns list[FunctionTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=LlamaIndexProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_llamaindex_provider_search() -> None:
|
||||
"""Verify LlamaIndex provider returns list[FunctionTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=LlamaIndexProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_llamaindex_provider_inferred() -> None:
|
||||
"""Verify LlamaIndex provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=LlamaIndexProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[FunctionTool] from provider type
|
||||
assert_type(tools, list[FunctionTool])
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Type inference verification tests for OpenAI Agents provider.
|
||||
|
||||
This file verifies that type checkers correctly infer `list[FunctionTool]`
|
||||
when using the OpenAI Agents provider with Composio.
|
||||
|
||||
**This file is NOT executed at runtime.** It is analyzed statically by type
|
||||
checkers to verify that type inference works correctly.
|
||||
|
||||
Run: mypy tests/test_type_inference_openai_agents.py
|
||||
|
||||
Requirements:
|
||||
- composio (core SDK)
|
||||
- composio-openai-agents
|
||||
- openai-agents
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from composio import Composio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from agents import FunctionTool
|
||||
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
|
||||
|
||||
def test_openai_agents_provider_toolkits() -> None:
|
||||
"""Verify OpenAI Agents provider returns list[FunctionTool] for toolkits query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=OpenAIAgentsProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Type checker should infer: list[FunctionTool]
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_openai_agents_provider_slug() -> None:
|
||||
"""Verify OpenAI Agents provider returns list[FunctionTool] for slug query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=OpenAIAgentsProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", slug="GITHUB_CREATE_REPO")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_openai_agents_provider_tools_list() -> None:
|
||||
"""Verify OpenAI Agents provider returns list[FunctionTool] for tools list query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=OpenAIAgentsProvider()
|
||||
)
|
||||
tools = composio.tools.get(
|
||||
user_id="test",
|
||||
tools=["GITHUB_CREATE_REPO", "GITHUB_GET_USER"],
|
||||
)
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_openai_agents_provider_search() -> None:
|
||||
"""Verify OpenAI Agents provider returns list[FunctionTool] for search query."""
|
||||
if TYPE_CHECKING:
|
||||
composio: Composio[FunctionTool, list[FunctionTool]] = Composio(
|
||||
provider=OpenAIAgentsProvider()
|
||||
)
|
||||
tools = composio.tools.get(user_id="test", search="github repository")
|
||||
|
||||
assert_type(tools, list[FunctionTool])
|
||||
|
||||
|
||||
def test_openai_agents_provider_inferred() -> None:
|
||||
"""Verify OpenAI Agents provider type is correctly inferred without explicit annotation."""
|
||||
if TYPE_CHECKING:
|
||||
composio = Composio(provider=OpenAIAgentsProvider())
|
||||
tools = composio.tools.get(user_id="test", toolkits=["github"])
|
||||
|
||||
# Should infer list[FunctionTool] from provider type
|
||||
assert_type(tools, list[FunctionTool])
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for the auto-upload directory allowlist (parity with TS sdk)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.core.models.base import allow_tracking
|
||||
from composio.exceptions import (
|
||||
FileUploadPathNotAllowedError,
|
||||
SDKFileNotFoundError,
|
||||
)
|
||||
from composio.utils.upload_dir_allowlist import (
|
||||
assert_path_inside_upload_dirs,
|
||||
get_default_upload_dir,
|
||||
resolve_effective_upload_allowlist,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_telemetry():
|
||||
token = allow_tracking.set(False)
|
||||
yield
|
||||
allow_tracking.reset(token)
|
||||
|
||||
|
||||
class TestResolveEffectiveUploadAllowlist:
|
||||
def test_none_returns_default(self):
|
||||
result = resolve_effective_upload_allowlist(None)
|
||||
default = get_default_upload_dir()
|
||||
assert default is not None, "Expected a default upload dir in this env."
|
||||
assert result == [default]
|
||||
|
||||
def test_empty_list_replaces_default(self):
|
||||
assert resolve_effective_upload_allowlist([]) == []
|
||||
|
||||
def test_false_means_no_local_paths(self):
|
||||
# Explicit "reject all local paths during auto-upload". Equivalent to []
|
||||
# but reads better at call sites.
|
||||
assert resolve_effective_upload_allowlist(False) == []
|
||||
|
||||
def test_explicit_list_replaces_default(self, tmp_path: Path):
|
||||
result = resolve_effective_upload_allowlist([str(tmp_path)])
|
||||
assert result == [tmp_path.resolve()]
|
||||
|
||||
def test_tilde_is_expanded(self):
|
||||
result = resolve_effective_upload_allowlist(["~"])
|
||||
assert result == [Path.home().resolve()]
|
||||
|
||||
def test_skips_blank_and_non_string_entries(self, tmp_path: Path):
|
||||
result = resolve_effective_upload_allowlist([str(tmp_path), "", " "])
|
||||
assert result == [tmp_path.resolve()]
|
||||
|
||||
def test_deduplicates_entries(self, tmp_path: Path):
|
||||
result = resolve_effective_upload_allowlist([str(tmp_path), str(tmp_path)])
|
||||
assert result == [tmp_path.resolve()]
|
||||
|
||||
|
||||
class TestAssertPathInsideUploadDirs:
|
||||
def test_accepts_direct_child(self, tmp_path: Path):
|
||||
allowed = tmp_path
|
||||
f = allowed / "a.txt"
|
||||
f.write_text("hi")
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_accepts_nested_child(self, tmp_path: Path):
|
||||
allowed = tmp_path
|
||||
nested = allowed / "sub" / "deeper"
|
||||
nested.mkdir(parents=True)
|
||||
f = nested / "a.txt"
|
||||
f.write_text("hi")
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_rejects_path_outside(self, tmp_path: Path):
|
||||
allowed = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
allowed.mkdir()
|
||||
outside.mkdir()
|
||||
f = outside / "a.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_enforces_component_boundary(self, tmp_path: Path):
|
||||
# /tmp/foo must NOT accept /tmp/foo-bar/x.
|
||||
allowed = tmp_path / "foo"
|
||||
sibling = tmp_path / "foo-bar"
|
||||
allowed.mkdir()
|
||||
sibling.mkdir()
|
||||
f = sibling / "x.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
|
||||
def test_raises_not_found_when_missing(self, tmp_path: Path):
|
||||
allowed = tmp_path
|
||||
missing = tmp_path / "does_not_exist.txt"
|
||||
with pytest.raises(SDKFileNotFoundError) as excinfo:
|
||||
assert_path_inside_upload_dirs(str(missing), [allowed])
|
||||
assert "does not exist on disk" in str(excinfo.value)
|
||||
assert "file_upload_dirs" in str(excinfo.value)
|
||||
|
||||
def test_empty_allowlist_fails_closed(self, tmp_path: Path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
|
||||
assert_path_inside_upload_dirs(str(f), [])
|
||||
msg = str(excinfo.value)
|
||||
assert "no upload directories are configured" in msg
|
||||
assert "dangerously_allow_auto_upload_download_files" in msg
|
||||
|
||||
def test_error_message_lists_allowlist(self, tmp_path: Path):
|
||||
allowed = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
allowed.mkdir()
|
||||
outside.mkdir()
|
||||
f = outside / "a.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
|
||||
assert_path_inside_upload_dirs(str(f), [allowed])
|
||||
msg = str(excinfo.value)
|
||||
assert str(allowed) in msg
|
||||
assert "file_upload_dirs" in msg
|
||||
assert "dangerously_allow_auto_upload_download_files" in msg
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only symlink semantics")
|
||||
def test_rejects_symlink_pointing_outside(self, tmp_path: Path):
|
||||
allowed = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
allowed.mkdir()
|
||||
outside.mkdir()
|
||||
secret = outside / "secret.txt"
|
||||
secret.write_text("pw")
|
||||
link = allowed / "link.txt"
|
||||
os.symlink(secret, link)
|
||||
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
assert_path_inside_upload_dirs(str(link), [allowed])
|
||||
|
||||
|
||||
class TestToolsWiresAllowlistCorrectly:
|
||||
"""Verifies ``Tools`` forwards the right allowlist to ``FileHelper`` given
|
||||
the full flag/value matrix."""
|
||||
|
||||
@staticmethod
|
||||
def _make_tools(
|
||||
dangerously_allow_auto_upload_download_files: bool,
|
||||
file_upload_dirs,
|
||||
):
|
||||
from composio.core.models.tools import Tools
|
||||
from unittest.mock import Mock
|
||||
|
||||
return Tools(
|
||||
client=Mock(),
|
||||
provider=Mock(name="provider"),
|
||||
dangerously_allow_auto_upload_download_files=dangerously_allow_auto_upload_download_files,
|
||||
file_upload_dirs=file_upload_dirs,
|
||||
)
|
||||
|
||||
def test_flag_off_means_no_allowlist_is_ever_built(self):
|
||||
# Flag off ⇒ auto-upload code path is never run. We pass ``None`` to
|
||||
# FileHelper so manual/no-op paths don't accidentally enforce anything.
|
||||
t = self._make_tools(False, None)
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
def test_flag_off_ignores_file_upload_dirs_entirely(self):
|
||||
t = self._make_tools(False, ["/tmp/whatever"])
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
t = self._make_tools(False, False)
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
t = self._make_tools(False, [])
|
||||
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
|
||||
|
||||
def test_flag_on_none_uses_default(self):
|
||||
t = self._make_tools(True, None)
|
||||
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
|
||||
get_default_upload_dir()
|
||||
]
|
||||
|
||||
def test_flag_on_false_means_empty_allowlist(self):
|
||||
t = self._make_tools(True, False)
|
||||
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
|
||||
|
||||
def test_flag_on_empty_list_means_empty_allowlist(self):
|
||||
t = self._make_tools(True, [])
|
||||
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
|
||||
|
||||
def test_flag_on_explicit_dirs_replace_default(self, tmp_path: Path):
|
||||
t = self._make_tools(True, [str(tmp_path)])
|
||||
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
|
||||
tmp_path.resolve()
|
||||
]
|
||||
|
||||
|
||||
class TestFileUploadableAllowlistWiring:
|
||||
"""Sanity: FileUploadable.from_path must honor the allowlist param."""
|
||||
|
||||
def test_from_path_with_allowlist_blocks_outside(self, tmp_path: Path):
|
||||
from composio.core.models._files import FileUploadable
|
||||
from unittest.mock import Mock
|
||||
|
||||
inside = tmp_path / "inside"
|
||||
outside = tmp_path / "outside"
|
||||
inside.mkdir()
|
||||
outside.mkdir()
|
||||
f = outside / "a.txt"
|
||||
f.write_text("hi")
|
||||
|
||||
with pytest.raises(FileUploadPathNotAllowedError):
|
||||
FileUploadable.from_path(
|
||||
client=Mock(),
|
||||
file=str(f),
|
||||
tool="T",
|
||||
toolkit="tk",
|
||||
file_upload_allowlist=[inside],
|
||||
)
|
||||
|
||||
def test_from_path_without_allowlist_skips_check(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Passing ``file_upload_allowlist=None`` must NOT trigger the check.
|
||||
|
||||
This simulates manual upload paths and the no-auto-upload default.
|
||||
"""
|
||||
from composio.core.models import _files as files_mod
|
||||
from composio.core.models._files import FileUploadable
|
||||
from unittest.mock import Mock
|
||||
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hi")
|
||||
|
||||
# Short-circuit the network-bound parts.
|
||||
mock_client = Mock()
|
||||
mock_client.post.return_value = Mock(
|
||||
new_presigned_url="https://example/upload", key="k"
|
||||
)
|
||||
monkeypatch.setattr(files_mod, "upload", lambda url, file: True)
|
||||
|
||||
result = FileUploadable.from_path(
|
||||
client=mock_client,
|
||||
file=str(f),
|
||||
tool="T",
|
||||
toolkit="tk",
|
||||
file_upload_allowlist=None,
|
||||
)
|
||||
assert result.s3key == "k"
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Integration tests for verify_webhook using fixtures.
|
||||
|
||||
These tests use the same fixtures as the TypeScript SDK to ensure
|
||||
cross-SDK compatibility and algorithm correctness.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from composio import exceptions
|
||||
from composio.core.models.triggers import Triggers, WebhookVersion
|
||||
from tests.conftest import compute_signature, load_fixtures, load_golden_signatures
|
||||
|
||||
|
||||
class TestVerifyWebhook:
|
||||
"""Tests for webhook verification against fixture data."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture",
|
||||
load_fixtures(),
|
||||
ids=lambda f: f["description"],
|
||||
)
|
||||
def test_verify_fixture(self, triggers: Triggers, fixture: dict) -> None:
|
||||
"""Verify each fixture passes verification."""
|
||||
result = triggers.verify_webhook(
|
||||
id=fixture["headers"]["webhook-id"],
|
||||
payload=fixture["payload"],
|
||||
signature=fixture["headers"]["webhook-signature"],
|
||||
timestamp=fixture["headers"]["webhook-timestamp"],
|
||||
secret=fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
assert result["version"].value == fixture["expectedResult"]["version"]
|
||||
expected_slug = fixture["expectedResult"]["triggerSlug"]
|
||||
assert result["payload"]["trigger_slug"] == expected_slug
|
||||
|
||||
if "userId" in fixture["expectedResult"]:
|
||||
assert result["payload"]["user_id"] == fixture["expectedResult"]["userId"]
|
||||
|
||||
if "connectedAccountId" in fixture["expectedResult"]:
|
||||
assert (
|
||||
result["payload"]["metadata"]["connected_account"]["id"]
|
||||
== fixture["expectedResult"]["connectedAccountId"]
|
||||
)
|
||||
|
||||
if "triggerId" in fixture["expectedResult"]:
|
||||
assert result["payload"]["id"] == fixture["expectedResult"]["triggerId"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected_enum,expected_keys",
|
||||
[
|
||||
("V3", WebhookVersion.V3, ["type", "metadata"]),
|
||||
("V2", WebhookVersion.V2, ["type", "data"]),
|
||||
("V1", WebhookVersion.V1, ["trigger_name", "connection_id"]),
|
||||
],
|
||||
)
|
||||
def test_detects_version(
|
||||
self,
|
||||
triggers: Triggers,
|
||||
webhook_fixtures: list[dict],
|
||||
version: str,
|
||||
expected_enum: WebhookVersion,
|
||||
expected_keys: list[str],
|
||||
) -> None:
|
||||
"""Test version detection for V1, V2, V3."""
|
||||
fixture = next(
|
||||
(f for f in webhook_fixtures if f["expectedResult"]["version"] == version),
|
||||
None,
|
||||
)
|
||||
if fixture is None:
|
||||
pytest.skip(f"No {version} fixture found")
|
||||
|
||||
result = triggers.verify_webhook(
|
||||
id=fixture["headers"]["webhook-id"],
|
||||
payload=fixture["payload"],
|
||||
signature=fixture["headers"]["webhook-signature"],
|
||||
timestamp=fixture["headers"]["webhook-timestamp"],
|
||||
secret=fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
assert result["version"] == expected_enum
|
||||
for key in expected_keys:
|
||||
assert key in result["raw_payload"]
|
||||
|
||||
|
||||
class TestGoldenSignatures:
|
||||
"""Contract tests for signature algorithm."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_case",
|
||||
load_golden_signatures()["testCases"],
|
||||
ids=lambda tc: tc["name"],
|
||||
)
|
||||
def test_produces_identical_signature(self, test_case: dict) -> None:
|
||||
"""Verify signature algorithm produces identical output."""
|
||||
computed = compute_signature(
|
||||
test_case["id"],
|
||||
test_case["timestamp"],
|
||||
test_case["payload"],
|
||||
test_case["secret"],
|
||||
)
|
||||
assert computed == test_case["expectedSignature"]
|
||||
|
||||
def test_algorithm_matches_documented_format(self, golden_signatures: dict) -> None:
|
||||
"""Verify algorithm documentation."""
|
||||
assert golden_signatures["algorithm"] == "HMAC-SHA256"
|
||||
assert (
|
||||
golden_signatures["format"]
|
||||
== "v1,base64(HMAC-SHA256(id.timestamp.payload, secret))"
|
||||
)
|
||||
|
||||
|
||||
class TestSignatureValidation:
|
||||
"""Tests for signature algorithm validation."""
|
||||
|
||||
def test_computes_signature_using_id_timestamp_payload_format(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify signature is computed using id.timestamp.payload format."""
|
||||
fixture = webhook_fixtures[0]
|
||||
expected_signature = compute_signature(
|
||||
fixture["headers"]["webhook-id"],
|
||||
fixture["headers"]["webhook-timestamp"],
|
||||
fixture["payload"],
|
||||
fixture["testSecret"],
|
||||
)
|
||||
assert expected_signature == fixture["headers"]["webhook-signature"]
|
||||
|
||||
def test_rejects_signature_computed_with_payload_only(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Reject signature computed with only payload (wrong format)."""
|
||||
fixture = webhook_fixtures[0]
|
||||
wrong_signature = "v1," + base64.b64encode(
|
||||
hmac.new(
|
||||
key=fixture["testSecret"].encode("utf-8"),
|
||||
msg=fixture["payload"].encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
with pytest.raises(exceptions.WebhookSignatureVerificationError):
|
||||
triggers.verify_webhook(
|
||||
id=fixture["headers"]["webhook-id"],
|
||||
payload=fixture["payload"],
|
||||
signature=wrong_signature,
|
||||
timestamp=fixture["headers"]["webhook-timestamp"],
|
||||
secret=fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
def test_rejects_signature_missing_id(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Reject signature computed with timestamp.payload (missing id)."""
|
||||
fixture = webhook_fixtures[0]
|
||||
to_sign = f"{fixture['headers']['webhook-timestamp']}.{fixture['payload']}"
|
||||
wrong_signature = "v1," + base64.b64encode(
|
||||
hmac.new(
|
||||
key=fixture["testSecret"].encode("utf-8"),
|
||||
msg=to_sign.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
with pytest.raises(exceptions.WebhookSignatureVerificationError):
|
||||
triggers.verify_webhook(
|
||||
id=fixture["headers"]["webhook-id"],
|
||||
payload=fixture["payload"],
|
||||
signature=wrong_signature,
|
||||
timestamp=fixture["headers"]["webhook-timestamp"],
|
||||
secret=fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
|
||||
class TestPayloadStructure:
|
||||
"""Tests for payload structure validation."""
|
||||
|
||||
def test_preserves_exact_json_structure(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify raw payload matches fixture exactly."""
|
||||
v3_fixture = next(
|
||||
(f for f in webhook_fixtures if f["expectedResult"]["version"] == "V3"),
|
||||
None,
|
||||
)
|
||||
if v3_fixture is None:
|
||||
pytest.skip("No V3 fixture found")
|
||||
|
||||
result = triggers.verify_webhook(
|
||||
id=v3_fixture["headers"]["webhook-id"],
|
||||
payload=v3_fixture["payload"],
|
||||
signature=v3_fixture["headers"]["webhook-signature"],
|
||||
timestamp=v3_fixture["headers"]["webhook-timestamp"],
|
||||
secret=v3_fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
parsed_payload = json.loads(v3_fixture["payload"])
|
||||
assert result["raw_payload"] == parsed_payload
|
||||
|
||||
def test_normalizes_v3_payload(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify V3 payload is normalized correctly."""
|
||||
v3_fixture = next(
|
||||
(f for f in webhook_fixtures if f["expectedResult"]["version"] == "V3"),
|
||||
None,
|
||||
)
|
||||
if v3_fixture is None:
|
||||
pytest.skip("No V3 fixture found")
|
||||
|
||||
result = triggers.verify_webhook(
|
||||
id=v3_fixture["headers"]["webhook-id"],
|
||||
payload=v3_fixture["payload"],
|
||||
signature=v3_fixture["headers"]["webhook-signature"],
|
||||
timestamp=v3_fixture["headers"]["webhook-timestamp"],
|
||||
secret=v3_fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
payload = result["payload"]
|
||||
assert "id" in payload
|
||||
assert "uuid" in payload
|
||||
assert "trigger_slug" in payload
|
||||
assert "toolkit_slug" in payload
|
||||
assert "user_id" in payload
|
||||
assert "payload" in payload
|
||||
assert "metadata" in payload
|
||||
assert "connected_account" in payload["metadata"]
|
||||
|
||||
|
||||
class TestWhitespaceSensitivity:
|
||||
"""Tests for whitespace sensitivity in payload verification."""
|
||||
|
||||
def test_fails_verification_if_whitespace_changes(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify whitespace changes cause verification failure."""
|
||||
fixture = webhook_fixtures[0]
|
||||
modified_payload = fixture["payload"].replace("{", "{ ")
|
||||
|
||||
expected_errors = (
|
||||
exceptions.WebhookSignatureVerificationError,
|
||||
exceptions.WebhookPayloadError,
|
||||
)
|
||||
with pytest.raises(expected_errors):
|
||||
triggers.verify_webhook(
|
||||
id=fixture["headers"]["webhook-id"],
|
||||
payload=modified_payload,
|
||||
signature=fixture["headers"]["webhook-signature"],
|
||||
timestamp=fixture["headers"]["webhook-timestamp"],
|
||||
secret=fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
def test_fails_verification_if_payload_reserialized(
|
||||
self, triggers: Triggers, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify re-serialization may cause verification failure."""
|
||||
fixture = webhook_fixtures[0]
|
||||
reserialized = json.dumps(json.loads(fixture["payload"]))
|
||||
|
||||
# Only test if re-serialization actually changed the payload
|
||||
if reserialized != fixture["payload"]:
|
||||
expected_errors = (
|
||||
exceptions.WebhookSignatureVerificationError,
|
||||
exceptions.WebhookPayloadError,
|
||||
)
|
||||
with pytest.raises(expected_errors):
|
||||
triggers.verify_webhook(
|
||||
id=fixture["headers"]["webhook-id"],
|
||||
payload=reserialized,
|
||||
signature=fixture["headers"]["webhook-signature"],
|
||||
timestamp=fixture["headers"]["webhook-timestamp"],
|
||||
secret=fixture["testSecret"],
|
||||
tolerance=0,
|
||||
)
|
||||
|
||||
|
||||
class TestFixtureConsistency:
|
||||
"""Tests for fixture consistency."""
|
||||
|
||||
def test_all_fixtures_use_same_test_secret(
|
||||
self, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify all fixtures use the same test secret."""
|
||||
secrets = {f["testSecret"] for f in webhook_fixtures}
|
||||
assert len(secrets) == 1
|
||||
assert "test-webhook-secret-for-fixtures" in secrets
|
||||
|
||||
def test_all_fixtures_have_unique_webhook_ids(
|
||||
self, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify all fixtures have unique webhook IDs."""
|
||||
ids = [f["headers"]["webhook-id"] for f in webhook_fixtures]
|
||||
assert len(set(ids)) == len(webhook_fixtures)
|
||||
|
||||
def test_fixtures_cover_all_supported_versions(
|
||||
self, webhook_fixtures: list[dict]
|
||||
) -> None:
|
||||
"""Verify fixtures cover V1, V2, and V3."""
|
||||
versions = {f["expectedResult"]["version"] for f in webhook_fixtures}
|
||||
assert {"V1", "V2", "V3"} <= versions
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for webhook event types."""
|
||||
|
||||
import pytest
|
||||
|
||||
from composio.core.models.webhook_events import (
|
||||
ConnectionExpiredEvent,
|
||||
WebhookEventType,
|
||||
is_connection_expired_event,
|
||||
)
|
||||
|
||||
|
||||
class TestWebhookEventType:
|
||||
"""Tests for WebhookEventType enum."""
|
||||
|
||||
def test_connection_expired_value(self) -> None:
|
||||
"""Should have correct value for CONNECTION_EXPIRED."""
|
||||
assert (
|
||||
WebhookEventType.CONNECTION_EXPIRED.value
|
||||
== "composio.connected_account.expired"
|
||||
)
|
||||
|
||||
def test_trigger_message_value(self) -> None:
|
||||
"""Should have correct value for TRIGGER_MESSAGE."""
|
||||
assert WebhookEventType.TRIGGER_MESSAGE.value == "composio.trigger.message"
|
||||
|
||||
|
||||
class TestIsConnectionExpiredEvent:
|
||||
"""Tests for is_connection_expired_event helper function."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_payload(self) -> dict:
|
||||
"""Return a valid connection expired event payload."""
|
||||
return {
|
||||
"id": "msg_847cdfcd-d219-4f18-a6dd-91acd42ca94a",
|
||||
"timestamp": "2026-02-02T10:14:20.955Z",
|
||||
"type": "composio.connected_account.expired",
|
||||
"data": {
|
||||
"toolkit": {"slug": "gmail"},
|
||||
"auth_config": {
|
||||
"id": "ac_izZGRCZ9qyxk",
|
||||
"auth_scheme": "OAUTH2",
|
||||
"is_composio_managed": True,
|
||||
"is_disabled": False,
|
||||
},
|
||||
"id": "ca__IvSeEzEBjVt",
|
||||
"user_id": "test-user",
|
||||
"status": "EXPIRED",
|
||||
"created_at": "2026-02-02T08:35:44.272Z",
|
||||
"updated_at": "2026-02-02T10:14:20.949Z",
|
||||
"state": {
|
||||
"authScheme": "OAUTH2",
|
||||
"val": {"status": "EXPIRED"},
|
||||
},
|
||||
"data": {},
|
||||
"params": {},
|
||||
"status_reason": None,
|
||||
"is_disabled": False,
|
||||
},
|
||||
"metadata": {
|
||||
"project_id": "pr_koucdrMIwRsf",
|
||||
"org_id": "4a4ded8f-d3ae-4dea-a229-c30234298b05",
|
||||
},
|
||||
}
|
||||
|
||||
def test_returns_true_for_valid_payload(self, valid_payload: dict) -> None:
|
||||
"""Should return True for a valid connection expired event."""
|
||||
assert is_connection_expired_event(valid_payload) is True
|
||||
|
||||
def test_returns_false_for_trigger_message(self) -> None:
|
||||
"""Should return False for a trigger message event."""
|
||||
payload = {
|
||||
"id": "msg_123",
|
||||
"timestamp": "2026-02-02T10:14:20.955Z",
|
||||
"type": "composio.trigger.message",
|
||||
"data": {},
|
||||
"metadata": {},
|
||||
}
|
||||
assert is_connection_expired_event(payload) is False
|
||||
|
||||
def test_returns_false_for_none(self) -> None:
|
||||
"""Should return False for None input."""
|
||||
assert is_connection_expired_event(None) is False # type: ignore
|
||||
|
||||
def test_returns_false_for_string(self) -> None:
|
||||
"""Should return False for string input."""
|
||||
assert is_connection_expired_event("string") is False # type: ignore
|
||||
|
||||
def test_returns_false_for_empty_dict(self) -> None:
|
||||
"""Should return False for empty dict."""
|
||||
assert is_connection_expired_event({}) is False
|
||||
|
||||
def test_returns_false_for_missing_type(self, valid_payload: dict) -> None:
|
||||
"""Should return False when type field is missing."""
|
||||
del valid_payload["type"]
|
||||
assert is_connection_expired_event(valid_payload) is False
|
||||
|
||||
|
||||
class TestConnectionExpiredEventType:
|
||||
"""Tests for ConnectionExpiredEvent TypedDict structure."""
|
||||
|
||||
def test_type_annotation(self) -> None:
|
||||
"""Should have correct type annotations."""
|
||||
# This test verifies the TypedDict can be used for type hints
|
||||
event: ConnectionExpiredEvent = {
|
||||
"id": "msg_123",
|
||||
"timestamp": "2026-02-02T10:14:20.955Z",
|
||||
"type": "composio.connected_account.expired",
|
||||
"data": {
|
||||
"toolkit": {"slug": "gmail"},
|
||||
"auth_config": {
|
||||
"id": "ac_123",
|
||||
"auth_scheme": "OAUTH2",
|
||||
"is_composio_managed": True,
|
||||
"is_disabled": False,
|
||||
},
|
||||
"id": "ca_123",
|
||||
"user_id": "user_123",
|
||||
"status": "EXPIRED",
|
||||
"created_at": "2026-02-02T08:35:44.272Z",
|
||||
"updated_at": "2026-02-02T10:14:20.949Z",
|
||||
"state": {"authScheme": "OAUTH2", "val": {}},
|
||||
"data": {},
|
||||
"params": {},
|
||||
"status_reason": None,
|
||||
"is_disabled": False,
|
||||
},
|
||||
"metadata": {"project_id": "pr_123", "org_id": "org_123"},
|
||||
}
|
||||
|
||||
# Verify type narrowing works
|
||||
assert event["type"] == "composio.connected_account.expired"
|
||||
assert event["data"]["toolkit"]["slug"] == "gmail"
|
||||
assert event["metadata"]["project_id"] == "pr_123"
|
||||
Reference in New Issue
Block a user