diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 00000000000..8881e440297 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1 @@ +*.composio.lock \ No newline at end of file diff --git a/python/composio/client/collections.py b/python/composio/client/collections.py index e3ab3dd9b81..622608203d5 100644 --- a/python/composio/client/collections.py +++ b/python/composio/client/collections.py @@ -1027,16 +1027,19 @@ class ActionModel(BaseModel): """Action data model.""" name: str - display_name: t.Optional[str] = None + description: str parameters: ActionParametersModel response: ActionResponseModel appName: str appId: str - tags: t.List[str] - enabled: bool = False + version: str + available_versions: t.List[str] + tags: t.List[str] logo: t.Optional[str] = None - description: t.Optional[str] = None + + display_name: t.Optional[str] = None + enabled: bool = False ParamPlacement = t.Literal["header", "path", "query", "subdomain", "metadata"] @@ -1080,8 +1083,19 @@ class Actions(Collection[ActionModel]): model = ActionModel endpoint = v2.actions - # TODO: Overload - def get( # type: ignore + def _get_action(self, action: ActionType) -> ActionModel: + return self.model( + **self._raise_if_required( + response=self.client.http.get( + url=str(self.endpoint / str(action)), + params={ + "version": Action(action).version, + }, + ) + ).json() + ) + + def _get_actions( self, actions: t.Optional[t.Sequence[ActionType]] = None, apps: t.Optional[t.Sequence[AppType]] = None, @@ -1090,18 +1104,6 @@ def get( # type: ignore use_case: t.Optional[str] = None, allow_all: bool = False, ) -> t.List[ActionModel]: - """ - Get a list of apps by the specified filters. - - :param actions: Filter by the list of Actions. - :param apps: Filter by the list of Apps. - :param tags: Filter by the list of given Tags. - :param limit: Limit the number of actions to a specific number. - :param use_case: Filter by use case. - :param allow_all: Allow querying all of the actions for a specific - app - :return: List of actions - """ def is_action(obj): try: @@ -1224,6 +1226,76 @@ def is_action(obj): items = [self.model(**item) for item in local_items] + items return items + @t.overload # type: ignore + def get(self) -> t.List[ActionModel]: ... + + @t.overload # type: ignore + def get(self, action: t.Optional[ActionType] = None) -> ActionModel: ... + + @t.overload # type: ignore + def get( + self, + actions: t.Optional[t.Sequence[ActionType]] = None, + apps: t.Optional[t.Sequence[AppType]] = None, + tags: t.Optional[t.Sequence[TagType]] = None, + limit: t.Optional[int] = None, + use_case: t.Optional[str] = None, + allow_all: bool = False, + ) -> t.List[ActionModel]: ... + + def get( # type: ignore + self, + action: t.Optional[ActionType] = None, + *, + actions: t.Optional[t.Sequence[ActionType]] = None, + apps: t.Optional[t.Sequence[AppType]] = None, + tags: t.Optional[t.Sequence[TagType]] = None, + limit: t.Optional[int] = None, + use_case: t.Optional[str] = None, + allow_all: bool = False, + ) -> t.Union[ActionModel, t.List[ActionModel]]: + """ + Get a list of apps by the specified filters. + + :param actions: Filter by the list of Actions. + :param action: Get data for this action. + :param apps: Filter by the list of Apps. + :param tags: Filter by the list of given Tags. + :param limit: Limit the number of actions to a specific number. + :param use_case: Filter by use case. + :param allow_all: Allow querying all of the actions for a specific + app + :return: List of actions + """ + if action is not None: + return self._get_action(action=action) + + return self._get_actions( + actions=actions, + apps=apps, + tags=tags, + limit=limit, + use_case=use_case, + allow_all=allow_all, + ) + + @staticmethod + def _serialize_auth(auth: t.Optional[CustomAuthObject]) -> t.Optional[t.Dict]: + if auth is None: + return None + + data = auth.model_dump(exclude_none=True) + data["parameters"] = [ + {"in": d["in_"], "name": d["name"], "value": d["value"]} + for d in data["parameters"] + ] + for param in data["parameters"]: + if param["in"] == "metadata": + raise ComposioClientError( + f"Param placement cannot be 'metadata' for remote action execution: {param}" + ) + return data + def execute( self, action: Action, @@ -1297,6 +1369,7 @@ def execute( "appName": action.app, "input": modified_params, "text": text, + "version": action.version, "sessionInfo": { "sessionId": session_id, }, @@ -1319,6 +1392,7 @@ def execute( "appName": action.app, "input": modified_params, "text": text, + "version": action.version, "authConfig": self._serialize_auth(auth=auth), "sessionInfo": { "sessionId": session_id, @@ -1353,23 +1427,6 @@ def request( }, ).json() - @staticmethod - def _serialize_auth(auth: t.Optional[CustomAuthObject]) -> t.Optional[t.Dict]: - if auth is None: - return None - - data = auth.model_dump(exclude_none=True) - data["parameters"] = [ - {"in": d["in_"], "name": d["name"], "value": d["value"]} - for d in data["parameters"] - ] - for param in data["parameters"]: - if param["in"] == "metadata": - raise ComposioClientError( - f"Param placement cannot be 'metadata' for remote action execution: {param}" - ) - return data - def search_for_a_task( self, use_case: str, diff --git a/python/composio/client/enums/action.py b/python/composio/client/enums/action.py index 72f44c0a189..26525826ebf 100644 --- a/python/composio/client/enums/action.py +++ b/python/composio/client/enums/action.py @@ -3,17 +3,37 @@ from composio.client.enums.base import ActionData, replacement_action_name from composio.client.enums.enum import Enum, EnumGenerator +from composio.constants import VERSION_LATEST, VERSION_LATEST_BASE from composio.exceptions import ComposioSDKError _ACTION_CACHE: t.Dict[str, "Action"] = {} +class InvalidVersionString(ComposioSDKError): + + def __init__(self, message: str, *args: t.Any, delegate: bool = False) -> None: + super().__init__(message, *args, delegate=delegate) + + +def clean_version_string(version: str) -> str: + version = version.lower() + if version in (VERSION_LATEST, VERSION_LATEST_BASE): + return version + + version = "_".join(version.split(".")).lstrip("v") + if version.count("_") != 1: + raise InvalidVersionString(version) + return version + + class Action(Enum[ActionData], metaclass=EnumGenerator): cache_folder = "actions" cache = _ACTION_CACHE storage = ActionData + _version: t.Optional[str] = None + def load(self) -> ActionData: """Handle deprecated actions""" action_data = super().load() @@ -121,3 +141,38 @@ def is_local(self) -> bool: def is_runtime(self) -> bool: """If set `True` the `app` is a runtime app.""" return self.load().is_runtime + + @property + def is_version_set(self) -> bool: + """If `True` version is set explicitly.""" + return self._version is not None + + @property + def version(self) -> str: + """Version string for the action enum instance.""" + return self._version or self.load().version + + @property + def available_versions(self) -> t.List[str]: + """List of available version strings.""" + return self.load().available_version + + def with_version(self, version: str) -> "Action": + if self.is_local: + raise RuntimeError("Versioning is not allowed for local tools") + + action = Action(self.slug, cache=False) + action._data = self.load() # pylint: disable=protected-access + action._version = clean_version_string( # pylint: disable=protected-access + version=version + ) + return action + + def latest(self) -> "Action": + return self.with_version(version=VERSION_LATEST) + + def latest_base(self) -> "Action": + return self.with_version(version=VERSION_LATEST_BASE) + + def __matmul__(self, other: str) -> "Action": + return self.with_version(version=other) diff --git a/python/composio/client/enums/action.pyi b/python/composio/client/enums/action.pyi index 7a6f983ae38..14dc4db37ab 100644 --- a/python/composio/client/enums/action.pyi +++ b/python/composio/client/enums/action.pyi @@ -7,11 +7,15 @@ from composio.exceptions import ComposioSDKError _ACTION_CACHE: t.Dict[str, "Action"] = {} +def clean_version_string(version: str) -> str: ... + class Action(Enum[ActionData], metaclass=EnumGenerator): cache_folder = "actions" cache = _ACTION_CACHE storage = ActionData + _version: t.Optional[str] = None + def load(self) -> ActionData: ... def load_from_runtime(self) -> t.Optional[ActionData]: ... def fetch_and_cache(self) -> t.Optional[ActionData]: ... @@ -27,6 +31,17 @@ class Action(Enum[ActionData], metaclass=EnumGenerator): def is_local(self) -> bool: ... @property def is_runtime(self) -> bool: ... + @property + def is_version_set(self) -> bool: ... + @property + def version(self) -> str: ... + @property + def available_versions(self) -> t.List[str]: ... + def with_version(self, version: str) -> "Action": ... + def latest(self) -> "Action": ... + def latest_base(self) -> "Action": ... + def __matmul__(self, other: str) -> "Action": ... + AFFINITY_GET_ALL_COMPANIES: "Action" AFFINITY_GET_ALL_LIST_ENTRIES_ON_A_LIST: "Action" AFFINITY_GET_ALL_LIST_ENTRIES_ON_A_SAVED_VIEW: "Action" diff --git a/python/composio/client/enums/base.py b/python/composio/client/enums/base.py index dbe286abfa9..83ac5194563 100644 --- a/python/composio/client/enums/base.py +++ b/python/composio/client/enums/base.py @@ -5,7 +5,14 @@ import difflib import typing as t -from composio.constants import LOCAL_CACHE_DIRECTORY +from pydantic import Field + +from composio.constants import ( + COMPOSIO_VERSIONING_POLICY, + LOCAL_CACHE_DIRECTORY, + VERSION_LATEST, + VERSION_LATEST_BASE, +) from composio.exceptions import ComposioSDKError from composio.storage.base import LocalStorage @@ -86,6 +93,17 @@ class ActionData(LocalStorage): replaced_by: t.Optional[str] = None "If set, the action is deprecated and replaced by the given action." + version: str = COMPOSIO_VERSIONING_POLICY + "Specify what version to use when executing action." + + available_version: t.List[str] = Field( + default_factory=lambda: [ + VERSION_LATEST, + VERSION_LATEST_BASE, + ] + ) + "Specify what version to use when executing action." + class TriggerData(LocalStorage): """Local storage for `Trigger` object.""" diff --git a/python/composio/client/enums/enum.py b/python/composio/client/enums/enum.py index bfeacb322e2..8a341df0519 100644 --- a/python/composio/client/enums/enum.py +++ b/python/composio/client/enums/enum.py @@ -24,7 +24,11 @@ class Enum(t.Generic[DataT]): cache: t.Dict[str, "te.Self"] storage: t.Type[DataT] - def __new__(cls, value: t.Union[str, te.Self, t.Type[SentinalObject]]) -> "te.Self": + def __new__( + cls, + value: t.Union[str, te.Self, t.Type[SentinalObject]], + cache: bool = True, + ) -> "te.Self": """Cache the enum singleton.""" # No caching for runtime actions if hasattr(value, "sentinel"): # TODO: get rid of SentinalObject @@ -41,14 +45,19 @@ def __new__(cls, value: t.Union[str, te.Self, t.Type[SentinalObject]]) -> "te.Se value = value.upper() cached_enum = cls.cache.get(value) - if cached_enum is not None: + if cache and cached_enum is not None: return cached_enum # type: ignore[return-value] enum = super().__new__(cls) - cls.cache[value] = enum + if cache: + cls.cache[value] = enum return enum - def __init__(self, value: t.Union[str, te.Self, t.Type[SentinalObject]]) -> None: + def __init__( + self, + value: t.Union[str, te.Self, t.Type[SentinalObject]], + cache: bool = True, # pylint: disable=unused-argument + ) -> None: if hasattr(self, "_data"): # Object was pulled from cache and is already initialized return diff --git a/python/composio/constants.py b/python/composio/constants.py index 2025d779f0b..2ea5c5e348e 100644 --- a/python/composio/constants.py +++ b/python/composio/constants.py @@ -2,6 +2,7 @@ Global constants for Composio SDK """ +import os from pathlib import Path @@ -15,6 +16,11 @@ Environment variable for specifying logging level """ +ENV_COMPOSIO_VERSIONING_POLICY = "COMPOSIO_VERSIONING_POLICY" +""" +Environment variable for specifying default versioning policy. +""" + LOCAL_CACHE_DIRECTORY_NAME = ".composio" """ Local cache directory name for composio CLI @@ -98,3 +104,19 @@ """ Name of the pusher cluster. """ + +LOCKFILE_PATH = Path("./.composio.lock") +""" +Path to the .composio.lock file. +""" + +VERSION_LATEST = "latest" +"""Latest version specifier.""" + +VERSION_LATEST_BASE = "latest:base" +"""Latest none-breaking version specifier.""" + +COMPOSIO_VERSIONING_POLICY = os.environ.get( + ENV_COMPOSIO_VERSIONING_POLICY, + VERSION_LATEST_BASE, +) diff --git a/python/composio/tools/base/runtime.py b/python/composio/tools/base/runtime.py index 08ad0fb58c6..b11d150013c 100644 --- a/python/composio/tools/base/runtime.py +++ b/python/composio/tools/base/runtime.py @@ -157,6 +157,7 @@ class WrappedAction(RuntimeAction[request_schema, response_schema]): # type: ig display_name = f.__name__ file = _file + runtime = True requires = _requires run_on_shell: bool = runs_on_shell @@ -180,9 +181,9 @@ def execute(self, request: t.Any, metadata: dict) -> t.Any: cls.__doc__ = f.__doc__ cls.description = f.__doc__ # type: ignore - existing_actions = [] # Normalize app name toolname = toolname.upper() + existing_actions = [] if toolname in tool_registry["runtime"]: existing_actions = tool_registry["runtime"][toolname].actions() tool = _create_tool_class(name=toolname, actions=[cls, *existing_actions]) # type: ignore diff --git a/python/composio/tools/local/handler.py b/python/composio/tools/local/handler.py index 4650f004e7f..3cb28a3fac7 100644 --- a/python/composio/tools/local/handler.py +++ b/python/composio/tools/local/handler.py @@ -55,7 +55,6 @@ def get_action_schemas( for schema in action_schemas: schema["name"] = schema["enum"] - return action_schemas diff --git a/python/composio/tools/toolset.py b/python/composio/tools/toolset.py index 10bfa4a46a4..6f5dfcb3808 100644 --- a/python/composio/tools/toolset.py +++ b/python/composio/tools/toolset.py @@ -18,6 +18,7 @@ from pathlib import Path import typing_extensions as te +import yaml from pydantic import BaseModel from composio import Action, ActionType, App, AppType, TagType @@ -50,12 +51,13 @@ ENV_COMPOSIO_API_KEY, LOCAL_CACHE_DIRECTORY, LOCAL_OUTPUT_FILE_DIRECTORY_NAME, + LOCKFILE_PATH, USER_DATA_FILE_NAME, + VERSION_LATEST, ) from composio.exceptions import ApiKeyNotProvidedError, ComposioSDKError from composio.storage.user import UserData -from composio.tools.base.abs import tool_registry -from composio.tools.base.local import LocalAction +from composio.tools.base.abs import action_registry, tool_registry from composio.tools.env.base import ( ENV_GITHUB_ACCESS_TOKEN, Workspace, @@ -139,6 +141,114 @@ class _Retry: RETRY = _Retry() +class VersionError(ComposioSDKError): + pass + + +class VersionSelectionError(VersionError): + + def __init__( + self, + action: str, + requested: str, + locked: str, + delegate: bool = False, + ) -> None: + self.action = action + self.requested = requested + self.locked = locked + super().__init__( + message=( + f"Error selecting version for action: {action!r}, " + f"requested: {requested!r}, locked: {locked!r}" + ), + delegate=delegate, + ) + + +class VersionLock: + """Lock file representing action->version mapping""" + + def __init__(self, path: t.Optional[Path] = None) -> None: + self._path = path or LOCKFILE_PATH + self._versions = self.__load() + + def __getitem__(self, action: ActionType) -> str: + return self._versions[str(action)] + + def __setitem__(self, action: ActionType, version: str) -> None: + self._versions[str(action)] = version + + def __contains__(self, action: ActionType) -> bool: + return str(action) in self._versions + + def __load(self) -> t.Dict[str, str]: + """Load tool versions from lockfile.""" + if not self._path.exists(): + return {} + + with open(self._path, encoding="utf-8") as file: + versions = yaml.safe_load(file) + + if not isinstance(versions, dict): + raise ComposioSDKError( + f"Invalid lockfile format, expected dict, got {type(versions)}" + ) + + for tool in versions.values(): + if not isinstance(tool, str): + raise ComposioSDKError( + f"Invalid lockfile format, expected version to be string, got {tool!r}" + ) + + return versions + + def __store(self) -> None: + """Store tool versions to lockfile.""" + with open(self._path, "w", encoding="utf-8") as file: + yaml.safe_dump(self._versions, file) + + set = __setitem__ + + def update( + self, + versions: t.Optional[t.Dict[str, str]] = None, + override: bool = False, + **_versions: str, + ): + for action, version in {**(versions or {}), **_versions}.items(): + if action in self._versions and not override: + continue + self._versions[action] = version + + def _apply(self, action: Action) -> Action: + if action not in self: + if action.is_version_set: + self[action] = action.version + return action + + if action.is_version_set: + if action.version == self[action]: + return action + + raise VersionSelectionError( + action=action.slug, + requested=action.version, + locked=self[action], + ) + + return action @ self[action] + + def apply(self, actions: t.List[Action]) -> t.List[Action]: + return list(map(self._apply, actions)) + + def get(self, action: ActionType) -> t.Optional[str]: + return self._versions.get(str(action)) + + def lock(self) -> None: + self.__store() + + class ComposioToolSet(WithLogger): # pylint: disable=too-many-public-methods """Composio toolset.""" @@ -198,6 +308,8 @@ def __init__( connected_account_ids: t.Optional[t.Dict[AppType, str]] = None, *, max_retries: int = 3, + lockfile: t.Optional[Path] = None, + lock: bool = True, **kwargs: t.Any, ) -> None: """ @@ -277,14 +389,12 @@ def _limit_file_search_response(response: t.Dict) -> t.Dict: verbosity_level=verbosity_level, ) self.session_id = workspace_id or uuid.uuid4().hex - self.entity_id = entity_id self.output_in_file = output_in_file self.output_dir = ( output_dir or LOCAL_CACHE_DIRECTORY / LOCAL_OUTPUT_FILE_DIRECTORY_NAME ) self._ensure_output_dir_exists() - self._base_url = base_url or get_api_url_base() try: self._api_key = ( @@ -326,9 +436,9 @@ def _limit_file_search_response(response: t.Dict) -> t.Dict: ) self.max_retries = max_retries - # To be populated by get_tools(), from within subclasses like - # composio_openai's Toolset. + # To be populated by get_tools(), from within subclasses like composio_openai's Toolset. self._requested_actions: t.List[str] = [] + self._version_lock = VersionLock(path=lockfile) if lock else None def _validating_connection_ids( self, @@ -862,6 +972,9 @@ def execute_action( :return: Output object from the function call """ action = Action(action) + if self._version_lock is not None: + (action,) = self._version_lock.apply(actions=[action]) + if _check_requested_actions and action.slug not in self._requested_actions: raise ComposioSDKError( f"Action {action.slug} is being called, but was never requested by the toolset. " @@ -881,7 +994,8 @@ def execute_action( ) self.logger.debug( - f"Executing `{action.slug}` with {params=} and {metadata=} {connected_account_id=}" + f"Executing `{action.slug}@{action.version}` with {params=} " + f"and {metadata=} {connected_account_id=}" ) failed_responses = [] @@ -1011,82 +1125,120 @@ def validate_tools( tags=tags, ) - def get_action_schemas( + def __map_enums(self, _t: t.Type[T], _sequence: t.Sequence[t.Any]) -> t.Sequence[T]: + return list(map(_t, _sequence)) + + def __get_runtime_action_schemas( + self, actions: t.List[Action] + ) -> t.List[ActionModel]: + items: list[ActionModel] = [] + for action in actions: + if not action.is_runtime: + continue + + _action = action_registry["runtime"][action.slug] + schema = _action.schema() + schema["name"] = _action.enum + items.append( + ActionModel( + **schema, + version=VERSION_LATEST, + available_versions=[VERSION_LATEST], + ).model_copy(deep=True) + ) + return items + + def __get_local_action_schemas( self, - apps: t.Optional[t.Sequence[AppType]] = None, - actions: t.Optional[t.Sequence[ActionType]] = None, + apps: t.List[App], + actions: t.List[Action], tags: t.Optional[t.Sequence[TagType]] = None, - *, - check_connected_accounts: bool = True, - _populate_requested: bool = False, ) -> t.List[ActionModel]: - runtime_actions = t.cast( - t.List[t.Type[LocalAction]], - [action for action in actions or [] if hasattr(action, "run_on_shell")], - ) - actions = t.cast( - t.List[Action], - [ - Action(action) - for action in actions or [] - if action not in runtime_actions - ], - ) - apps = t.cast(t.List[App], [App(app) for app in apps or []]) - items: t.List[ActionModel] = [] - - local_actions = [action for action in actions if action.is_local] - local_apps = [app for app in apps if app.is_local] - if len(local_actions) > 0 or len(local_apps) > 0: - items += [ - ActionModel(**item) + actions = [ + action for action in actions if action.is_local and not action.is_runtime + ] + apps = [app for app in apps if app.is_local] + if len(actions) > 0 or len(apps) > 0: + return [ + ActionModel( + **item, + version=VERSION_LATEST, + available_versions=[VERSION_LATEST], + ) for item in self._local_client.get_action_schemas( - apps=local_apps, - actions=local_actions, + apps=apps, + actions=actions, tags=tags, ) ] + return [] + def __get_remote_actions_schemas( + self, + apps: t.List[App], + actions: t.List[Action], + tags: t.Optional[t.Sequence[TagType]] = None, + check_connected_accounts: bool = True, + ) -> t.List[ActionModel]: remote_actions = [action for action in actions if not action.is_local] remote_apps = [app for app in apps if not app.is_local] if len(remote_actions) > 0 or len(remote_apps) > 0: - remote_items = self.client.actions.get( - apps=remote_apps, - actions=remote_actions, - tags=tags, - ) + versioned_actions = [a for a in remote_actions if a.is_version_set] + none_versioned_actions = [a for a in remote_actions if not a.is_version_set] + # TODO: use tool version when fetching actions + items = [self.client.actions.get(a) for a in versioned_actions] + if len(none_versioned_actions) > 0: + items += self.client.actions.get( + apps=remote_apps, + actions=none_versioned_actions, + tags=tags, + ) + if check_connected_accounts: - for item in remote_items: + for item in items: self.check_connected_account(action=item.name) + else: warnings.warn( "Not verifying connected accounts for apps." " Actions may fail when the Agent tries to use them.", UserWarning, ) - items = items + remote_items - - for act in runtime_actions: - schema = act.schema() - schema["name"] = act.enum - items.append(ActionModel(**schema).model_copy(deep=True)) - - for item in items: - item = self._process_schema(item) + return items + return [] - # This is to support anthropic-claude - if item.name == Action.ANTHROPIC_BASH_COMMAND.slug: - item.name = "bash" - - if item.name == Action.ANTHROPIC_COMPUTER.slug: - item.name = "computer" - - if item.name == Action.ANTHROPIC_TEXT_EDITOR.slug: - item.name = "str_replace_editor" + def get_action_schemas( + self, + apps: t.Optional[t.Sequence[AppType]] = None, + actions: t.Optional[t.Sequence[ActionType]] = None, + tags: t.Optional[t.Sequence[TagType]] = None, + *, + check_connected_accounts: bool = True, + _populate_requested: bool = False, + # TODO: take manual override version as parameter + ) -> t.List[ActionModel]: + apps = t.cast(t.List[App], self.__map_enums(App, apps or [])) + actions = t.cast(t.List[Action], self.__map_enums(Action, actions or [])) + if self._version_lock is not None: + actions = self._version_lock.apply(actions=actions) + + items: t.List[ActionModel] = [ + *self.__get_runtime_action_schemas(actions=actions), + *self.__get_local_action_schemas(apps=apps, actions=actions, tags=tags), + *self.__get_remote_actions_schemas( + apps=apps, + actions=actions, + check_connected_accounts=check_connected_accounts, + tags=tags, + ), + ] + items = list(map(self._process_schema, items)) if _populate_requested: - action_names = [item.name for item in items] - self._requested_actions += action_names + self._requested_actions += [item.name for item in items] + + if self._version_lock is not None: + self._version_lock.lock() return items @@ -1148,6 +1300,17 @@ def _process_schema(self, action_item: ActionModel) -> ActionModel: ) if self._action_name_char_limit is not None: action_item.name = action_item.name[: self._action_name_char_limit] + + # This is to support anthropic-claude + if action_item.name == Action.ANTHROPIC_BASH_COMMAND.slug: + action_item.name = "bash" + + if action_item.name == Action.ANTHROPIC_COMPUTER.slug: + action_item.name = "computer" + + if action_item.name == Action.ANTHROPIC_TEXT_EDITOR.slug: + action_item.name = "str_replace_editor" + return action_item def create_trigger_listener(self, timeout: float = 15.0) -> TriggerSubscription: diff --git a/python/tests/test_client/test_enum.py b/python/tests/test_client/test_enum.py index 9209f286982..d43925811a1 100644 --- a/python/tests/test_client/test_enum.py +++ b/python/tests/test_client/test_enum.py @@ -10,6 +10,7 @@ from composio import action from composio.client.enums import Action, App, Tag, Trigger +from composio.client.enums.action import clean_version_string from composio.client.enums.enum import EnumStringNotFound from composio.tools.base.local import LocalAction, LocalTool @@ -173,3 +174,28 @@ def test_invalid_enum(): with pytest.raises(EnumStringNotFound): App.SOME_BS.load() + + +@pytest.mark.parametrize( + "version,clean", + ( + ("2.0", "2_0"), + ("v0.1", "0_1"), + ("v2.0", "2_0"), + ("Latest", "latest"), + ), +) +def test_clean_action_version_strings(version: str, clean: str): + assert clean_version_string(version=version) == clean + + +@pytest.mark.parametrize("version", ("0_5", "2_0", "latest")) +def test_action_version_specifier(version): + assert Action.GITHUB_ACCEPT_A_REPOSITORY_INVITATION.version != version + assert (Action.GITHUB_ACCEPT_A_REPOSITORY_INVITATION @ version).version == version + + +def test_is_version_set(): + assert Action.GITHUB_ACCEPT_A_REPOSITORY_INVITATION.is_version_set is False + assert (Action.GITHUB_ACCEPT_A_REPOSITORY_INVITATION @ "0_1").is_version_set is True + assert Action.GITHUB_ACCEPT_A_REPOSITORY_INVITATION.is_version_set is False diff --git a/python/tox.ini b/python/tox.ini index ecc3dc0074f..63e47c7905e 100644 --- a/python/tox.ini +++ b/python/tox.ini @@ -115,6 +115,7 @@ deps = gitpython>=3.1.43 unidiff==0.7.5 tqdm==4.66.4 + uv commands = ; Install swekit uv pip install -e swe/ --no-deps