chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:34 +08:00
commit 0549b088a4
2405 changed files with 810255 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests"""
+104
View File
@@ -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()
+47
View File
@@ -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="
}
]
}
+16
View File
@@ -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"
}
}
+16
View File
@@ -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"
}
}
+17
View File
@@ -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"
}
}
+590
View File
@@ -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
)
+856
View File
@@ -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
+124
View File
@@ -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."
)
+904
View File
@@ -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
+655
View File
@@ -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"}
+58
View File
@@ -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)"
]
+128
View File
@@ -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}")
+426
View File
@@ -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
+110
View File
@@ -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"
+199
View File
@@ -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)
+108
View File
@@ -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
+211
View File
@@ -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()
+398
View File
@@ -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})
+187
View File
@@ -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
+116
View File
@@ -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])
+251
View File
@@ -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
+133
View File
@@ -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"