From c1f279d3bb0bdfc5684c947ea74305c0b88e8c1d Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Tue, 25 Mar 2025 11:15:39 +0530 Subject: [PATCH 1/3] feat: Local clipboard tool to copy text, image and filepaths (#1453) --- .../tools/local/clipboardtool/__init__.py | 5 + .../local/clipboardtool/actions/__init__.py | 15 ++ .../clipboardtool/actions/base_action.py | 46 ++++ .../local/clipboardtool/actions/files.py | 101 ++++++++ .../local/clipboardtool/actions/image.py | 133 +++++++++++ .../tools/local/clipboardtool/actions/text.py | 83 +++++++ .../tools/local/clipboardtool/tool.py | 70 ++++++ python/docs/imgs/logos/clipboardtool.png | Bin 0 -> 24628 bytes .../miscellaneous/clipboard_example.py | 161 +++++++++++++ python/setup.py | 1 + .../test_local/test_clipboardtool.py | 218 ++++++++++++++++++ 11 files changed, 833 insertions(+) create mode 100644 python/composio/tools/local/clipboardtool/__init__.py create mode 100644 python/composio/tools/local/clipboardtool/actions/__init__.py create mode 100644 python/composio/tools/local/clipboardtool/actions/base_action.py create mode 100644 python/composio/tools/local/clipboardtool/actions/files.py create mode 100644 python/composio/tools/local/clipboardtool/actions/image.py create mode 100644 python/composio/tools/local/clipboardtool/actions/text.py create mode 100644 python/composio/tools/local/clipboardtool/tool.py create mode 100644 python/docs/imgs/logos/clipboardtool.png create mode 100644 python/examples/miscellaneous/clipboard_example.py create mode 100644 python/tests/test_tools/test_local/test_clipboardtool.py diff --git a/python/composio/tools/local/clipboardtool/__init__.py b/python/composio/tools/local/clipboardtool/__init__.py new file mode 100644 index 00000000000..ea2dee45a30 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/__init__.py @@ -0,0 +1,5 @@ +""" +Clipboard manager. +""" + +from .tool import Clipboardtool diff --git a/python/composio/tools/local/clipboardtool/actions/__init__.py b/python/composio/tools/local/clipboardtool/actions/__init__.py new file mode 100644 index 00000000000..cdbadf164d8 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/actions/__init__.py @@ -0,0 +1,15 @@ +"""Clipboard actions.""" + +from .files import CopyFilePaths, PasteFilePaths +from .image import CopyImage, PasteImage +from .text import CopyText, PasteText + + +__all__ = [ + "CopyText", + "PasteText", + "CopyImage", + "PasteImage", + "CopyFilePaths", + "PasteFilePaths", +] diff --git a/python/composio/tools/local/clipboardtool/actions/base_action.py b/python/composio/tools/local/clipboardtool/actions/base_action.py new file mode 100644 index 00000000000..d6f63ef6a87 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/actions/base_action.py @@ -0,0 +1,46 @@ +"""Base classes for clipboard actions.""" + +from typing import Any, Dict, TypedDict + +from pydantic import BaseModel, Field + + +class ClipboardState(TypedDict, total=False): + """Type definition for clipboard state.""" + + text_data: str + image_data: str + file_paths: list[str] + + +class BaseClipboardRequest(BaseModel): + """Base request for clipboard actions.""" + + pass + + +class BaseClipboardResponse(BaseModel): + """Base response for clipboard actions.""" + + message: str = Field( + default="", + description="Message describing the result of the action", + ) + error: str = Field( + default="", + description="Error message if the action failed", + ) + + +def get_clipboard_state(metadata: Dict[str, Any]) -> ClipboardState: + """Get clipboard state from metadata. + + Args: + metadata: The metadata dictionary containing clipboard state + + Returns: + The clipboard state dictionary, initialized if it doesn't exist + """ + if "clipboard_state" not in metadata: + metadata["clipboard_state"] = {} + return metadata["clipboard_state"] # type: ignore diff --git a/python/composio/tools/local/clipboardtool/actions/files.py b/python/composio/tools/local/clipboardtool/actions/files.py new file mode 100644 index 00000000000..fd16a0b5589 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/actions/files.py @@ -0,0 +1,101 @@ +"""File path clipboard actions.""" + +import os +from typing import Dict, List + +from pydantic import Field + +from composio.tools.base.local import LocalAction +from composio.tools.local.clipboardtool.actions.base_action import ( + BaseClipboardRequest, + BaseClipboardResponse, + get_clipboard_state, +) + + +class CopyFilePathsRequest(BaseClipboardRequest): + """Request to copy file paths to clipboard.""" + + paths: List[str] = Field( + ..., + description="List of file paths to copy to clipboard", + ) + + +class PasteFilePathsRequest(BaseClipboardRequest): + """Request to paste file paths from clipboard.""" + + pass + + +class PasteFilePathsResponse(BaseClipboardResponse): + """Response from pasting file paths from clipboard.""" + + paths: List[str] = Field( + default_factory=list, + description="List of file paths pasted from clipboard", + ) + + +class CopyFilePaths(LocalAction[CopyFilePathsRequest, BaseClipboardResponse]): + """Copy file paths to clipboard.""" + + def execute( + self, request: CopyFilePathsRequest, metadata: Dict + ) -> BaseClipboardResponse: + """Execute the action.""" + try: + # Validate paths exist + valid_paths = [p for p in request.paths if os.path.exists(p)] + + if not valid_paths: + return BaseClipboardResponse( + error="No valid files found to copy", + ) + + # Store paths in clipboard state + clipboard_state = get_clipboard_state(metadata) + clipboard_state["file_paths"] = valid_paths + + return BaseClipboardResponse( + message="File paths copied to clipboard successfully" + ) + except Exception as e: + return BaseClipboardResponse(error=f"Failed to copy file paths: {str(e)}") + + +class PasteFilePaths(LocalAction[PasteFilePathsRequest, PasteFilePathsResponse]): + """Paste file paths from clipboard.""" + + def execute( + self, request: PasteFilePathsRequest, metadata: Dict + ) -> PasteFilePathsResponse: + """Execute the action.""" + try: + clipboard_state = get_clipboard_state(metadata) + paths = clipboard_state.get("file_paths", []) + + if not paths: + return PasteFilePathsResponse( + error="No files found in clipboard", + paths=[], + ) + + # Validate paths exist + valid_paths = [p for p in paths if os.path.exists(p)] + + if not valid_paths: + return PasteFilePathsResponse( + error="No valid files found in clipboard", + paths=[], + ) + + return PasteFilePathsResponse( + message="File paths pasted from clipboard successfully", + paths=valid_paths, + ) + except Exception as e: + return PasteFilePathsResponse( + error=f"Failed to paste file paths: {str(e)}", + paths=[], + ) diff --git a/python/composio/tools/local/clipboardtool/actions/image.py b/python/composio/tools/local/clipboardtool/actions/image.py new file mode 100644 index 00000000000..3b0ac67e1b2 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/actions/image.py @@ -0,0 +1,133 @@ +"""Image clipboard actions.""" + +import base64 +import os +import logging +import tempfile +import typing as t +from pathlib import Path + +from PIL import Image +from pydantic import ConfigDict, Field + +from composio.tools.base.local import LocalAction +from composio.tools.local.clipboardtool.actions.base_action import ( + BaseClipboardRequest, + BaseClipboardResponse, + get_clipboard_state, +) + +logger = logging.getLogger(__name__) + + +class CopyImageRequest(BaseClipboardRequest): + """Request to copy image to clipboard.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + image_path: str = Field( + default=..., + description="Path to image file to copy to clipboard", + ) + + +class CopyImageResponse(BaseClipboardResponse): + """Response from copying image to clipboard.""" + + pass + + +class PasteImageRequest(BaseClipboardRequest): + """Request to paste image from clipboard.""" + + save_path: str = Field( + ..., + description="Path to save the pasted image to", + ) + + +class PasteImageResponse(BaseClipboardResponse): + """Response from pasting image from clipboard.""" + + image_path: str = Field( + default="", + description="Path to the saved image file", + ) + + +class CopyImage(LocalAction[CopyImageRequest, CopyImageResponse]): + """Copy image to clipboard.""" + + def execute(self, request: CopyImageRequest, metadata: t.Dict) -> CopyImageResponse: + """Execute the action.""" + try: + logger.debug(f"Checking if image exists at {request.image_path}") + # Validate image exists + if not os.path.exists(request.image_path): + logger.error(f"Image not found at {request.image_path}") + return CopyImageResponse( + error="Image file not found", + ) + + logger.debug(f"Opening image from {request.image_path}") + # Store image data in clipboard state + image = Image.open(request.image_path) + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + temp_path = temp_file.name + temp_file.close() + + logger.debug(f"Saving temp file to {temp_path}") + image.save(temp_path) # PIL needs a file to copy to clipboard + + logger.debug("Reading temp file") + with open(temp_path, "rb") as f: + data = f.read() + logger.debug("Cleaning up temp file") + Path(temp_path).unlink() # Clean up temp file + + logger.debug("Storing data in clipboard state") + clipboard_state = get_clipboard_state(metadata) + clipboard_state["image_data"] = base64.b64encode(data).decode() + + return CopyImageResponse(message="Image copied to clipboard successfully") + except Exception as e: + logger.exception(f"Error occurred: {str(e)}") + return CopyImageResponse(error=f"Failed to copy image: {str(e)}") + + +class PasteImage(LocalAction[PasteImageRequest, PasteImageResponse]): + """Paste image from clipboard.""" + + def execute( + self, request: PasteImageRequest, metadata: t.Dict + ) -> PasteImageResponse: + """Execute the action.""" + try: + clipboard_state = get_clipboard_state(metadata) + image_data = clipboard_state.get("image_data") + + if not image_data: + logger.warning("No valid image found in clipboard") + return PasteImageResponse( + error="No valid image found in clipboard", + image_path="", + ) + + # Create destination directory if needed + os.makedirs(os.path.dirname(request.save_path), exist_ok=True) + + # Decode and save image + data = base64.b64decode(image_data) + with open(request.save_path, "wb") as f: + f.write(data) + + logger.debug(f"Image saved to {request.save_path}") + return PasteImageResponse( + message="Image pasted from clipboard successfully", + image_path=request.save_path, + ) + except Exception as e: + logger.exception(f"Failed to paste image: {str(e)}") + return PasteImageResponse( + error=f"Failed to paste image: {str(e)}", + image_path="", + ) diff --git a/python/composio/tools/local/clipboardtool/actions/text.py b/python/composio/tools/local/clipboardtool/actions/text.py new file mode 100644 index 00000000000..6e55b0cce15 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/actions/text.py @@ -0,0 +1,83 @@ +"""Text clipboard actions.""" + +import typing as t + +from pydantic import Field + +from composio.tools.base.local import LocalAction +from composio.tools.local.clipboardtool.actions.base_action import ( + BaseClipboardRequest, + BaseClipboardResponse, + get_clipboard_state, +) + + +class CopyTextRequest(BaseClipboardRequest): + """Request to copy text to clipboard.""" + + text: str = Field( + ..., + description="Text to copy to clipboard", + ) + + +class CopyTextResponse(BaseClipboardResponse): + """Response from copying text to clipboard.""" + + pass + + +class PasteTextRequest(BaseClipboardRequest): + """Request to paste text from clipboard.""" + + pass + + +class PasteTextResponse(BaseClipboardResponse): + """Response from pasting text from clipboard.""" + + text: str = Field( + default="", + description="Text pasted from clipboard", + ) + + +class CopyText(LocalAction[CopyTextRequest, CopyTextResponse]): + """Copy text to clipboard.""" + + def execute(self, request: CopyTextRequest, metadata: t.Dict) -> CopyTextResponse: + """Execute the action.""" + try: + # Store text in clipboard state + clipboard_state = get_clipboard_state(metadata) + clipboard_state["text_data"] = request.text + + return CopyTextResponse(message="Text copied to clipboard successfully") + except Exception as e: + return CopyTextResponse(error=f"Failed to copy text: {str(e)}") + + +class PasteText(LocalAction[PasteTextRequest, PasteTextResponse]): + """Paste text from clipboard.""" + + def execute(self, request: PasteTextRequest, metadata: t.Dict) -> PasteTextResponse: + """Execute the action.""" + try: + clipboard_state = get_clipboard_state(metadata) + text = clipboard_state.get("text_data", "") + + if not text: + return PasteTextResponse( + error="No text found in clipboard", + text="", + ) + + return PasteTextResponse( + message="Text pasted from clipboard successfully", + text=text, + ) + except Exception as e: + return PasteTextResponse( + error=f"Failed to paste text: {str(e)}", + text="", + ) diff --git a/python/composio/tools/local/clipboardtool/tool.py b/python/composio/tools/local/clipboardtool/tool.py new file mode 100644 index 00000000000..ae488caffc0 --- /dev/null +++ b/python/composio/tools/local/clipboardtool/tool.py @@ -0,0 +1,70 @@ +""" +Clipboard tool for Composio. + +This tool provides clipboard operations for text, images, and file paths. +It maintains clipboard state in memory and provides copy/paste functionality +for different types of content. +""" + +from typing import List, Type + +from composio.client.enums.base import ActionData, add_runtime_action +from composio.tools.base.local import LocalAction, LocalTool + +from .actions import ( + CopyFilePaths, + CopyImage, + CopyText, + PasteFilePaths, + PasteImage, + PasteText, +) + + +def register_clipboard_actions() -> None: + """Register clipboard actions in the Action enum.""" + actions = [ + (CopyText, "Copy text to clipboard"), + (PasteText, "Paste text from clipboard"), + (CopyImage, "Copy image to clipboard"), + (PasteImage, "Paste image from clipboard"), + (CopyFilePaths, "Copy file paths to clipboard"), + (PasteFilePaths, "Paste file paths from clipboard"), + ] + + for action, _ in actions: + add_runtime_action( + f"CLIPBOARDTOOL_{action.__name__.upper()}", + ActionData( + name=action.__name__, + app="CLIPBOARDTOOL", + tags=[], + no_auth=True, + is_local=True, + is_runtime=True, + ), + ) + + +# Register actions in Action enum when the module is imported +register_clipboard_actions() + + +class Clipboardtool(LocalTool, autoload=True): + """Clipboard tool.""" + + logo = "https://raw.githubusercontent.com/ComposioHQ/composio/master/python/docs/imgs/logos/clipboardtool.png" + + @classmethod + def actions(cls) -> List[Type[LocalAction]]: + """Return the list of actions.""" + actions = [ + CopyText, + PasteText, + CopyImage, + PasteImage, + CopyFilePaths, + PasteFilePaths, + ] + + return actions diff --git a/python/docs/imgs/logos/clipboardtool.png b/python/docs/imgs/logos/clipboardtool.png new file mode 100644 index 0000000000000000000000000000000000000000..a085019130da2d03855f1b517d073657fcbca968 GIT binary patch literal 24628 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.7.1,<14", "pyperclip>=1.8.2,<2", + "Pillow>=10.2.0,<11", # For image clipboard support # Workspace dependencies "paramiko>=3.4.1", # Host workspace # Tooling server dependencies diff --git a/python/tests/test_tools/test_local/test_clipboardtool.py b/python/tests/test_tools/test_local/test_clipboardtool.py new file mode 100644 index 00000000000..463923bbc5f --- /dev/null +++ b/python/tests/test_tools/test_local/test_clipboardtool.py @@ -0,0 +1,218 @@ +# pylint: disable=protected-access,no-member,unsupported-membership-test,unspecified-encoding,not-an-iterable,unsubscriptable-object,unused-argument +import os +import tempfile + +import pytest +from PIL import Image + +from composio import Action +from composio.tools.env.factory import WorkspaceType +from composio.tools.local.clipboardtool.actions.files import ( + CopyFilePaths, + CopyFilePathsRequest, + PasteFilePaths, + PasteFilePathsRequest, +) +from composio.tools.local.clipboardtool.actions.image import ( + CopyImage, + CopyImageRequest, + PasteImage, + PasteImageRequest, +) +from composio.tools.local.clipboardtool.actions.text import ( + CopyText, + CopyTextRequest, + PasteText, + PasteTextRequest, +) +from composio.tools.local.clipboardtool.tool import Clipboardtool +from composio.tools.toolset import ComposioToolSet + +from tests.conftest import skip_if_ci + + +# Disable remote enum fetching for tests +os.environ["COMPOSIO_NO_REMOTE_ENUM_FETCHING"] = "true" + + +@pytest.fixture(scope="module") +def temp_dir(): + with tempfile.TemporaryDirectory() as tmpdirname: + yield tmpdirname + + +@pytest.fixture(scope="module") +def clipboard_state(): + """Shared clipboard state for tests.""" + from composio.tools.local.clipboardtool.actions.base_action import ClipboardState + + return {"clipboard_state": ClipboardState()} + + +@pytest.mark.usefixtures("temp_dir") +class TestClipboardtool: + def test_text_copy_and_paste(self, clipboard_state): + """Test copying and pasting text.""" + # Copy text + copy_action = CopyText() + test_text = "Hello, World!" + copy_response = copy_action.execute( + CopyTextRequest(text=test_text), clipboard_state + ) + assert not copy_response.error + + # Paste text + paste_action = PasteText() + paste_response = paste_action.execute(PasteTextRequest(), clipboard_state) + assert not paste_response.error + assert paste_response.text == test_text + + def test_image_copy_and_paste(self, temp_dir, clipboard_state): + """Test copying and pasting an image.""" + # Create a test image + test_image = Image.new("RGB", (100, 100), color="red") + test_image_path = os.path.join(temp_dir, "test.png") + test_image.save(test_image_path) + + # Copy image + copy_action = CopyImage() + copy_response = copy_action.execute( + CopyImageRequest(image_path=test_image_path), clipboard_state + ) + assert not copy_response.error + + # Paste image + paste_action = PasteImage() + paste_path = os.path.join(temp_dir, "pasted.png") + paste_response = paste_action.execute( + PasteImageRequest(save_path=paste_path), clipboard_state + ) + assert not paste_response.error + assert paste_response.image_path == paste_path + assert os.path.exists(paste_path) + + # Verify pasted image + pasted_image = Image.open(paste_path) + assert pasted_image.size == (100, 100) + assert pasted_image.getpixel((0, 0)) == (255, 0, 0) # Red color + + # Cleanup + pasted_image.close() + os.remove(paste_path) + + def test_file_paths_copy_and_paste(self, temp_dir, clipboard_state): + """Test copying and pasting file paths.""" + # Create test files + test_files = [] + for i in range(3): + file_path = os.path.join(temp_dir, f"test{i}.txt") + with open(file_path, "w") as f: + f.write(f"Test content {i}") + test_files.append(file_path) + + # Copy file paths + copy_action = CopyFilePaths() + copy_response = copy_action.execute( + CopyFilePathsRequest(paths=test_files), clipboard_state + ) + assert not copy_response.error + + # Paste file paths + paste_action = PasteFilePaths() + paste_response = paste_action.execute(PasteFilePathsRequest(), clipboard_state) + assert not paste_response.error + assert paste_response.paths == test_files + + # Cleanup + for file_path in test_files: + os.remove(file_path) + + @skip_if_ci(reason="Timeout") + def test_clipboardtool_with_toolset(self, temp_dir, clipboard_state): + """Test clipboard tool with toolset.""" + # Load clipboard tool to register actions + tool = Clipboardtool() + # Register actions + tool.register() + actions = tool.actions() + + # Register actions in action registry + from composio.tools.base.abs import action_registry + + for action in actions: + action_registry["runtime"][ + f"CLIPBOARDTOOL_{action.__name__.upper()}" + ] = action() + + toolset = ComposioToolSet(workspace_config=WorkspaceType.Host()) + + # Test text copy/paste + test_text = "Hello, World!" + copy_response = toolset.execute_action( + Action.CLIPBOARDTOOL_COPY_TEXT, + {"text": test_text}, + metadata={"clipboard_state": clipboard_state}, + ) + assert not copy_response.get("error") + + paste_response = toolset.execute_action( + Action.CLIPBOARDTOOL_PASTE_TEXT, + {}, + metadata={"clipboard_state": clipboard_state}, + ) + assert not paste_response.get("error") + assert paste_response["data"]["text"] == test_text + + # Test image copy/paste + test_image = Image.new("RGB", (100, 100), color="red") + test_image_path = os.path.join(temp_dir, "test.png") + test_image.save(test_image_path) + + copy_response = toolset.execute_action( + Action.CLIPBOARDTOOL_COPY_IMAGE, + {"image_path": test_image_path}, + metadata={"clipboard_state": clipboard_state}, + ) + assert not copy_response.get("error") + + paste_path = os.path.join(temp_dir, "pasted.png") + paste_response = toolset.execute_action( + Action.CLIPBOARDTOOL_PASTE_IMAGE, + {"save_path": paste_path}, + metadata={"clipboard_state": clipboard_state}, + ) + assert not paste_response.get("error") + assert paste_response["data"]["image_path"] == paste_path + assert os.path.exists(paste_path) + + # Cleanup image files + test_image.close() + os.remove(test_image_path) + os.remove(paste_path) + + # Test file paths copy/paste + test_files = [] + for i in range(3): + file_path = os.path.join(temp_dir, f"test{i}.txt") + with open(file_path, "w") as f: + f.write(f"Test content {i}") + test_files.append(file_path) + + copy_response = toolset.execute_action( + Action.CLIPBOARDTOOL_COPY_FILE_PATHS, + {"paths": test_files}, + metadata={"clipboard_state": clipboard_state}, + ) + assert not copy_response.get("error") + + paste_response = toolset.execute_action( + Action.CLIPBOARDTOOL_PASTE_FILE_PATHS, + {}, + metadata={"clipboard_state": clipboard_state}, + ) + assert not paste_response.get("error") + assert paste_response["data"]["paths"] == test_files + + # Cleanup test files + for file_path in test_files: + os.remove(file_path) From 23d3f72ca6952654875cade313f8a6eb23870d2b Mon Sep 17 00:00:00 2001 From: Suhas Deshpande Date: Tue, 1 Apr 2025 22:50:44 -0700 Subject: [PATCH 2/3] chore: Improve code formatting and readability (#1511) --- README.md | 92 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a30a8b926c8..37be98c5671 100644 --- a/README.md +++ b/README.md @@ -144,52 +144,51 @@ composio add github # Run this in terminal from openai import OpenAI from composio_openai import ComposioToolSet, App, Action +# Initialize OpenAI client openai_client = OpenAI( -api_key="{{OPENAIKEY}}" + api_key="{{OPENAIKEY}}" ) -# Initialise the Composio Tool Set - +# Initialize the Composio Tool Set composio_tool_set = ComposioToolSet() # Get GitHub tools that are pre-configured actions = composio_tool_set.get_actions( -actions=[Action.GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER] + actions=[Action.GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER] ) my_task = "Star a repo composiodev/composio on GitHub" -# Setup openai assistant +# Setup OpenAI assistant assistant_instruction = "You are a super intelligent personal assistant" - assistant = openai_client.beta.assistants.create( -name="Personal Assistant", -instructions=assistant_instruction, -model="gpt-4-turbo", -tools=actions, + name="Personal Assistant", + instructions=assistant_instruction, + model="gpt-4-turbo", + tools=actions, ) -# create a thread +# Create a thread thread = openai_client.beta.threads.create() +# Add user message to thread message = openai_client.beta.threads.messages.create( -thread_id=thread.id, -role="user", -content=my_task + thread_id=thread.id, + role="user", + content=my_task ) # Execute Agent with integrations run = openai_client.beta.threads.runs.create( -thread_id=thread.id, -assistant_id=assistant.id + thread_id=thread.id, + assistant_id=assistant.id ) - # Execute Function calls response_after_tool_calls = composio_tool_set.wait_and_handle_assistant_tool_calls( -client=openai_client, -run=run, -thread=thread, + client=openai_client, + run=run, + thread=thread, ) print(response_after_tool_calls) @@ -223,38 +222,43 @@ import OpenAI from "openai"; const toolset = new OpenAIToolSet({ apiKey: process.env.COMPOSIO_API_KEY }); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); -const tools = await toolset.getTools({ actions: ["GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER"] }); +const tools = await toolset.getTools({ + actions: ["GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER"] +}); async function createGithubAssistant(openai, tools) { -return await openai.beta.assistants.create({ -name: "Github Assistant", -instructions: "You're a GitHub Assistant, you can do operations on GitHub", -tools: tools, -model: "gpt-4o" -}); + return await openai.beta.assistants.create({ + name: "Github Assistant", + instructions: "You're a GitHub Assistant, you can do operations on GitHub", + tools: tools, + model: "gpt-4o" + }); } async function executeAssistantTask(openai, toolset, assistant, task) { -const thread = await openai.beta.threads.create(); -const run = await openai.beta.threads.runs.create(thread.id, { -assistant_id: assistant.id, -instructions: task, -tools: tools, -model: "gpt-4o", -stream: false -}); -const call = await toolset.waitAndHandleAssistantToolCalls(openai, run, thread); -console.log(call); + const thread = await openai.beta.threads.create(); + + const run = await openai.beta.threads.runs.create(thread.id, { + assistant_id: assistant.id, + instructions: task, + tools: tools, + model: "gpt-4o", + stream: false + }); + + const call = await toolset.waitAndHandleAssistantToolCalls(openai, run, thread); + console.log(call); } (async () => { -const githubAssistant = await createGithubAssistant(openai, tools); -await executeAssistantTask( -openai, -toolset, -githubAssistant, -"Star the repository 'composiohq/composio'" -); + const githubAssistant = await createGithubAssistant(openai, tools); + + await executeAssistantTask( + openai, + toolset, + githubAssistant, + "Star the repository 'composiohq/composio'" + ); })(); ``` From 134af133025cdef09d5ee09c5df3f57eaac1f5e6 Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Mon, 14 Apr 2025 10:55:51 +0300 Subject: [PATCH 3/3] feat: add descope auth support (#1519) --- .../use-actions-with-custom-auth.mdx | 29 +++- .../use-tools/use-tools-with-your-auth.mdx | 16 ++ python/composio/exceptions.py | 8 + python/composio/utils/descope.py | 138 ++++++++++++++++++ python/tests/test_tools/test_toolset.py | 107 ++++++++++++++ 5 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 python/composio/utils/descope.py diff --git a/docs/patterns/tools/use-tools/use-actions-with-custom-auth.mdx b/docs/patterns/tools/use-tools/use-actions-with-custom-auth.mdx index 4e5b130f08b..09c7345b264 100644 --- a/docs/patterns/tools/use-tools/use-actions-with-custom-auth.mdx +++ b/docs/patterns/tools/use-tools/use-actions-with-custom-auth.mdx @@ -11,6 +11,8 @@ Composio allows you to [execute actions directly](action-guide-without-agents) u Use the `add_auth` method to add the custom authentication to the toolset for the app you want to use. `in_` is where you want to add the auth, `name` is the name of the header you want to add and `value` is the value of the header. +You can also use `DescopeAuth` for simpler Descope integration. + ```python {7-13} from composio import ComposioToolSet, App @@ -27,11 +29,32 @@ toolset.add_auth( ], ) -toolset.execute_action( - action="GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER", - params={"owner": "composiohq", "repo": "composio"}, +# Method 2: Using DescopeAuth (for Descope) +from composio.utils.descope import DescopeAuth + +# Initialize DescopeAuth with your credentials +descope = DescopeAuth( + project_id="your_project_id", # Or uses DESCOPE_PROJECT_ID env var + management_key="your_management_key" # Or uses DESCOPE_MANAGEMENT_KEY env var +) + +toolset = ComposioToolSet() + +# Add authentication using DescopeAuth +toolset.add_auth( + app=App.GITHUB, + parameters=descope.get_auth( + app=App.GITHUB, + user_id="your_user_id", + scopes=["user", "public_repo"] # Permissions for the token + ) ) ``` + +The `DescopeAuth` utility simplifies authentication with Descope by: +- Generating the necessary authentication tokens for external services +- Managing the authorization headers and metadata +- Setting appropriate scopes for the required permissions Here you need to pass the authentication parameters inside the `authConfig`. `in_` is where you want to add the auth, `name` is the name of the header you want to add and `value` is the value of the header. diff --git a/docs/patterns/tools/use-tools/use-tools-with-your-auth.mdx b/docs/patterns/tools/use-tools/use-tools-with-your-auth.mdx index f7b1c079acb..c19fd14995a 100644 --- a/docs/patterns/tools/use-tools/use-tools-with-your-auth.mdx +++ b/docs/patterns/tools/use-tools/use-tools-with-your-auth.mdx @@ -16,6 +16,7 @@ pip install composio-core ``` Use the `add_auth` method to add the existing authentication to the toolset for the app you want to use. `in_` is where you want to add the auth, `name` is the name of the header you want to add and `value` is the value of the header. +You can also use `DescopeAuth` for simpler Descope integration. ```python {7-13} from composio import ComposioToolSet, App @@ -32,11 +33,26 @@ toolset.add_auth( ], ) +# Method 2: Using DescopeAuth (for Descope) +from composio.utils.descope import DescopeAuth + +descope = DescopeAuth(project_id="your_project_id", management_key="your_management_key") +toolset = ComposioToolSet() + +toolset.add_auth( + App.GITHUB, + descope.get_auth(user_id="your_user_id", scopes=["user", "public_repo"]) +) + toolset.execute_action( action="GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER", params={"owner": "composiohq", "repo": "composio"}, ) ``` + +# Additional Context for DescopeAuth Usage + +To use `DescopeAuth`, ensure you have the required `project_id` and `management_key` from your Descope account. These credentials are necessary to authenticate and generate the required headers for API calls. The `scopes` parameter defines the permissions for the generated token. Install packages: diff --git a/python/composio/exceptions.py b/python/composio/exceptions.py index 089d20179da..b2246fd7439 100644 --- a/python/composio/exceptions.py +++ b/python/composio/exceptions.py @@ -250,3 +250,11 @@ class InvalidConnectedAccount(ValidationError, ConnectedAccountError): class ErrorProcessingToolExecutionRequest(PluginError): pass + + +class DescopeAuthError(ComposioSDKError): + pass + + +class DescopeConfigError(ComposioSDKError): + pass diff --git a/python/composio/utils/descope.py b/python/composio/utils/descope.py new file mode 100644 index 00000000000..cce3022e2ff --- /dev/null +++ b/python/composio/utils/descope.py @@ -0,0 +1,138 @@ +""" +Descope Authentication Module + +This module provides a client for interacting with Descope's authentication API. +It allows generating tokens for users with specific scopes to access external applications. + +Usage: + auth = DescopeAuth() + auth_params = auth.get_auth("my_app", "user123", ["read", "write"]) +""" + +from typing import Optional, List, Dict, Any +import os +import json +import requests +from requests.exceptions import RequestException, HTTPError +from composio.exceptions import DescopeAuthError, DescopeConfigError +from composio.client.collections import CustomAuthParameter +from composio import AppType +import typing as t + + +class DescopeAuth: + """ + Client for Descope authentication services. + + This class handles authentication with Descope's API to generate access tokens + for users to access external applications with specific permissions. + + Attributes: + project_id (str): The Descope project ID + management_key (str): The Descope management key for API access + base_url (str): The base URL for the Descope API + """ + + def __init__( + self, + project_id: Optional[str] = None, + management_key: Optional[str] = None, + base_url: Optional[str] = None + ) -> None: + """ + Initialize a new DescopeAuth client. + + Args: + project_id: The Descope project ID. If not provided, will try to read from DESCOPE_PROJECT_ID environment variable. + management_key: The Descope management key. If not provided, will try to read from DESCOPE_MANAGEMENT_KEY environment variable. + base_url: The base URL for the Descope API. If not provided, will try to read from DESCOPE_BASE_URL environment variable + or default to "https://api.descope.com". + + Raises: + DescopeConfigError: If project_id or management_key is not provided and not available in environment variables. + """ + self.project_id = project_id or os.environ.get("DESCOPE_PROJECT_ID") + self.management_key = management_key or os.environ.get("DESCOPE_MANAGEMENT_KEY") + self.base_url = base_url or os.environ.get("DESCOPE_BASE_URL") or "https://api.descope.com" + + if not self.project_id: + raise DescopeConfigError("Descope project ID is required.") + if not self.management_key: + raise DescopeConfigError("Descope management key is required.") + + def get_auth( + self, + app: AppType, + user_id: str, + scopes: List[str] + ) -> List[CustomAuthParameter]: + """ + Get Descope authentication parameters for a user to access an application. + + This method generates a Descope access token for the specified user and app + with the requested permission scopes. + + Args: + app: The application identifier or AppType enum + user_id: The user ID to generate the token for + scopes: List of permission scopes to include in the token + + Returns: + List of CustomAuthParameter objects containing: + - Authorization header with the access token + - Metadata containing the scopes + + Raises: + DescopeAuthError: If there is an error during the authentication process, + including network issues, invalid responses, or other unexpected errors. + """ + descope_url = f"{self.base_url}/v1/mgmt/outbound/app/user/token" + app_id = app if isinstance(app, str) else str(app).lower() + + payload = json.dumps({ + "appId": app_id, + "userId": user_id, + "scopes": scopes + }) + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.project_id}:{self.management_key}", + } + + try: + response = requests.post( + descope_url, + headers=headers, + data=payload, + timeout=10, + verify=False + ) + response.raise_for_status() + + response_data = response.json() + + if "token" not in response_data or "accessToken" not in response_data["token"]: + raise DescopeAuthError("Invalid response format: missing 'token' or 'accessToken'.") + + return [ + CustomAuthParameter( + in_="header", + name="Authorization", + value=f"Bearer {response_data['token']['accessToken']}" + ), + CustomAuthParameter( + in_="metadata", + name="scopes", + value=",".join(scopes) + ) + ] + + except HTTPError as e: + raise DescopeAuthError(f"HTTP error during Descope token request: {e.response.status_code} - {e.response.text}") from e + except RequestException as e: + raise DescopeAuthError(f"Error during Descope token request: {e}") from e + except json.JSONDecodeError as e: + raise DescopeAuthError(f"Invalid JSON response from server: {e}") from e + except Exception as e: + raise DescopeAuthError(f"Unexpected error during Descope token request: {e}") from e diff --git a/python/tests/test_tools/test_toolset.py b/python/tests/test_tools/test_toolset.py index 911c484135e..ee452116ca5 100644 --- a/python/tests/test_tools/test_toolset.py +++ b/python/tests/test_tools/test_toolset.py @@ -24,6 +24,7 @@ from composio.utils.pypi import reset_installed_list from composio_langchain.toolset import ComposioToolSet as LangchainToolSet +from composio.utils.descope import DescopeAuth def test_get_schemas() -> None: @@ -529,6 +530,112 @@ def action_2( assert result["successful"] +def test_custom_descope_auth_fails_on_localtool(): + # Prepare a fake token response for Descope + fake_response = {"token": {"accessToken": "dummy-token"}} + fake_post_response = mock.MagicMock() + fake_post_response.raise_for_status = lambda: None + fake_post_response.json.return_value = fake_response + + # Patch requests.post for the entire descope flow. + with mock.patch( + "composio.utils.descope.requests.post", return_value=fake_post_response + ): + toolset = ComposioToolSet( + descope_config=DescopeAuth( + project_id="project_id", + management_key="management_key", + ) + ) + # This call now uses the patched requests.post and should return "dummy-token" + descope = DescopeAuth(project_id="project_id", management_key="management_key") + toolset.add_auth( + app=Filetool.enum, + parameters=descope.get_auth(Filetool.enum, user_id="user_id", scopes=["openid", "email"]), + ) + + def _execute(cls, request, metadata): # pylint: disable=unused-argument + return mock.MagicMock( + model_dump=lambda *_: { + "assert": metadata["name"] == "value", + }, + ) + + # Since local tools only accept metadata-based custom auth, + # the header-based token should trigger failure. + with pytest.raises( + ComposioSDKError, + match="Invalid custom auth found for FILETOOL", + ): + toolset.execute_action( + action=FindFile.enum, + params={ + "pattern": "*.py", + }, + ) + + +def test_custom_descope_auth_runtime_tool(): + reset_installed_list() + tool = "tool" + expected_data = { + "headers": {"Authorization": "Bearer dummy-token"}, + } + + @custom_action(toolname=tool) + def action_descope_1(auth: t.Dict) -> int: + """ + Custom action 1 + + :return exit_code: int + """ + assert auth["headers"] == expected_data["headers"] + return 0 + + class Req(BaseModel): + pass + + class Res(BaseModel): + data: int = Field(...) + + @custom_action(toolname=tool) + def action_descope_2( + request: Req, # pylint: disable=unused-argument + metadata: dict, + ) -> Res: + assert metadata["headers"] == expected_data["headers"] + return Res(data=0) + + # Prepare a fake token response for Descope + fake_response = {"token": {"accessToken": "dummy-token"}} + fake_post_response = mock.MagicMock() + fake_post_response.raise_for_status = lambda: None + fake_post_response.json.return_value = fake_response + + # Patch requests.post so that get_access_token returns our fake token. + with mock.patch( + "composio.utils.descope.requests.post", return_value=fake_post_response + ): + toolset = ComposioToolSet( + descope_config=DescopeAuth( + project_id="project_id", + management_key="management_key", + ) + ) + # Updated to use DescopeAuth and add_auth + descope = DescopeAuth(project_id="project_id", management_key="management_key") + toolset.add_auth( + app="tool", + parameters=descope.get_auth("tool", user_id="user_id", scopes=["openid", "email"]), + ) + + result = toolset.execute_action(action=action_descope_1, params={}) + assert result["successful"] + + result = toolset.execute_action(action=action_descope_2, params={}) + assert result["successful"] + + class TestSubclassInit: def test_runtime(self):