diff --git a/.github/workflows/check-unauthorized-toolkit-updates.yml b/.github/workflows/check-unauthorized-toolkit-updates.yml deleted file mode 100644 index 7bb5db01..00000000 --- a/.github/workflows/check-unauthorized-toolkit-updates.yml +++ /dev/null @@ -1,90 +0,0 @@ -# This workflow prevents unauthorized updates to existing toolkit versions, -# as well as unauthorized renames or removals of toolkits. -# Toolkits are versioned via the `toolkits/*/pyproject.toml` file. -# It ensures that only toolkit release managers can modify existing toolkit versions, rename, or remove toolkits. -# If a pull request is made by someone not in the toolkit release managers list, then the workflow -# will fail if any existing toolkit version is changed, or if a toolkit is renamed or removed. - -name: Prevent Unauthorized Version Updates - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -jobs: - version-check: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Load toolkit release managers - id: load_trm - run: | - echo "Loading authorized toolkit release managers from toolkits/TOOLKIT_RELEASE_MANAGERS.txt" - if [[ -f toolkits/TOOLKIT_RELEASE_MANAGERS.txt ]]; then - TOOLKIT_RELEASE_MANAGERS=$(cat toolkits/TOOLKIT_RELEASE_MANAGERS.txt | tr '\n' ' ') - echo "toolkit_release_managers=${TOOLKIT_RELEASE_MANAGERS}" >> $GITHUB_OUTPUT - else - echo "ERROR: TOOLKIT_RELEASE_MANAGERS.txt not found." - exit 1 - fi - - - name: Check if PR author is a toolkit release manager - id: check_author - run: | - PR_AUTHOR="${{ github.event.pull_request.user.login }}" - echo "PR Author: $PR_AUTHOR" - if echo "${{ steps.load_trm.outputs.toolkit_release_managers }}" | grep -wq "$PR_AUTHOR"; then - echo "Author is a toolkit release manager. Exiting workflow successfully." - echo "authorized=true" >> $GITHUB_OUTPUT - else - echo "Author is not authorized to perform toolkit release. Need to perform toolkit version checks." - echo "authorized=false" >> $GITHUB_OUTPUT - fi - - - name: Get versions from current commit - if: steps.check_author.outputs.authorized == 'false' - id: current_versions - # Get all toolkits in the format of "package_name=version" for the PR's current commit and save to current_versions.txt - run: | - paste <(cat toolkits/*/pyproject.toml | grep "^name = " | grep "arcade_" | cut -d'"' -f2) <(cat toolkits/*/pyproject.toml | grep "^version = " | cut -d'"' -f2) | awk '{print $1"="$2}' > current_versions.txt - echo "Package versions in current commit:" - cat current_versions.txt - - - name: Get versions from target branch - if: steps.check_author.outputs.authorized == 'false' - id: target_versions - # Get all toolkits in the format of "package_name=version" for the target branch and save to target_versions.txt - run: | - git fetch origin main - git checkout origin/main - paste <(cat toolkits/*/pyproject.toml | grep "^name = " | grep "arcade_" | cut -d'"' -f2) <(cat toolkits/*/pyproject.toml | grep "^version = " | cut -d'"' -f2) | awk '{print $1"="$2}' > target_versions.txt - echo "Package versions in target branch:" - cat target_versions.txt - - - name: Compare versions - if: steps.check_author.outputs.authorized == 'false' - id: compare_versions - # Iterate over each toolkit in the target branch and compare its version with the current commit - # Only fails if an existing toolkit version is changed, or if a toolkit is renamed or removed. - run: | - while read -r target_line; do - package_name=$(echo "$target_line" | cut -d'=' -f1) - target_version=$(echo "$target_line" | cut -d'=' -f2) - current_version=$(grep "^$package_name=" current_versions.txt | cut -d'=' -f2) - echo "Comparing $package_name: $target_version (target) vs $current_version (current)" - if [ -z "$current_version" ]; then - echo "Package $package_name has been removed or renamed." - echo "ERROR: Only toolkit release managers can remove or rename toolkits." - exit 1 - elif [ "$target_version" != "$current_version" ]; then - echo "Version mismatch for $package_name: $target_version (target) vs $current_version (current)" - echo "ERROR: Only toolkit release managers can alter an existing toolkit version." - exit 1 - else - echo "Versions match for $package_name: $target_version (target) vs $current_version (current)" - fi - done < target_versions.txt diff --git a/.github/workflows/test-toolkits.yml b/.github/workflows/test-toolkits.yml deleted file mode 100644 index 1867ddb0..00000000 --- a/.github/workflows/test-toolkits.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Test Toolkits - -on: - push: - branches: - - main - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -jobs: - setup: - runs-on: ubuntu-latest - outputs: - toolkits_with_gha_secrets: ${{ steps.load_toolkits.outputs.toolkits_with_gha_secrets }} - toolkits_without_gha_secrets: ${{ steps.load_toolkits.outputs.toolkits_without_gha_secrets }} - steps: - - name: Check out - uses: actions/checkout@v4 - - - name: determine toolkits with and without GHA secrets - id: load_toolkits - run: | - # Find all directories in toolkits/ that have a pyproject.toml - TOOLKITS=$(find toolkits -maxdepth 1 -type d -not -name "toolkits" -exec test -f {}/pyproject.toml \; -exec basename {} \; | jq -R -s -c 'split("\n")[:-1]') - TOOLKITS_WITH_GHA_SECRETS='["postgres"]' - TOOLKITS_WITHOUT_GHA_SECRETS=$(echo "$TOOLKITS" | jq -c --argjson with "$TOOLKITS_WITH_GHA_SECRETS" '[.[] | select(. as $t | $with | index($t) | not)]') - echo "Found toolkits: $TOOLKITS" - echo "Found toolkits without GHA secrets: $TOOLKITS_WITHOUT_GHA_SECRETS" - echo "Found toolkits with GHA secrets: $TOOLKITS_WITH_GHA_SECRETS" - echo "toolkits_without_gha_secrets=$TOOLKITS_WITHOUT_GHA_SECRETS" >> $GITHUB_OUTPUT - echo "toolkits_with_gha_secrets=$TOOLKITS_WITH_GHA_SECRETS" >> $GITHUB_OUTPUT - - test-toolkits: - needs: setup - runs-on: ubuntu-latest - strategy: - matrix: - toolkit: ${{ fromJson(needs.setup.outputs.toolkits_without_gha_secrets) }} - fail-fast: true - steps: - - name: Check out - uses: actions/checkout@v4 - - - name: Set up the environment - uses: ./.github/actions/setup-uv-env - - - name: Install toolkit dependencies - working-directory: toolkits/${{ matrix.toolkit }} - run: uv pip install -e ".[dev]" - - - name: Check toolkit - working-directory: toolkits/${{ matrix.toolkit }} - run: | - uv run --active pre-commit run -a - uv run --active mypy --config-file=pyproject.toml - - - name: Test stand-alone toolkits (no secrets) - working-directory: toolkits/${{ matrix.toolkit }} - run: | - # Run pytest and capture exit code - uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$? - - if [ "${EXIT_CODE:-0}" -eq 5 ]; then - echo "No tests found for toolkit ${{ matrix.toolkit }}, skipping..." - exit 0 - elif [ "${EXIT_CODE:-0}" -ne 0 ]; then - exit ${EXIT_CODE} - fi - - test-toolkits-with-gha-secrets: - needs: setup - runs-on: ubuntu-latest - strategy: - matrix: - toolkit: ${{ fromJson(needs.setup.outputs.toolkits_with_gha_secrets) }} - fail-fast: true - steps: - - name: Check out - uses: actions/checkout@v4 - - - name: Set up the environment - uses: ./.github/actions/setup-uv-env - - - name: Install toolkit dependencies - working-directory: toolkits/${{ matrix.toolkit }} - run: uv pip install -e ".[dev]" - - - name: Check toolkit - working-directory: toolkits/${{ matrix.toolkit }} - run: | - uv run --active pre-commit run -a - uv run --active mypy --config-file=pyproject.toml - - - name: Test stand-alone toolkits (with secrets) - if: | - !github.event.pull_request.head.repo.fork - working-directory: toolkits/${{ matrix.toolkit }} - env: - TEST_POSTGRES_DATABASE_CONNECTION_STRING: ${{ secrets.TEST_POSTGRES_DATABASE_CONNECTION_STRING }} # TODO: dynamically only load the `TEST_${{ matrix.toolkit }}_DATABASE_CONNECTION_STRING secret` - run: | - # Run pytest and capture exit code - uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$? - - if [ "${EXIT_CODE:-0}" -eq 5 ]; then - echo "No tests found for toolkit ${{ matrix.toolkit }}, skipping..." - exit 0 - elif [ "${EXIT_CODE:-0}" -ne 0 ]; then - exit ${EXIT_CODE} - fi diff --git a/toolkits/TOOLKIT_RELEASE_MANAGERS.txt b/toolkits/TOOLKIT_RELEASE_MANAGERS.txt deleted file mode 100644 index 78c231a2..00000000 --- a/toolkits/TOOLKIT_RELEASE_MANAGERS.txt +++ /dev/null @@ -1,8 +0,0 @@ -Spartee -nbarbettini -EricGustin -sdreyer -wdawson -byrro -torresmateo -evantahler diff --git a/toolkits/asana/.pre-commit-config.yaml b/toolkits/asana/.pre-commit-config.yaml deleted file mode 100644 index 1ef6a856..00000000 --- a/toolkits/asana/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/asana/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/asana/.ruff.toml b/toolkits/asana/.ruff.toml deleted file mode 100644 index bacd9161..00000000 --- a/toolkits/asana/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py39" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/asana/LICENSE b/toolkits/asana/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/asana/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/asana/Makefile b/toolkits/asana/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/asana/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/asana/arcade_asana/__init__.py b/toolkits/asana/arcade_asana/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/asana/arcade_asana/constants.py b/toolkits/asana/arcade_asana/constants.py deleted file mode 100644 index 16b4e1dd..00000000 --- a/toolkits/asana/arcade_asana/constants.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -from enum import Enum - -ASANA_BASE_URL = "https://app.asana.com/api" -ASANA_API_VERSION = "1.0" - -try: - ASANA_MAX_CONCURRENT_REQUESTS = int(os.getenv("ASANA_MAX_CONCURRENT_REQUESTS", 3)) -except ValueError: - ASANA_MAX_CONCURRENT_REQUESTS = 3 - -try: - ASANA_MAX_TIMEOUT_SECONDS = int(os.getenv("ASANA_MAX_TIMEOUT_SECONDS", 20)) -except ValueError: - ASANA_MAX_TIMEOUT_SECONDS = 20 - -MAX_PROJECTS_TO_SCAN_BY_NAME = 1000 -MAX_TAGS_TO_SCAN_BY_NAME = 1000 - -PROJECT_OPT_FIELDS = [ - "gid", - "resource_type", - "name", - "workspace", - "color", - "created_at", - "current_status_update", - "due_on", - "members", - "notes", - "completed", - "completed_at", - "completed_by", - "owner", - "team", - "workspace", - "permalink_url", -] - -TASK_OPT_FIELDS = [ - "gid", - "name", - "notes", - "completed", - "completed_at", - "completed_by", - "created_at", - "created_by", - "due_on", - "start_on", - "owner", - "team", - "workspace", - "permalink_url", - "approval_status", - "assignee", - "assignee_status", - "dependencies", - "dependents", - "memberships", - "num_subtasks", - "resource_type", - "custom_type", - "custom_type_status_option", - "parent", - "tags", - "workspace", -] - - -TAG_OPT_FIELDS = [ - "gid", - "name", - "workspace", -] - -TEAM_OPT_FIELDS = [ - "gid", - "name", - "description", - "organization", - "permalink_url", -] - -USER_OPT_FIELDS = [ - "gid", - "resource_type", - "name", - "email", - "photo", - "workspaces", -] - -WORKSPACE_OPT_FIELDS = [ - "gid", - "resource_type", - "name", - "email_domains", - "is_organization", -] - - -class TaskSortBy(Enum): - DUE_DATE = "due_date" - CREATED_AT = "created_at" - COMPLETED_AT = "completed_at" - MODIFIED_AT = "modified_at" - LIKES = "likes" - - -class SortOrder(Enum): - ASCENDING = "ascending" - DESCENDING = "descending" - - -class TagColor(Enum): - DARK_GREEN = "dark-green" - DARK_RED = "dark-red" - DARK_BLUE = "dark-blue" - DARK_PURPLE = "dark-purple" - DARK_PINK = "dark-pink" - DARK_ORANGE = "dark-orange" - DARK_TEAL = "dark-teal" - DARK_BROWN = "dark-brown" - DARK_WARM_GRAY = "dark-warm-gray" - LIGHT_GREEN = "light-green" - LIGHT_RED = "light-red" - LIGHT_BLUE = "light-blue" - LIGHT_PURPLE = "light-purple" - LIGHT_PINK = "light-pink" - LIGHT_ORANGE = "light-orange" - LIGHT_TEAL = "light-teal" - LIGHT_BROWN = "light-brown" - LIGHT_WARM_GRAY = "light-warm-gray" - - -class ReturnType(Enum): - FULL_ITEMS_DATA = "full_items_data" - ITEMS_COUNT = "items_count" diff --git a/toolkits/asana/arcade_asana/decorators.py b/toolkits/asana/arcade_asana/decorators.py deleted file mode 100644 index 64f7fa7d..00000000 --- a/toolkits/asana/arcade_asana/decorators.py +++ /dev/null @@ -1,26 +0,0 @@ -from functools import wraps -from typing import Any, Callable - - -def clean_asana_response(func: Callable[..., Any]) -> Callable[..., Any]: - def response_cleaner(data: dict[str, Any]) -> dict[str, Any]: - if "gid" in data: - data["id"] = data["gid"] - del data["gid"] - - for k, v in data.items(): - if isinstance(v, dict): - data[k] = response_cleaner(v) - elif isinstance(v, list): - data[k] = [ - item if not isinstance(item, dict) else response_cleaner(item) for item in v - ] - - return data - - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - response = await func(*args, **kwargs) - return response_cleaner(response) - - return wrapper diff --git a/toolkits/asana/arcade_asana/exceptions.py b/toolkits/asana/arcade_asana/exceptions.py deleted file mode 100644 index 0a76be8a..00000000 --- a/toolkits/asana/arcade_asana/exceptions.py +++ /dev/null @@ -1,14 +0,0 @@ -from arcade_tdk.errors import ToolExecutionError - - -class AsanaToolExecutionError(ToolExecutionError): - pass - - -class PaginationTimeoutError(AsanaToolExecutionError): - def __init__(self, timeout_seconds: int, tool_name: str): - message = f"Pagination timed out after {timeout_seconds} seconds" - super().__init__( - message=message, - developer_message=f"{message} while calling the tool {tool_name}", - ) diff --git a/toolkits/asana/arcade_asana/models.py b/toolkits/asana/arcade_asana/models.py deleted file mode 100644 index 9cbfa8e0..00000000 --- a/toolkits/asana/arcade_asana/models.py +++ /dev/null @@ -1,165 +0,0 @@ -import asyncio -import json -from dataclasses import dataclass -from typing import Optional, cast - -import httpx - -from arcade_asana.constants import ASANA_API_VERSION, ASANA_BASE_URL, ASANA_MAX_CONCURRENT_REQUESTS -from arcade_asana.decorators import clean_asana_response -from arcade_asana.exceptions import AsanaToolExecutionError - - -@dataclass -class AsanaClient: - auth_token: str - base_url: str = ASANA_BASE_URL - api_version: str = ASANA_API_VERSION - max_concurrent_requests: int = ASANA_MAX_CONCURRENT_REQUESTS - _semaphore: asyncio.Semaphore | None = None - - def __post_init__(self) -> None: - self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests) - - def _build_url(self, endpoint: str, api_version: str | None = None) -> str: - api_version = api_version or self.api_version - return f"{self.base_url.rstrip('/')}/{api_version.strip('/')}/{endpoint.lstrip('/')}" - - def _build_error_messages(self, response: httpx.Response) -> tuple[str, str]: - try: - data = response.json() - errors = data["errors"] - - if len(errors) == 1: - error_message = errors[0]["message"] - developer_message = ( - f"{errors[0]['message']} | {errors[0]['help']} " - f"(HTTP status code: {response.status_code})" - ) - else: - errors_concat = "', '".join([error["message"] for error in errors]) - error_message = f"Multiple errors occurred: '{errors_concat}'" - developer_message = ( - f"Multiple errors occurred: {json.dumps(errors)} " - f"(HTTP status code: {response.status_code})" - ) - - except Exception as e: - error_message = "Failed to parse Asana error response" - developer_message = f"Failed to parse Asana error response: {type(e).__name__}: {e!s}" - - return error_message, developer_message - - def _raise_for_status(self, response: httpx.Response) -> None: - if response.status_code < 300: - return - - error_message, developer_message = self._build_error_messages(response) - - raise AsanaToolExecutionError(error_message, developer_message) - - def _set_request_body(self, kwargs: dict, data: dict | None, json_data: dict | None) -> dict: - if data and json_data: - raise ValueError("Cannot provide both data and json_data") - - if data: - kwargs["data"] = data - - elif json_data: - kwargs["json"] = json_data - - return kwargs - - @clean_asana_response - async def get( - self, - endpoint: str, - params: Optional[dict] = None, - headers: Optional[dict] = None, - api_version: str | None = None, - ) -> dict: - default_headers = { - "Authorization": f"Bearer {self.auth_token}", - "Accept": "application/json", - } - headers = {**default_headers, **(headers or {})} - - kwargs = { - "url": self._build_url(endpoint, api_version), - "headers": headers, - } - - if params: - kwargs["params"] = params - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.get(**kwargs) # type: ignore[arg-type] - self._raise_for_status(response) - return cast(dict, response.json()) - - @clean_asana_response - async def post( - self, - endpoint: str, - data: Optional[dict] = None, - json_data: Optional[dict] = None, - files: Optional[dict] = None, - headers: Optional[dict] = None, - api_version: str | None = None, - ) -> dict: - default_headers = { - "Authorization": f"Bearer {self.auth_token}", - "Accept": "application/json", - } - - if files is None and json_data is not None: - default_headers["Content-Type"] = "application/json" - - headers = {**default_headers, **(headers or {})} - - kwargs = { - "url": self._build_url(endpoint, api_version), - "headers": headers, - } - - if files is not None: - kwargs["files"] = files - if data is not None: - kwargs["data"] = data - else: - kwargs = self._set_request_body(kwargs, data, json_data) - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.post(**kwargs) # type: ignore[arg-type] - self._raise_for_status(response) - return cast(dict, response.json()) - - @clean_asana_response - async def put( - self, - endpoint: str, - data: Optional[dict] = None, - json_data: Optional[dict] = None, - headers: Optional[dict] = None, - api_version: str | None = None, - ) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - headers["Content-Type"] = "application/json" - headers["Accept"] = "application/json" - - kwargs = { - "url": self._build_url(endpoint, api_version), - "headers": headers, - } - - kwargs = self._set_request_body(kwargs, data, json_data) - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.put(**kwargs) # type: ignore[arg-type] - self._raise_for_status(response) - return cast(dict, response.json()) - - async def get_current_user(self) -> dict: - response = await self.get("/users/me") - return cast(dict, response["data"]) diff --git a/toolkits/asana/arcade_asana/tools/__init__.py b/toolkits/asana/arcade_asana/tools/__init__.py deleted file mode 100644 index 3380b470..00000000 --- a/toolkits/asana/arcade_asana/tools/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from arcade_asana.tools.projects import get_project_by_id, list_projects -from arcade_asana.tools.tags import create_tag, list_tags -from arcade_asana.tools.tasks import ( - attach_file_to_task, - create_task, - get_subtasks_from_a_task, - get_task_by_id, - get_tasks_without_id, - update_task, -) -from arcade_asana.tools.teams import get_team_by_id, list_teams_the_current_user_is_a_member_of -from arcade_asana.tools.users import get_user_by_id, list_users -from arcade_asana.tools.workspaces import list_workspaces - -__all__ = [ - "attach_file_to_task", - "create_tag", - "create_task", - "get_project_by_id", - "get_subtasks_from_a_task", - "get_task_by_id", - "get_team_by_id", - "get_user_by_id", - "list_projects", - "list_tags", - "list_teams_the_current_user_is_a_member_of", - "list_users", - "list_workspaces", - "get_tasks_without_id", - "update_task", -] diff --git a/toolkits/asana/arcade_asana/tools/projects.py b/toolkits/asana/arcade_asana/tools/projects.py deleted file mode 100644 index f8e36e66..00000000 --- a/toolkits/asana/arcade_asana/tools/projects.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Asana - -from arcade_asana.constants import PROJECT_OPT_FIELDS -from arcade_asana.models import AsanaClient -from arcade_asana.utils import ( - get_next_page, - get_unique_workspace_id_or_raise_error, - remove_none_values, -) - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_project_by_id( - context: ToolContext, - project_id: Annotated[str, "The ID of the project."], -) -> Annotated[ - dict[str, Any], - "Get a project by its ID", -]: - """Get an Asana project by its ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/projects/{project_id}", - params={"opt_fields": PROJECT_OPT_FIELDS}, - ) - return {"project": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def list_projects( - context: ToolContext, - team_id: Annotated[ - str | None, - "The team ID to get projects from. Defaults to None (does not filter by team).", - ] = None, - workspace_id: Annotated[ - str | None, - "The workspace ID to get projects from. Defaults to None. If not provided and the user " - "has only one workspace, it will use that workspace. If not provided and the user has " - "multiple workspaces, it will raise an error listing the available workspaces.", - ] = None, - limit: Annotated[ - int, "The maximum number of projects to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of projects. Defaults to None (start from the first " - "page of projects).", - ] = None, -) -> Annotated[ - dict[str, Any], - "List projects in Asana associated to teams the current user is a member of", -]: - """List projects in Asana""" - # Note: Asana recommends filtering by team to avoid timeout in large domains. - # Ref: https://developers.asana.com/reference/getprojects - limit = max(1, min(100, limit)) - - workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - - client = AsanaClient(context.get_auth_token_or_empty()) - - response = await client.get( - "/projects", - params=remove_none_values({ - "limit": limit, - "offset": next_page_token, - "team": team_id, - "workspace": workspace_id, - "opt_fields": PROJECT_OPT_FIELDS, - }), - ) - - return { - "projects": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } diff --git a/toolkits/asana/arcade_asana/tools/tags.py b/toolkits/asana/arcade_asana/tools/tags.py deleted file mode 100644 index b414e475..00000000 --- a/toolkits/asana/arcade_asana/tools/tags.py +++ /dev/null @@ -1,102 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Asana -from arcade_tdk.errors import ToolExecutionError - -from arcade_asana.constants import TAG_OPT_FIELDS, TagColor -from arcade_asana.models import AsanaClient -from arcade_asana.utils import ( - get_next_page, - get_unique_workspace_id_or_raise_error, - remove_none_values, -) - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_tag_by_id( - context: ToolContext, - tag_id: Annotated[str, "The ID of the Asana tag to get"], -) -> Annotated[dict[str, Any], "Get an Asana tag by its ID"]: - """Get an Asana tag by its ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get(f"/tags/{tag_id}") - return {"tag": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def create_tag( - context: ToolContext, - name: Annotated[str, "The name of the tag to create. Length must be between 1 and 100."], - description: Annotated[ - str | None, "The description of the tag to create. Defaults to None (no description)." - ] = None, - color: Annotated[ - TagColor | None, "The color of the tag to create. Defaults to None (no color)." - ] = None, - workspace_id: Annotated[ - str | None, - "The ID of the workspace to create the tag in. If not provided, it will associated the tag " - "to a current workspace, if there's only one. Otherwise, it will raise an error.", - ] = None, -) -> Annotated[dict[str, Any], "The created tag."]: - """Create a tag in Asana""" - if not 1 <= len(name) <= 100: - raise ToolExecutionError("Tag name must be between 1 and 100 characters long.") - - workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - - data = remove_none_values({ - "name": name, - "notes": description, - "color": color.value if color else None, - "workspace": workspace_id, - }) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.post("/tags", json_data={"data": data}) - return {"tag": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def list_tags( - context: ToolContext, - workspace_id: Annotated[ - str | None, - "The workspace ID to retrieve tags from. Defaults to None. If not provided and the user " - "has only one workspace, it will use that workspace. If not provided and the user has " - "multiple workspaces, it will raise an error listing the available workspaces.", - ] = None, - limit: Annotated[ - int, "The maximum number of tags to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of tags. Defaults to None (start from the first page " - "of tags)", - ] = None, -) -> Annotated[ - dict[str, Any], - "List tags in an Asana workspace", -]: - """List tags in an Asana workspace""" - limit = max(1, min(100, limit)) - - workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - "/tags", - params=remove_none_values({ - "limit": limit, - "offset": next_page_token, - "workspace": workspace_id, - "opt_fields": TAG_OPT_FIELDS, - }), - ) - - return { - "tags": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } diff --git a/toolkits/asana/arcade_asana/tools/tasks.py b/toolkits/asana/arcade_asana/tools/tasks.py deleted file mode 100644 index 876c4986..00000000 --- a/toolkits/asana/arcade_asana/tools/tasks.py +++ /dev/null @@ -1,455 +0,0 @@ -import base64 -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Asana -from arcade_tdk.errors import ToolExecutionError - -from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy -from arcade_asana.models import AsanaClient -from arcade_asana.utils import ( - build_task_search_query_params, - get_next_page, - get_project_by_name_or_raise_error, - get_tag_ids, - get_unique_workspace_id_or_raise_error, - handle_new_task_associations, - handle_new_task_tags, - remove_none_values, - validate_date_format, -) - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_tasks_without_id( - context: ToolContext, - keywords: Annotated[ - str | None, "Keywords to search for tasks. Matches against the task name and description." - ] = None, - workspace_id: Annotated[ - str | None, - "The workspace ID to search for tasks. Defaults to None. If not provided and the user " - "has only one workspace, it will use that workspace. If not provided and the user has " - "multiple workspaces, it will raise an error listing the available workspaces.", - ] = None, - assignee_id: Annotated[ - str | None, - "The ID of the user to filter tasks assigned to. " - "Defaults to None (does not filter by assignee).", - ] = None, - project: Annotated[ - str | None, - "The ID or name of the project to filter tasks. " - "Defaults to None (searches tasks associated to any project or no project).", - ] = None, - team_id: Annotated[ - str | None, - "Restricts the search to tasks associated to the given team ID. " - "Defaults to None (searches tasks associated to any team).", - ] = None, - tags: Annotated[ - list[str] | None, - "Restricts the search to tasks associated to the given tags. " - "Each item in the list can be a tag name (e.g. 'My Tag') or a tag ID (e.g. '1234567890'). " - "Defaults to None (searches tasks associated to any tag or no tag).", - ] = None, - due_on: Annotated[ - str | None, - "Match tasks that are due exactly on this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (searches tasks due on any date or without a due date).", - ] = None, - due_on_or_after: Annotated[ - str | None, - "Match tasks that are due on OR AFTER this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " - "Defaults to None (searches tasks due on any date or without a due date).", - ] = None, - due_on_or_before: Annotated[ - str | None, - "Match tasks that are due on OR BEFORE this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " - "Defaults to None (searches tasks due on any date or without a due date).", - ] = None, - start_on: Annotated[ - str | None, - "Match tasks that started on this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (searches tasks started on any date or without a start date).", - ] = None, - start_on_or_after: Annotated[ - str | None, - "Match tasks that started on OR AFTER this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " - "Defaults to None (searches tasks started on any date or without a start date).", - ] = None, - start_on_or_before: Annotated[ - str | None, - "Match tasks that started on OR BEFORE this date. Format: YYYY-MM-DD. Ex: '2025-01-01' " - "Defaults to None (searches tasks started on any date or without a start date).", - ] = None, - completed: Annotated[ - bool | None, - "Match tasks that are completed. Defaults to None (does not filter by completion status).", - ] = None, - limit: Annotated[ - int, - "The maximum number of tasks to return. Min of 1, max of 100. Defaults to 100.", - ] = 100, - sort_by: Annotated[ - TaskSortBy, - "The field to sort the tasks by. Defaults to TaskSortBy.MODIFIED_AT.", - ] = TaskSortBy.MODIFIED_AT, - sort_order: Annotated[ - SortOrder, - "The order to sort the tasks by. Defaults to SortOrder.DESCENDING.", - ] = SortOrder.DESCENDING, -) -> Annotated[dict[str, Any], "The tasks that match the query."]: - """Search for tasks""" - limit = max(1, min(100, limit)) - - project_id = None - - if project: - if project.isnumeric(): - project_id = project - else: - project_data = await get_project_by_name_or_raise_error(context, project) - project_id = project_data["id"] - if not workspace_id: - workspace_id = project_data["workspace"]["id"] - - tag_ids = await get_tag_ids(context, tags) - - client = AsanaClient(context.get_auth_token_or_empty()) - - validate_date_format("due_on", due_on) - validate_date_format("due_on_or_after", due_on_or_after) - validate_date_format("due_on_or_before", due_on_or_before) - validate_date_format("start_on", start_on) - validate_date_format("start_on_or_after", start_on_or_after) - validate_date_format("start_on_or_before", start_on_or_before) - - if not any([workspace_id, project_id, team_id]): - workspace_id = await get_unique_workspace_id_or_raise_error(context) - - if not workspace_id and team_id: - from arcade_asana.tools.teams import get_team_by_id - - team = await get_team_by_id(context, team_id) - workspace_id = team["organization"]["id"] - - response = await client.get( - f"/workspaces/{workspace_id}/tasks/search", - params=build_task_search_query_params( - keywords=keywords, - completed=completed, - assignee_id=assignee_id, - project_id=project_id, - team_id=team_id, - tag_ids=tag_ids, - due_on=due_on, - due_on_or_after=due_on_or_after, - due_on_or_before=due_on_or_before, - start_on=start_on, - start_on_or_after=start_on_or_after, - start_on_or_before=start_on_or_before, - limit=limit, - sort_by=sort_by, - sort_order=sort_order, - ), - ) - - tasks_by_id = {task["id"]: task for task in response["data"]} - - tasks = list(tasks_by_id.values()) - - return {"tasks": tasks, "count": len(tasks)} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_task_by_id( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to get."], - max_subtasks: Annotated[ - int, - "The maximum number of subtasks to return. " - "Min of 0 (no subtasks), max of 100. Defaults to 100.", - ] = 100, -) -> Annotated[dict[str, Any], "The task with the given ID."]: - """Get a task by its ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/tasks/{task_id}", - params={"opt_fields": TASK_OPT_FIELDS}, - ) - if max_subtasks > 0: - max_subtasks = min(max_subtasks, 100) - subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks) - response["data"]["subtasks"] = subtasks["subtasks"] - return {"task": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_subtasks_from_a_task( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to get the subtasks of."], - limit: Annotated[ - int, - "The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.", - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of subtasks. Defaults to None (start from the first " - "page of subtasks)", - ] = None, -) -> Annotated[dict[str, Any], "The subtasks of the task."]: - """Get the subtasks of a task""" - limit = max(1, min(100, limit)) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/tasks/{task_id}/subtasks", - params=remove_none_values({ - "opt_fields": TASK_OPT_FIELDS, - "limit": limit, - "offset": next_page_token, - }), - ) - - return { - "subtasks": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } - - -@tool(requires_auth=Asana(scopes=["default"])) -async def update_task( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to update."], - name: Annotated[ - str | None, - "The new name of the task. Defaults to None (does not change the current name).", - ] = None, - completed: Annotated[ - bool | None, - "The new completion status of the task. " - "Provide True to mark the task as completed, False to mark it as not completed. " - "Defaults to None (does not change the current completion status).", - ] = None, - start_date: Annotated[ - str | None, - "The new start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " - "Defaults to None (does not change the current start date).", - ] = None, - due_date: Annotated[ - str | None, - "The new due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " - "Defaults to None (does not change the current due date).", - ] = None, - description: Annotated[ - str | None, - "The new description of the task. " - "Defaults to None (does not change the current description).", - ] = None, - assignee_id: Annotated[ - str | None, - "The ID of the new user to assign the task to. " - "Provide 'me' to assign the task to the current user. " - "Defaults to None (does not change the current assignee).", - ] = None, -) -> Annotated[ - dict[str, Any], - "Updates a task in Asana", -]: - """Updates a task in Asana""" - client = AsanaClient(context.get_auth_token_or_empty()) - - validate_date_format("start_date", start_date) - validate_date_format("due_date", due_date) - - task_data = { - "data": remove_none_values({ - "name": name, - "completed": completed, - "due_on": due_date, - "start_on": start_date, - "notes": description, - "assignee": assignee_id, - }), - } - - response = await client.put(f"/tasks/{task_id}", json_data=task_data) - - return { - "status": {"success": True, "message": "task updated successfully"}, - "task": response["data"], - } - - -@tool(requires_auth=Asana(scopes=["default"])) -async def mark_task_as_completed( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to mark as completed."], -) -> Annotated[dict[str, Any], "The task marked as completed."]: - """Mark a task in Asana as completed""" - return await update_task(context, task_id, completed=True) # type: ignore[no-any-return] - - -@tool(requires_auth=Asana(scopes=["default"])) -async def create_task( - context: ToolContext, - name: Annotated[str, "The name of the task"], - start_date: Annotated[ - str | None, - "The start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " - "Defaults to None.", - ] = None, - due_date: Annotated[ - str | None, - "The due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. " - "Defaults to None.", - ] = None, - description: Annotated[str | None, "The description of the task. Defaults to None."] = None, - parent_task_id: Annotated[str | None, "The ID of the parent task. Defaults to None."] = None, - workspace_id: Annotated[ - str | None, "The ID of the workspace to associate the task to. Defaults to None." - ] = None, - project: Annotated[ - str | None, "The ID or name of the project to associate the task to. Defaults to None." - ] = None, - assignee_id: Annotated[ - str | None, - "The ID of the user to assign the task to. " - "Defaults to 'me', which assigns the task to the current user.", - ] = "me", - tags: Annotated[ - list[str] | None, - "The tags to associate with the task. Multiple tags can be provided in the list. " - "Each item in the list can be a tag name (e.g. 'My Tag') or a tag ID (e.g. '1234567890'). " - "If a tag name does not exist, it will be automatically created with the new task. " - "Defaults to None (no tags associated).", - ] = None, -) -> Annotated[ - dict[str, Any], - "Creates a task in Asana", -]: - """Creates a task in Asana - - The task must be associated to at least one of the following: parent_task_id, project, or - workspace_id. If none of these are provided and the account has only one workspace, the task - will be associated to that workspace. If the account has multiple workspaces, an error will - be raised with a list of available workspaces. - """ - client = AsanaClient(context.get_auth_token_or_empty()) - - parent_task_id, project_id, workspace_id = await handle_new_task_associations( - context, parent_task_id, project, workspace_id - ) - - tag_ids = await handle_new_task_tags(context, tags, workspace_id) - - validate_date_format("start_date", start_date) - validate_date_format("due_date", due_date) - - task_data = { - "data": remove_none_values({ - "name": name, - "due_on": due_date, - "start_on": start_date, - "notes": description, - "parent": parent_task_id, - "projects": [project_id] if project_id else None, - "workspace": workspace_id, - "assignee": assignee_id, - "tags": tag_ids, - }), - } - - response = await client.post("tasks", json_data=task_data) - - return { - "status": {"success": True, "message": "task successfully created"}, - "task": response["data"], - } - - -@tool(requires_auth=Asana(scopes=["default"])) -async def attach_file_to_task( - context: ToolContext, - task_id: Annotated[str, "The ID of the task to attach the file to."], - file_name: Annotated[ - str, - "The name of the file to attach with format extension. E.g. 'Image.png' or 'Report.pdf'.", - ], - file_content_str: Annotated[ - str | None, - "The string contents of the file to attach. Use this if the file is a text file. " - "Defaults to None.", - ] = None, - file_content_base64: Annotated[ - str | None, - "The base64-encoded binary contents of the file. " - "Use this for binary files like images or PDFs. Defaults to None.", - ] = None, - file_content_url: Annotated[ - str | None, - "The URL of the file to attach. Use this if the file is hosted on an external URL. " - "Defaults to None.", - ] = None, - file_encoding: Annotated[ - str, - "The encoding of the file to attach. Only used with file_content_str. Defaults to 'utf-8'.", - ] = "utf-8", -) -> Annotated[dict[str, Any], "The task with the file attached."]: - """Attaches a file to an Asana task - - Provide exactly one of file_content_str, file_content_base64, or file_content_url, never more - than one. - - - Use file_content_str for text files (will be encoded using file_encoding) - - Use file_content_base64 for binary files like images, PDFs, etc. - - Use file_content_url if the file is hosted on an external URL - """ - client = AsanaClient(context.get_auth_token_or_empty()) - - if sum([bool(file_content_str), bool(file_content_base64), bool(file_content_url)]) != 1: - raise ToolExecutionError( - "Provide exactly one of file_content_str, file_content_base64, or file_content_url" - ) - - data = { - "parent": task_id, - "name": file_name, - "resource_subtype": "asana", - } - - if file_content_url is not None: - data["url"] = file_content_url - data["resource_subtype"] = "external" - file_content = None - elif file_content_str is not None: - try: - file_content = file_content_str.encode(file_encoding) - except LookupError as exc: - raise ToolExecutionError(f"Unknown encoding: {file_encoding}") from exc - except Exception as exc: - raise ToolExecutionError( - f"Failed to encode file content string with {file_encoding} encoding: {exc!s}" - ) from exc - elif file_content_base64 is not None: - try: - file_content = base64.b64decode(file_content_base64) - except Exception as exc: - raise ToolExecutionError(f"Failed to decode base64 file content: {exc!s}") from exc - - if file_content: - if file_name.lower().endswith(".pdf"): - files = {"file": (file_name, file_content, "application/pdf")} - else: - files = {"file": (file_name, file_content)} # type: ignore[dict-item] - else: - files = None - - response = await client.post("/attachments", data=data, files=files) - - return { - "status": {"success": True, "message": f"file successfully attached to task {task_id}"}, - "response": response["data"], - } diff --git a/toolkits/asana/arcade_asana/tools/teams.py b/toolkits/asana/arcade_asana/tools/teams.py deleted file mode 100644 index 25bf87f2..00000000 --- a/toolkits/asana/arcade_asana/tools/teams.py +++ /dev/null @@ -1,110 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Asana - -from arcade_asana.constants import TEAM_OPT_FIELDS -from arcade_asana.models import AsanaClient -from arcade_asana.utils import ( - get_next_page, - get_unique_workspace_id_or_raise_error, - remove_none_values, -) - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_team_by_id( - context: ToolContext, - team_id: Annotated[str, "The ID of the Asana team to get"], -) -> Annotated[dict[str, Any], "Get an Asana team by its ID"]: - """Get an Asana team by its ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/teams/{team_id}", - params=remove_none_values({"opt_fields": TEAM_OPT_FIELDS}), - ) - return {"team": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def list_teams_the_current_user_is_a_member_of( - context: ToolContext, - workspace_id: Annotated[ - str | None, - "The workspace ID to list teams from. Defaults to None. If no workspace ID is provided, " - "it will use the current user's workspace , if there's only one. If the user has multiple " - "workspaces, it will raise an error.", - ] = None, - limit: Annotated[ - int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of teams. Defaults to None (start from the first page " - "of teams)", - ] = None, -) -> Annotated[ - dict[str, Any], - "List teams in Asana that the current user is a member of", -]: - """List teams in Asana that the current user is a member of""" - limit = max(1, min(100, limit)) - - workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - "/users/me/teams", - params=remove_none_values({ - "limit": limit, - "offset": next_page_token, - "opt_fields": TEAM_OPT_FIELDS, - "organization": workspace_id, - }), - ) - - return { - "teams": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } - - -@tool(requires_auth=Asana(scopes=["default"])) -async def list_teams( - context: ToolContext, - workspace_id: Annotated[ - str | None, - "The workspace ID to list teams from. Defaults to None. If no workspace ID is provided, " - "it will use the current user's workspace, if there's only one. If the user has multiple " - "workspaces, it will raise an error listing the available workspaces.", - ] = None, - limit: Annotated[ - int, "The maximum number of teams to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of teams. Defaults to None (start from the first page " - "of teams)", - ] = None, -) -> Annotated[dict[str, Any], "List teams in an Asana workspace"]: - """List teams in an Asana workspace""" - limit = max(1, min(100, limit)) - - workspace_id = workspace_id or await get_unique_workspace_id_or_raise_error(context) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - f"/workspaces/{workspace_id}/teams", - params=remove_none_values({ - "limit": limit, - "offset": next_page_token, - "opt_fields": TEAM_OPT_FIELDS, - }), - ) - - return { - "teams": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } diff --git a/toolkits/asana/arcade_asana/tools/users.py b/toolkits/asana/arcade_asana/tools/users.py deleted file mode 100644 index d65bedc4..00000000 --- a/toolkits/asana/arcade_asana/tools/users.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Asana - -from arcade_asana.constants import USER_OPT_FIELDS -from arcade_asana.models import AsanaClient -from arcade_asana.utils import ( - get_next_page, - get_unique_workspace_id_or_raise_error, - remove_none_values, -) - - -@tool(requires_auth=Asana(scopes=["default"])) -async def list_users( - context: ToolContext, - workspace_id: Annotated[ - str | None, - "The workspace ID to list users from. Defaults to None. If no workspace ID is provided, " - "it will use the current user's workspace , if there's only one. If the user has multiple " - "workspaces, it will raise an error.", - ] = None, - limit: Annotated[ - int, - "The maximum number of users to retrieve. Min is 1, max is 100. Defaults to 100.", - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of users. Defaults to None (start from the first page " - "of users)", - ] = None, -) -> Annotated[ - dict[str, Any], - "List users in Asana", -]: - """List users in Asana""" - limit = max(1, min(100, limit)) - - if not workspace_id: - workspace_id = await get_unique_workspace_id_or_raise_error(context) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - "/users", - params=remove_none_values({ - "workspace": workspace_id, - "limit": limit, - "offset": next_page_token, - "opt_fields": USER_OPT_FIELDS, - }), - ) - - return { - "users": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_user_by_id( - context: ToolContext, - user_id: Annotated[str, "The user ID to get."], -) -> Annotated[dict[str, Any], "The user information."]: - """Get a user by ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get(f"/users/{user_id}", params={"opt_fields": USER_OPT_FIELDS}) - return {"user": response} diff --git a/toolkits/asana/arcade_asana/tools/workspaces.py b/toolkits/asana/arcade_asana/tools/workspaces.py deleted file mode 100644 index 72c02478..00000000 --- a/toolkits/asana/arcade_asana/tools/workspaces.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Asana - -from arcade_asana.constants import WORKSPACE_OPT_FIELDS -from arcade_asana.models import AsanaClient -from arcade_asana.utils import get_next_page, remove_none_values - - -@tool(requires_auth=Asana(scopes=["default"])) -async def get_workspace_by_id( - context: ToolContext, - workspace_id: Annotated[str, "The ID of the Asana workspace to get"], -) -> Annotated[dict[str, Any], "Get an Asana workspace by its ID"]: - """Get an Asana workspace by its ID""" - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get(f"/workspaces/{workspace_id}") - return {"workspace": response["data"]} - - -@tool(requires_auth=Asana(scopes=["default"])) -async def list_workspaces( - context: ToolContext, - limit: Annotated[ - int, "The maximum number of workspaces to return. Min is 1, max is 100. Defaults to 100." - ] = 100, - next_page_token: Annotated[ - str | None, - "The token to retrieve the next page of workspaces. Defaults to None (start from the first " - "page of workspaces)", - ] = None, -) -> Annotated[ - dict[str, Any], - "List workspaces in Asana that are visible to the authenticated user", -]: - """List workspaces in Asana that are visible to the authenticated user""" - limit = max(1, min(100, limit)) - - client = AsanaClient(context.get_auth_token_or_empty()) - response = await client.get( - "/workspaces", - params=remove_none_values({ - "limit": limit, - "offset": next_page_token, - "opt_fields": WORKSPACE_OPT_FIELDS, - }), - ) - - return { - "workspaces": response["data"], - "count": len(response["data"]), - "next_page": get_next_page(response), - } diff --git a/toolkits/asana/arcade_asana/utils.py b/toolkits/asana/arcade_asana/utils.py deleted file mode 100644 index 4d1733a1..00000000 --- a/toolkits/asana/arcade_asana/utils.py +++ /dev/null @@ -1,483 +0,0 @@ -import asyncio -import json -from collections.abc import Awaitable -from datetime import datetime -from typing import Any, Callable, TypeVar, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_asana.constants import ( - ASANA_MAX_TIMEOUT_SECONDS, - MAX_PROJECTS_TO_SCAN_BY_NAME, - MAX_TAGS_TO_SCAN_BY_NAME, - TASK_OPT_FIELDS, - SortOrder, - TaskSortBy, -) -from arcade_asana.exceptions import PaginationTimeoutError - -ToolResponse = TypeVar("ToolResponse", bound=dict[str, Any]) - - -def remove_none_values(data: dict[str, Any]) -> dict[str, Any]: - return {k: v for k, v in data.items() if v is not None} - - -def validate_date_format(name: str, date_str: str | None) -> None: - if not date_str: - return - - try: - datetime.strptime(date_str, "%Y-%m-%d") - except ValueError: - raise ToolExecutionError(f"Invalid {name} date format. Use the format YYYY-MM-DD.") - - -def build_task_search_query_params( - keywords: str | None, - completed: bool | None, - assignee_id: str | None, - project_id: str | None, - team_id: str | None, - tag_ids: list[str] | None, - due_on: str | None, - due_on_or_after: str | None, - due_on_or_before: str | None, - start_on: str | None, - start_on_or_after: str | None, - start_on_or_before: str | None, - limit: int, - sort_by: TaskSortBy, - sort_order: SortOrder, -) -> dict[str, Any]: - query_params: dict[str, Any] = { - "text": keywords, - "opt_fields": TASK_OPT_FIELDS, - "sort_by": sort_by.value, - "sort_ascending": sort_order == SortOrder.ASCENDING, - "limit": limit, - } - if completed is not None: - query_params["completed"] = completed - if assignee_id: - query_params["assignee.any"] = assignee_id - if project_id: - query_params["projects.any"] = project_id - if team_id: - query_params["team.any"] = team_id - if tag_ids: - query_params["tags.any"] = ",".join(tag_ids) - - query_params = add_task_search_date_params( - query_params, - due_on, - due_on_or_after, - due_on_or_before, - start_on, - start_on_or_after, - start_on_or_before, - ) - - return query_params - - -def add_task_search_date_params( - query_params: dict[str, Any], - due_on: str | None, - due_on_or_after: str | None, - due_on_or_before: str | None, - start_on: str | None, - start_on_or_after: str | None, - start_on_or_before: str | None, -) -> dict[str, Any]: - """ - Builds the date-related query parameters for task search. - - If a date is provided, it will be added to the query parameters. If not, it will be ignored. - """ - if due_on: - query_params["due_on"] = due_on - if due_on_or_after: - query_params["due_on.after"] = due_on_or_after - if due_on_or_before: - query_params["due_on.before"] = due_on_or_before - if start_on: - query_params["start_on"] = start_on - if start_on_or_after: - query_params["start_on.after"] = start_on_or_after - if start_on_or_before: - query_params["start_on.before"] = start_on_or_before - - return query_params - - -async def handle_new_task_associations( - context: ToolContext, - parent_task_id: str | None, - project: str | None, - workspace_id: str | None, -) -> tuple[str | None, str | None, str | None]: - """ - Handles the association of a new task to a parent task, project, or workspace. - - If no association is provided, it will try to find a workspace in the user's account. - In case the user has only one workspace, it will use that workspace. - Otherwise, it will raise an error. - - If a workspace_id is not provided, but a parent_task_id or a project_id is provided, it will try - to find the workspace associated with the parent task or project. - - In each of the two cases explained above, if a workspace is found, the function will return this - value, even if the workspace_id argument was None. - - Returns a tuple of (parent_task_id, project_id, workspace_id). - """ - project_id, project_name = (None, None) - - if project: - if project.isnumeric(): - project_id = project - else: - project_name = project - - if project_name: - project_data = await get_project_by_name_or_raise_error(context, project_name) - project_id = project_data["id"] - workspace_id = project_data["workspace"]["id"] - - if not any([parent_task_id, project_id, workspace_id]): - workspace_id = await get_unique_workspace_id_or_raise_error(context) - - if not workspace_id and parent_task_id: - from arcade_asana.tools.tasks import get_task_by_id # avoid circular imports - - response = await get_task_by_id(context, parent_task_id) - workspace_id = response["task"]["workspace"]["id"] - - return parent_task_id, project_id, workspace_id - - -async def get_project_by_name_or_raise_error( - context: ToolContext, - project_name: str, - max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME, -) -> dict[str, Any]: - response = await find_projects_by_name( - context=context, - names=[project_name], - response_limit=100, - max_items_to_scan=max_items_to_scan, - return_projects_not_matched=True, - ) - - if not response["matches"]["projects"]: - projects = response["not_matched"]["projects"] - projects = [{"name": project["name"], "id": project["id"]} for project in projects] - message = ( - f"Project with name '{project_name}' was not found. The search scans up to " - f"{max_items_to_scan} projects. If the user account has a larger number of projects, " - "it's possible that it exists, but the search didn't find it." - ) - additional_prompt = f"Projects available: {json.dumps(projects)}" - raise RetryableToolError( - message=message, - developer_message=f"{message} {additional_prompt}", - additional_prompt_content=additional_prompt, - ) - - elif response["matches"]["count"] > 1: - projects = [ - {"name": project["name"], "id": project["id"]} - for project in response["matches"]["projects"] - ] - message = "Multiple projects found with the same name. Please provide a project ID instead." - additional_prompt = f"Projects matching the name '{project_name}': {json.dumps(projects)}" - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=additional_prompt, - ) - - return cast(dict, response["matches"]["projects"][0]) - - -async def handle_new_task_tags( - context: ToolContext, - tags: list[str] | None, - workspace_id: str | None, -) -> list[str] | None: - if not tags: - return None - - tag_ids = [] - tag_names = [] - for tag in tags: - if tag.isnumeric(): - tag_ids.append(tag) - else: - tag_names.append(tag) - - if tag_names: - response = await find_tags_by_name(context, tag_names) - tag_ids.extend([tag["id"] for tag in response["matches"]["tags"]]) - - if response["not_found"]["tags"]: - from arcade_asana.tools.tags import create_tag # avoid circular imports - - responses = await asyncio.gather(*[ - create_tag(context, name=name, workspace_id=workspace_id) - for name in response["not_found"]["tags"] - ]) - tag_ids.extend([response["tag"]["id"] for response in responses]) - - return tag_ids - - -async def get_tag_ids( - context: ToolContext, - tags: list[str] | None, - max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME, -) -> list[str] | None: - """ - Returns the IDs of the tags provided in the tags list, which can be either tag IDs or tag names. - - If the tags list is empty, it returns None. - """ - tag_ids = [] - tag_names = [] - - if tags: - for tag in tags: - if tag.isnumeric(): - tag_ids.append(tag) - else: - tag_names.append(tag) - - if tag_names: - searched_tags = await find_tags_by_name( - context=context, - names=tag_names, - max_items_to_scan=max_items_to_scan, - ) - - if searched_tags["not_found"]["tags"]: - tag_names_not_found = ", ".join(searched_tags["not_found"]["tags"]) - raise ToolExecutionError( - f"Tags not found: {tag_names_not_found}. The search scans up to " - f"{max_items_to_scan} tags. If the user account has a larger number of tags, " - "it's possible that the tags exist, but the search didn't find them." - ) - - tag_ids.extend([tag["id"] for tag in searched_tags["matches"]["tags"]]) - - return tag_ids if tag_ids else None - - -async def paginate_tool_call( - tool: Callable[[ToolContext, Any], Awaitable[ToolResponse]], - context: ToolContext, - response_key: str, - max_items: int = 300, - timeout_seconds: int = ASANA_MAX_TIMEOUT_SECONDS, - next_page_token: str | None = None, - **tool_kwargs: Any, -) -> list[ToolResponse]: - results: list[ToolResponse] = [] - - async def paginate_loop() -> None: - nonlocal results - keep_paginating = True - - if "limit" not in tool_kwargs: - tool_kwargs["limit"] = 100 - - while keep_paginating: - response = await tool(context, **tool_kwargs) # type: ignore[call-arg] - results.extend(response[response_key]) - next_page = get_next_page(response) - next_page_token = next_page["next_page_token"] - if not next_page_token or len(results) >= max_items: - keep_paginating = False - else: - tool_kwargs["next_page_token"] = next_page_token - - try: - await asyncio.wait_for(paginate_loop(), timeout=timeout_seconds) - except TimeoutError: - raise PaginationTimeoutError(timeout_seconds, tool.__tool_name__) # type: ignore[attr-defined] - else: - return results - - -async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str: - # Importing here to avoid circular imports - from arcade_asana.tools.workspaces import list_workspaces - - workspaces = await list_workspaces(context) - if len(workspaces["workspaces"]) == 1: - return cast(str, workspaces["workspaces"][0]["id"]) - else: - message = "User has multiple workspaces. Please provide a workspace ID." - additional_prompt = f"Workspaces available: {json.dumps(workspaces['workspaces'])}" - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=additional_prompt, - ) - - -async def find_projects_by_name( - context: ToolContext, - names: list[str], - team_id: list[str] | None = None, - response_limit: int = 100, - max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME, - return_projects_not_matched: bool = False, -) -> dict[str, Any]: - """Find projects by name using exact match - - This function will paginate the list_projects tool call and return the projects that match - the names provided. If the names provided are not found, it will return the names searched for - that did not match any projects. - - If return_projects_not_matched is True, it will also return the projects that were scanned, - but did not match any of the names searched for. - - Args: - context: The tool context to use in the list_projects tool call. - names: The names of the projects to search for. - team_id: The ID of the team to search for projects in. - response_limit: The maximum number of matched projects to return. - max_items_to_scan: The maximum number of projects to scan while looking for matches. - return_projects_not_matched: Whether to return the projects that were scanned, but did not - match any of the names searched for. - """ - from arcade_asana.tools.projects import list_projects # avoid circular imports - - names_lower = {name.casefold() for name in names} - - projects = await paginate_tool_call( - tool=list_projects, - context=context, - response_key="projects", - max_items=max_items_to_scan, - timeout_seconds=15, - team_id=team_id, - ) - - matches: list[dict[str, Any]] = [] - not_matched: list[str] = [] - - for project in projects: - project_name_lower = project["name"].casefold() - if len(matches) >= response_limit: - break - if project_name_lower in names_lower: - matches.append(project) - names_lower.remove(project_name_lower) - else: - not_matched.append(project) - - not_found = [name for name in names if name.casefold() in names_lower] - - response = { - "matches": { - "projects": matches, - "count": len(matches), - }, - "not_found": { - "names": not_found, - "count": len(not_found), - }, - } - - if return_projects_not_matched: - response["not_matched"] = { - "projects": not_matched, - "count": len(not_matched), - } - - return response - - -async def find_tags_by_name( - context: ToolContext, - names: list[str], - workspace_id: list[str] | None = None, - response_limit: int = 100, - max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME, - return_tags_not_matched: bool = False, -) -> dict[str, Any]: - """Find tags by name using exact match - - This function will paginate the list_tags tool call and return the tags that match the names - provided. If the names provided are not found, it will return the names searched for that did - not match any tags. - - If return_tags_not_matched is True, it will also return the tags that were scanned, but did not - match any of the names searched for. - - Args: - context: The tool context to use in the list_tags tool call. - names: The names of the tags to search for. - workspace_id: The ID of the workspace to search for tags in. - response_limit: The maximum number of matched tags to return. - max_items_to_scan: The maximum number of tags to scan while looking for matches. - return_tags_not_matched: Whether to return the tags that were scanned, but did not match - any of the names searched for. - """ - from arcade_asana.tools.tags import list_tags # avoid circular imports - - names_lower = {name.casefold() for name in names} - - tags = await paginate_tool_call( - tool=list_tags, - context=context, - response_key="tags", - max_items=max_items_to_scan, - timeout_seconds=15, - workspace_id=workspace_id, - ) - - matches: list[dict[str, Any]] = [] - not_matched: list[str] = [] - for tag in tags: - tag_name_lower = tag["name"].casefold() - if len(matches) >= response_limit: - break - if tag_name_lower in names_lower: - matches.append(tag) - names_lower.remove(tag_name_lower) - else: - not_matched.append(tag["name"]) - - not_found = [name for name in names if name.casefold() in names_lower] - - response = { - "matches": { - "tags": matches, - "count": len(matches), - }, - "not_found": { - "tags": not_found, - "count": len(not_found), - }, - } - - if return_tags_not_matched: - response["not_matched"] = { - "tags": not_matched, - "count": len(not_matched), - } - - return response - - -def get_next_page(response: dict[str, Any]) -> dict[str, Any]: - try: - token = response["next_page"]["offset"] - except (KeyError, TypeError): - token = None - - return {"has_more_pages": token is not None, "next_page_token": token} diff --git a/toolkits/asana/conftest.py b/toolkits/asana/conftest.py deleted file mode 100644 index 412413c9..00000000 --- a/toolkits/asana/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_httpx_client(): - with patch("arcade_asana.models.httpx") as mock_httpx: - yield mock_httpx.AsyncClient().__aenter__.return_value diff --git a/toolkits/asana/evals/eval_create_task.py b/toolkits/asana/evals/eval_create_task.py deleted file mode 100644 index 0bef1bc2..00000000 --- a/toolkits/asana/evals/eval_create_task.py +++ /dev/null @@ -1,96 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.tools import ( - create_task, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def create_task_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="create task eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create task with name, description, start and due dates", - user_message="Create a task with the name 'Hello World' and the description 'This is a task description' starting on 2025-05-05 and due on 2025-05-11.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_task, - args={ - "name": "Hello World", - "description": "This is a task description", - "start_date": "2025-05-05", - "due_date": "2025-05-11", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=1 / 4), - BinaryCritic(critic_field="description", weight=1 / 4), - BinaryCritic(critic_field="start_date", weight=1 / 4), - BinaryCritic(critic_field="due_date", weight=1 / 4), - ], - ) - - suite.add_case( - name="Create task with name and tag names", - user_message="Create a task with the name 'Hello World' and the tags 'My Tag' and 'My Other Tag'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_task, - args={ - "name": "Hello World", - "tags": ["My Tag", "My Other Tag"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.5), - BinaryCritic(critic_field="tags", weight=0.5), - ], - ) - - suite.add_case( - name="Create task with name and tag IDs", - user_message="Create a task with the name 'Hello World' and the tags '1234567890' and '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_task, - args={ - "name": "Hello World", - "tags": ["1234567890", "1234567891"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.5), - BinaryCritic(critic_field="tags", weight=0.5), - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_projects.py b/toolkits/asana/evals/eval_projects.py deleted file mode 100644 index eb556296..00000000 --- a/toolkits/asana/evals/eval_projects.py +++ /dev/null @@ -1,197 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.tools import get_project_by_id, list_projects - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def list_projects_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="list projects eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List projects", - user_message="List the projects in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_projects, - args={ - "team_ids": None, - "limit": 100, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="team_ids", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.3), - BinaryCritic(critic_field="offset", weight=0.3), - ], - ) - - suite.add_case( - name="List projects filtering by teams", - user_message="List the projects in Asana for the team '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_projects, - args={ - "team_ids": ["1234567890"], - "limit": 100, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="team_ids", weight=0.6), - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="offset", weight=0.2), - ], - ) - - suite.add_case( - name="List projects with limit", - user_message="List 10 projects in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_projects, - args={ - "team_ids": None, - "limit": 10, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="team_ids", weight=0.2), - BinaryCritic(critic_field="limit", weight=0.6), - BinaryCritic(critic_field="offset", weight=0.2), - ], - ) - - suite.add_case( - name="List projects with pagination", - user_message="Show me the next 2 projects.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_projects, - args={ - "limit": 2, - "offset": "abc123", - "team_ids": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.45), - BinaryCritic(critic_field="offset", weight=0.45), - BinaryCritic(critic_field="team_ids", weight=0.1), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 projects in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListProjects", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "workspaces": [ - { - "id": "1234567890", - "name": "Project Hello", - }, - { - "id": "1234567891", - "name": "Project World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListProjects", - }, - { - "role": "assistant", - "content": "Here are two projects in Asana:\n\n1. Project Hello\n2. Project World", - }, - ], - ) - - return suite - - -@tool_eval() -def get_project_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get project by id eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get project by id", - user_message="Get the project with id '1234567890' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_project_by_id, - args={ - "project_id": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.7), - BinaryCritic(critic_field="description", weight=0.1), - BinaryCritic(critic_field="color", weight=0.1), - BinaryCritic(critic_field="workspace_id", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_tags.py b/toolkits/asana/evals/eval_tags.py deleted file mode 100644 index 0d377a34..00000000 --- a/toolkits/asana/evals/eval_tags.py +++ /dev/null @@ -1,281 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.tools import create_tag, list_tags - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def list_tags_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="list tags eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List tags", - user_message="List the tags in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tags, - args={ - "limit": 100, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - ) - - suite.add_case( - name="List tags with limit", - user_message="List 10 tags in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tags, - args={ - "limit": 10, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.75), - BinaryCritic(critic_field="offset", weight=0.25), - ], - ) - - suite.add_case( - name="List tags with pagination", - user_message="Show me the next 2 tags.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tags, - args={ - "limit": 2, - "offset": "abc123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 tags in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListTags", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "workspaces": [ - { - "id": "1234567890", - "name": "Tag Hello", - }, - { - "id": "1234567891", - "name": "Tag World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListTags", - }, - { - "role": "assistant", - "content": "Here are two tags in Asana:\n\n1. Tag Hello\n2. Tag World", - }, - ], - ) - - suite.add_case( - name="List tags with pagination changing the limit", - user_message="Show me the next 5 tags.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tags, - args={ - "limit": 5, - "offset": "abc123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Show me 5 tags in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListTags", - "arguments": '{"limit":5}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "workspaces": [ - { - "id": "1234567890", - "name": "Tag Hello", - }, - { - "id": "1234567891", - "name": "Tag World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListTags", - }, - { - "role": "assistant", - "content": "Here are two tags in Asana:\n\n1. Tag Hello\n2. Tag World", - }, - ], - ) - - return suite - - -@tool_eval() -def create_tag_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="create tag eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create tag", - user_message="Create a 'Hello World' tag in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_tag, - args={ - "name": "Hello World", - "description": None, - "color": None, - "workspace_id": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.7), - BinaryCritic(critic_field="description", weight=0.1), - BinaryCritic(critic_field="color", weight=0.1), - BinaryCritic(critic_field="workspace_id", weight=0.1), - ], - ) - - suite.add_case( - name="Create tag with description and color", - user_message="Create a dark orange tag 'Attention' in Asana with the description 'This is a tag for important tasks'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_tag, - args={ - "name": "Attention", - "description": "This is a tag for important tasks", - "color": "dark-orange", - "workspace_id": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.3), - BinaryCritic(critic_field="description", weight=0.3), - BinaryCritic(critic_field="color", weight=0.3), - BinaryCritic(critic_field="workspace_id", weight=0.1), - ], - ) - - suite.add_case( - name="Create tag in a specific workspace", - user_message="Create a dark orange tag 'Attention' in Asana with the description 'This is a tag for important tasks' in the workspace '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_tag, - args={ - "name": "Attention", - "description": "This is a tag for important tasks", - "color": "dark-orange", - "workspace_id": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="name", weight=0.25), - BinaryCritic(critic_field="description", weight=0.25), - BinaryCritic(critic_field="color", weight=0.25), - BinaryCritic(critic_field="workspace_id", weight=0.25), - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_tasks.py b/toolkits/asana/evals/eval_tasks.py deleted file mode 100644 index 445cdd0f..00000000 --- a/toolkits/asana/evals/eval_tasks.py +++ /dev/null @@ -1,442 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.constants import SortOrder, TaskSortBy -from arcade_asana.tools import ( - get_subtasks_from_a_task, - get_task_by_id, - get_tasks_without_id, - update_task, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def get_task_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get task by id eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get task by id", - user_message="Get the task with id '1234567890' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_task_by_id, - args={ - "task_id": "1234567890", - "max_subtasks": 100, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=0.8), - BinaryCritic(critic_field="max_subtasks", weight=0.2), - ], - ) - - suite.add_case( - name="Get task by id with subtasks limit", - user_message="Get the task with id '1234567890' in Asana with up to 10 subtasks.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_task_by_id, - args={ - "task_id": "1234567890", - "max_subtasks": 10, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=0.5), - BinaryCritic(critic_field="max_subtasks", weight=0.5), - ], - ) - - return suite - - -@tool_eval() -def get_subtasks_from_a_task_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get subtasks from a task eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get subtasks from a task", - user_message="Get the next 2 subtasks.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_subtasks_from_a_task, - args={ - "task_id": "1234567890", - "limit": 2, - "offset": "abc123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=1 / 3), - BinaryCritic(critic_field="limit", weight=1 / 3), - BinaryCritic(critic_field="offset", weight=1 / 3), - ], - additional_messages=[ - {"role": "user", "content": "Get 2 subtasks from the task '1234567890'."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_GetSubtasksFromATask", - "arguments": '{"task_id":"1234567890","limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "subtasks": [ - { - "id": "1234567890", - "name": "Subtask Hello", - }, - { - "id": "1234567891", - "name": "Subtask World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_GetSubtasksFromATask", - }, - { - "role": "assistant", - "content": "Here are two subtasks in Asana:\n\n1. Subtask Hello\n2. Subtask World", - }, - ], - ) - - return suite - - -@tool_eval() -def search_tasks_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="search tasks eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search tasks by name", - user_message="Search for the task 'Hello' in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=1), - ], - ) - - suite.add_case( - name="Search tasks by name with custom sorting", - user_message="Search for the task 'Hello' in Asana sorting by likes in descending order.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "sort_by": TaskSortBy.LIKES, - "sort_order": SortOrder.DESCENDING, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=1 / 3), - BinaryCritic(critic_field="sort_by", weight=1 / 3), - BinaryCritic(critic_field="sort_order", weight=1 / 3), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by project ID", - user_message="Search for the task 'Hello' associated to the project with ID '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "project_id": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.5), - BinaryCritic(critic_field="project_id", weight=0.5), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by project name", - user_message="Search for the task 'Hello' associated to the project named 'My Project'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "project_name": "My Project", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.5), - BinaryCritic(critic_field="project_name", weight=0.5), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by team ID", - user_message="Search for the task 'Hello' associated to the team with ID '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "team_id": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.5), - BinaryCritic(critic_field="team_id", weight=0.5), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by tag IDs", - user_message="Search for the task 'Hello' associated to the tags with IDs '1234567890' and '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "tags": ["1234567890", "1234567891"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.5), - BinaryCritic(critic_field="tag_ids", weight=0.5), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by tags names", - user_message="Search for the task 'Hello' associated to the tags 'My Tag' and 'My Other Tag'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "tags": ["My Tag", "My Other Tag"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.5), - BinaryCritic(critic_field="tag_names", weight=0.5), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by start and due dates", - user_message="Search for tasks 'Hello' that started on '2025-01-01' and are due on '2025-01-02'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "start_on": "2025-01-01", - "due_on": "2025-01-02", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=1 / 3), - BinaryCritic(critic_field="start_on", weight=1 / 3), - BinaryCritic(critic_field="due_on", weight=1 / 3), - ], - ) - - suite.add_case( - name="Search tasks by name filtering by start and due dates", - user_message="Search for tasks 'Hello' that start on 2025-05-05 and are due on or before 2025-05-11.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "start_on": "2025-05-05", - "due_on_or_before": "2025-05-11", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=1 / 3), - BinaryCritic(critic_field="start_on", weight=1 / 3), - BinaryCritic(critic_field="due_on_or_before", weight=1 / 3), - ], - ) - - suite.add_case( - name="Search not-completed tasks by name filtering by due date", - user_message="Search for tasks 'Hello' that are not completed and are due on or before 2025-05-11.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_tasks_without_id, - args={ - "keywords": "Hello", - "due_on_or_before": "2025-05-11", - "completed": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=1 / 3), - BinaryCritic(critic_field="due_on_or_before", weight=1 / 3), - BinaryCritic(critic_field="completed", weight=1 / 3), - ], - ) - - return suite - - -@tool_eval() -def update_task_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="update task eval suite", - system_message="You are an AI assistant with access to Asana tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Update task name", - user_message="Update the task '1234567890' with the name 'Hello World'.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_task, - args={"task_id": "1234567890", "name": "Hello World"}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=0.5), - BinaryCritic(critic_field="name", weight=0.5), - ], - ) - - suite.add_case( - name="Update task as completed", - user_message="Mark the task '1234567890' as completed.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_task, - args={"task_id": "1234567890", "completed": True}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=0.5), - BinaryCritic(critic_field="completed", weight=0.5), - ], - ) - - suite.add_case( - name="Update task with new parent task", - user_message="Update the task '1234567890' with the parent task '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_task, - args={"task_id": "1234567890", "parent_task_id": "1234567891"}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=0.5), - BinaryCritic(critic_field="parent_task_id", weight=0.5), - ], - ) - - suite.add_case( - name="Update task with new assignee", - user_message="Update the task '1234567890' with the assignee '1234567891'.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_task, - args={"task_id": "1234567890", "assignee_id": "1234567891"}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="task_id", weight=0.5), - BinaryCritic(critic_field="assignee_id", weight=0.5), - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_teams.py b/toolkits/asana/evals/eval_teams.py deleted file mode 100644 index 79778de8..00000000 --- a/toolkits/asana/evals/eval_teams.py +++ /dev/null @@ -1,269 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.tools import get_team_by_id, list_teams_the_current_user_is_a_member_of - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def get_team_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get team by id eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get team by id", - user_message="Get the team with ID 1234567890.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_team_by_id, - args={ - "team_id": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="team_id", weight=1), - ], - ) - - return suite - - -@tool_eval() -def list_teams_the_current_user_is_a_member_of_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="list teams the current user is a member of eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List teams the current user is a member of", - user_message="List the teams the current user is a member of.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={}, - ), - ], - rubric=rubric, - critics=[], - ) - - suite.add_case( - name="List teams I am a member of", - user_message="List the teams I'm a member of.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={}, - ), - ], - rubric=rubric, - critics=[], - ) - - suite.add_case( - name="List teams I am a member of", - user_message="What teams am I a member of in asana?", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={}, - ), - ], - rubric=rubric, - critics=[], - ) - - suite.add_case( - name="List teams the current user is a member of filtering by workspace", - user_message="List the teams the current user is a member of in the workspace 1234567890.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={ - "workspace_ids": ["1234567890"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="workspace_ids", weight=1), - ], - ) - - suite.add_case( - name="List up to 5 teams the current user is a member of filtering by workspace", - user_message="List up to 5 teams the current user is a member of in the workspace 1234567890.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={ - "workspace_ids": ["1234567890"], - "limit": 5, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="workspace_ids", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="List teams with pagination", - user_message="Show me the next 2 teams.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={ - "limit": 2, - "offset": "abc123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="offset", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 teams I'm a member of in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 1, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "teams": [ - { - "id": "1234567890", - "name": "Team Hello", - }, - { - "id": "1234567891", - "name": "Team World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", - }, - { - "role": "assistant", - "content": "Here are two teams you're a member of in Asana:\n\n1. Team Hello\n2. Team World", - }, - ], - ) - - suite.add_case( - name="List teams with pagination changing the limit", - user_message="Show me the next 5 teams.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_teams_the_current_user_is_a_member_of, - args={ - "limit": 5, - "offset": "abc123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 teams I'm a member of in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 1, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "teams": [ - { - "id": "1234567890", - "name": "Team Hello", - }, - { - "id": "1234567891", - "name": "Team World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListTeamsTheCurrentUserIsAMemberOf", - }, - { - "role": "assistant", - "content": "Here are two teams you're a member of in Asana:\n\n1. Team Hello\n2. Team World", - }, - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_users.py b/toolkits/asana/evals/eval_users.py deleted file mode 100644 index 0021ec5e..00000000 --- a/toolkits/asana/evals/eval_users.py +++ /dev/null @@ -1,253 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.tools import get_user_by_id, list_users - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def get_user_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get user by id eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get user by id", - user_message="Get the user with ID 1234567890.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_user_by_id, - args={ - "user_id": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="user_id", weight=0.1), - ], - ) - - return suite - - -@tool_eval() -def list_users_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="list users eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List users", - user_message="List the users in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_users, - args={ - "workspace_id": None, - "limit": 100, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="workspace_id", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.3), - BinaryCritic(critic_field="offset", weight=0.4), - ], - ) - - suite.add_case( - name="List users filtering by workspace", - user_message="List the users in the workspace 1234567890.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_users, - args={ - "workspace_id": "1234567890", - "limit": 100, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="workspace_id", weight=0.8), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="offset", weight=0.1), - ], - ) - - suite.add_case( - name="List users with limit", - user_message="List up to 5 users.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_users, - args={ - "limit": 5, - "workspace_id": None, - "offset": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.8), - BinaryCritic(critic_field="workspace_id", weight=0.1), - BinaryCritic(critic_field="offset", weight=0.1), - ], - ) - - suite.add_case( - name="List users with pagination", - user_message="Show me the next 2 users.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_users, - args={ - "workspace_id": None, - "limit": 2, - "offset": 2, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.45), - BinaryCritic(critic_field="offset", weight=0.45), - BinaryCritic(critic_field="workspace_id", weight=0.1), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 users in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListUsers", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "users": [ - { - "id": "1234567890", - "name": "User Hello", - }, - { - "id": "1234567891", - "name": "User World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListUsers", - }, - { - "role": "assistant", - "content": "Here are two users in Asana:\n\n1. User Hello\n2. User World", - }, - ], - ) - - suite.add_case( - name="List users with pagination changing the limit", - user_message="Show me the next 5 users.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_users, - args={ - "workspace_id": None, - "limit": 5, - "offset": 2, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.45), - BinaryCritic(critic_field="offset", weight=0.45), - BinaryCritic(critic_field="workspace_id", weight=0.1), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 users in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListUsers", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "users": [ - { - "id": "1234567890", - "name": "User Hello", - }, - { - "id": "1234567891", - "name": "User World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListUsers", - }, - { - "role": "assistant", - "content": "Here are two users in Asana:\n\n1. User Hello\n2. User World", - }, - ], - ) - - return suite diff --git a/toolkits/asana/evals/eval_workspaces.py b/toolkits/asana/evals/eval_workspaces.py deleted file mode 100644 index b21a2124..00000000 --- a/toolkits/asana/evals/eval_workspaces.py +++ /dev/null @@ -1,177 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_asana -from arcade_asana.tools import list_workspaces - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_asana) - - -@tool_eval() -def list_workspaces_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="list workspaces eval suite", - system_message=( - "You are an AI assistant with access to Asana tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List workspaces", - user_message="List the workspaces in Asana.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_workspaces, - args={ - "limit": 100, - "offset": 0, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - ) - - suite.add_case( - name="List workspaces with pagination", - user_message="Show me the next 2 workspaces.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_workspaces, - args={ - "limit": 2, - "offset": 2, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Show me 2 workspaces in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListWorkspaces", - "arguments": '{"limit":2}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "workspaces": [ - { - "id": "1234567890", - "name": "Workspace Hello", - }, - { - "id": "1234567891", - "name": "Workspace World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListWorkspaces", - }, - { - "role": "assistant", - "content": "Here are two workspaces in Asana:\n\n1. Workspace Hello\n2. Workspace World", - }, - ], - ) - - suite.add_case( - name="List workspaces with pagination changing the limit", - user_message="Show me the next 5 workspaces.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_workspaces, - args={ - "limit": 5, - "offset": "abc123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Show me 5 workspaces in Asana."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Asana_ListWorkspaces", - "arguments": '{"limit":5}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "count": 2, - "next_page": { - "has_more_results": True, - "next_page_token": "abc123", - }, - "workspaces": [ - { - "id": "1234567890", - "name": "Workspace Hello", - }, - { - "id": "1234567891", - "name": "Workspace World", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Asana_ListWorkspaces", - }, - { - "role": "assistant", - "content": "Here are two workspaces in Asana:\n\n1. Workspace Hello\n2. Workspace World", - }, - ], - ) - - return suite diff --git a/toolkits/asana/pyproject.toml b/toolkits/asana/pyproject.toml deleted file mode 100644 index c329fe8d..00000000 --- a/toolkits/asana/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_asana" -version = "0.1.3" -description = "Arcade tools designed for LLMs to interact with Asana" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_asana/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_asana",] diff --git a/toolkits/asana/tests/test_utils.py b/toolkits/asana/tests/test_utils.py deleted file mode 100644 index 29e22760..00000000 --- a/toolkits/asana/tests/test_utils.py +++ /dev/null @@ -1,78 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk.errors import RetryableToolError - -from arcade_asana.utils import ( - get_project_by_name_or_raise_error, - get_tag_ids, - get_unique_workspace_id_or_raise_error, -) - - -@pytest.mark.asyncio -@patch("arcade_asana.utils.find_tags_by_name") -async def test_get_tag_ids(mock_find_tags_by_name, mock_context): - assert await get_tag_ids(mock_context, None) is None - assert await get_tag_ids(mock_context, ["1234567890", "1234567891"]) == [ - "1234567890", - "1234567891", - ] - - mock_find_tags_by_name.return_value = { - "matches": { - "tags": [ - {"id": "1234567890", "name": "My Tag"}, - {"id": "1234567891", "name": "My Other Tag"}, - ] - }, - "not_found": {"tags": []}, - } - - assert await get_tag_ids(mock_context, ["My Tag", "My Other Tag"]) == [ - "1234567890", - "1234567891", - ] - - -@pytest.mark.asyncio -@patch("arcade_asana.tools.workspaces.list_workspaces") -async def test_get_unique_workspace_id_or_raise_error(mock_list_workspaces, mock_context): - mock_list_workspaces.return_value = { - "workspaces": [ - {"id": "1234567890", "name": "My Workspace"}, - ] - } - assert await get_unique_workspace_id_or_raise_error(mock_context) == "1234567890" - - mock_list_workspaces.return_value = { - "workspaces": [ - {"id": "1234567890", "name": "My Workspace"}, - {"id": "1234567891", "name": "My Other Workspace"}, - ] - } - with pytest.raises(RetryableToolError) as exc_info: - await get_unique_workspace_id_or_raise_error(mock_context) - - assert "My Other Workspace" in exc_info.value.additional_prompt_content - - -@pytest.mark.asyncio -@patch("arcade_asana.utils.find_projects_by_name") -async def test_get_project_by_name_or_raise_error(mock_find_projects_by_name, mock_context): - project1 = {"id": "1234567890", "name": "My Project"} - - mock_find_projects_by_name.return_value = { - "matches": {"projects": [project1], "count": 1}, - "not_matched": {"projects": [], "count": 0}, - } - assert await get_project_by_name_or_raise_error(mock_context, project1["name"]) == project1 - - mock_find_projects_by_name.return_value = { - "matches": {"projects": [], "count": 0}, - "not_matched": {"projects": [project1], "count": 1}, - } - with pytest.raises(RetryableToolError) as exc_info: - await get_project_by_name_or_raise_error(mock_context, "Inexistent Project") - - assert project1["name"] in exc_info.value.additional_prompt_content diff --git a/toolkits/code_sandbox/.pre-commit-config.yaml b/toolkits/code_sandbox/.pre-commit-config.yaml deleted file mode 100644 index 6d815ab5..00000000 --- a/toolkits/code_sandbox/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/code_sandbox/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/code_sandbox/.ruff.toml b/toolkits/code_sandbox/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/code_sandbox/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/code_sandbox/LICENSE b/toolkits/code_sandbox/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/code_sandbox/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/code_sandbox/Makefile b/toolkits/code_sandbox/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/code_sandbox/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/code_sandbox/arcade_code_sandbox/__init__.py b/toolkits/code_sandbox/arcade_code_sandbox/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/code_sandbox/arcade_code_sandbox/tools/__init__.py b/toolkits/code_sandbox/arcade_code_sandbox/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py b/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py deleted file mode 100644 index cbe3934c..00000000 --- a/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from e2b_code_interpreter import Sandbox - -from arcade_code_sandbox.tools.models import E2BSupportedLanguage - -# See https://e2b.dev/docs to learn more about E2B - - -@tool(requires_secrets=["E2B_API_KEY"]) -def run_code( - context: ToolContext, - code: Annotated[str, "The code to run"], - language: Annotated[ - E2BSupportedLanguage, "The language of the code" - ] = E2BSupportedLanguage.PYTHON, -) -> Annotated[str, "The sandbox execution as a JSON string"]: - """ - Run code in a sandbox and return the output. - """ - api_key = context.get_secret("E2B_API_KEY") - - with Sandbox(api_key=api_key) as sbx: - execution = sbx.run_code(code=code, language=language) - - return str(execution.to_json()) - - -# Note: Not recommended to use tool_choice='generate' with this tool -# since it contains base64 encoded image. -@tool(requires_secrets=["E2B_API_KEY"]) -def create_static_matplotlib_chart( - context: ToolContext, - code: Annotated[str, "The Python code to run"], -) -> Annotated[dict, "A dictionary with the following keys: base64_image, logs, error"]: - """ - Run the provided Python code to generate a static matplotlib chart. - The resulting chart is returned as a base64 encoded image. - """ - api_key = context.get_secret("E2B_API_KEY") - - with Sandbox(api_key=api_key) as sbx: - execution = sbx.run_code(code=code) - - result = { - "base64_image": execution.results[0].png if execution.results else None, - "logs": execution.logs.to_json(), - "error": execution.error.to_json() if execution.error else None, - } - - return result diff --git a/toolkits/code_sandbox/arcade_code_sandbox/tools/models.py b/toolkits/code_sandbox/arcade_code_sandbox/tools/models.py deleted file mode 100644 index 34ff05bc..00000000 --- a/toolkits/code_sandbox/arcade_code_sandbox/tools/models.py +++ /dev/null @@ -1,10 +0,0 @@ -from enum import Enum - - -# Models and enums for the e2b code interpreter -class E2BSupportedLanguage(str, Enum): - PYTHON = "python" - JAVASCRIPT = "js" - R = "r" - JAVA = "java" - BASH = "bash" diff --git a/toolkits/code_sandbox/evals/eval_e2b.py b/toolkits/code_sandbox/evals/eval_e2b.py deleted file mode 100644 index f92bf05e..00000000 --- a/toolkits/code_sandbox/evals/eval_e2b.py +++ /dev/null @@ -1,119 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_code_sandbox -from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code -from arcade_code_sandbox.tools.models import E2BSupportedLanguage - -merge_sort_code = """ -def merge_sort(arr): - if len(arr) <= 1: - return arr - - mid = len(arr) // 2 - left = merge_sort(arr[:mid]) - right = merge_sort(arr[mid:]) - - return merge(left, right) - -def merge(left, right): - result = [] - i, j = 0, 0 - - while i < len(left) and j < len(right): - if left[i] < right[j]: - result.append(left[i]) - i += 1 - else: - result.append(right[j]) - j += 1 - - result.extend(left[i:]) - result.extend(right[j:]) - - return result - -sample_list = ["banana", "apple", "cherry", "date", "elderberry"] - -sorted_list = merge_sort(sample_list) -print("Sorted list:", sorted_list) -""" - -matplotlib_chart_code = """ -import matplotlib.pyplot as plt - -labels = ['Apples', 'Bananas', 'Cherries', 'Dates'] -sizes = [30, 25, 20, 25] -colors = ['red', 'yellow', 'purple', 'brown'] - -plt.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90) - -plt.axis('equal') - -plt.title('Fruit Distribution') - -plt.savefig('fruit_pie_chart.png') -""" - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_code_sandbox) - - -@tool_eval() -def code_sandbox_eval_suite(): - suite = EvalSuite( - name="code_sandbox Tools Evaluation", - system_message="You are an AI assistant with access to code_sandbox tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Run code", - user_message=f"Can you please run my merge sort algo?\n\n{merge_sort_code}", - expected_tool_calls=[ - ExpectedToolCall( - func=run_code, - args={ - "code": merge_sort_code, - "language": E2BSupportedLanguage.PYTHON, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="code", weight=0.8), - BinaryCritic(critic_field="language", weight=0.2), - ], - ) - - suite.add_case( - name="Create static matplotlib chart", - user_message=f"Run this code:\n\n{matplotlib_chart_code}", - expected_tool_calls=[ - ExpectedToolCall( - func=create_static_matplotlib_chart, - args={ - "code": matplotlib_chart_code, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="code", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/code_sandbox/pyproject.toml b/toolkits/code_sandbox/pyproject.toml deleted file mode 100644 index 2c2e380d..00000000 --- a/toolkits/code_sandbox/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_code_sandbox" -version = "1.0.2" -description = "Arcade.dev LLM tools for running code in a sandbox" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "e2b-code-interpreter>=1.0.1,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_code_sandbox/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_code_sandbox",] diff --git a/toolkits/code_sandbox/tests/__init__.py b/toolkits/code_sandbox/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/code_sandbox/tests/test_e2b.py b/toolkits/code_sandbox/tests/test_e2b.py deleted file mode 100644 index ce29dc56..00000000 --- a/toolkits/code_sandbox/tests/test_e2b.py +++ /dev/null @@ -1,67 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem -from arcade_tdk.errors import ToolExecutionError - -from arcade_code_sandbox.tools.e2b import create_static_matplotlib_chart, run_code -from arcade_code_sandbox.tools.models import E2BSupportedLanguage - - -@pytest.fixture -def mock_sandbox(): - with patch("arcade_code_sandbox.tools.e2b.Sandbox") as mock: - yield mock.return_value.__enter__.return_value - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="e2b_api_key", value="fake_api_key")]) - - -def test_run_code_success(mock_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.to_json.return_value = '{"result": "success"}' - mock_sandbox.run_code.return_value = mock_execution - - result = run_code(mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON) - assert result == '{"result": "success"}' - - -def test_run_code_error(mock_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.to_json.side_effect = ToolExecutionError("Execution failed") - mock_sandbox.run_code.return_value = mock_execution - - with pytest.raises(ToolExecutionError, match="Execution failed"): - run_code(mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON) - - -def test_create_static_matplotlib_chart_success(mock_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.results = [MagicMock(png="base64encodedimage")] - mock_execution.logs.to_json.return_value = '{"logs": "log data"}' - mock_execution.error = None - mock_sandbox.run_code.return_value = mock_execution - - result = create_static_matplotlib_chart(mock_context, "import matplotlib.pyplot as plt") - assert result == { - "base64_image": "base64encodedimage", - "logs": '{"logs": "log data"}', - "error": None, - } - - -def test_create_static_matplotlib_chart_error(mock_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.results = [] - mock_execution.logs.to_json.return_value = '{"logs": "log data"}' - mock_execution.error.to_json.return_value = '{"error": "some error"}' - mock_sandbox.run_code.return_value = mock_execution - - result = create_static_matplotlib_chart(mock_context, "import matplotlib.pyplot as plt") - assert result == { - "base64_image": None, - "logs": '{"logs": "log data"}', - "error": '{"error": "some error"}', - } diff --git a/toolkits/confluence/.pre-commit-config.yaml b/toolkits/confluence/.pre-commit-config.yaml deleted file mode 100644 index abac2158..00000000 --- a/toolkits/confluence/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/confluence/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/confluence/.ruff.toml b/toolkits/confluence/.ruff.toml deleted file mode 100644 index 9519fe6c..00000000 --- a/toolkits/confluence/.ruff.toml +++ /dev/null @@ -1,44 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/confluence/LICENSE b/toolkits/confluence/LICENSE deleted file mode 100644 index 8c2d4f37..00000000 --- a/toolkits/confluence/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/confluence/Makefile b/toolkits/confluence/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/confluence/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/confluence/arcade_confluence/__init__.py b/toolkits/confluence/arcade_confluence/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/confluence/arcade_confluence/client.py b/toolkits/confluence/arcade_confluence/client.py deleted file mode 100644 index 5415d16c..00000000 --- a/toolkits/confluence/arcade_confluence/client.py +++ /dev/null @@ -1,617 +0,0 @@ -from enum import Enum -from typing import Any -from urllib.parse import parse_qs, urlparse - -import httpx -from arcade_tdk.errors import ToolExecutionError - -from arcade_confluence.enums import BodyFormat, PageUpdateMode -from arcade_confluence.utils import ( - build_child_url, - build_hierarchy, - remove_none_values, -) - - -class ConfluenceAPIVersion(str, Enum): - V1 = "wiki/rest/api" - V2 = "wiki/api/v2" - - -class ConfluenceClient: - ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" - BASE_URL = "https://api.atlassian.com/ex/confluence" - - def __init__(self, token: str, api_version: ConfluenceAPIVersion): - self.token = token - self.cloud_id = self._get_cloud_id() - self.api_version = api_version.value - - def _get_cloud_id(self) -> str: - """ - Fetch the cloudId for .atlassian.net - using the OAuth2 3LO accessible-resources endpoint. - - For details on why this is necessary, see: https://developer.atlassian.com/cloud/oauth/getting-started/making-calls-to-api - """ - headers = {"Authorization": f"Bearer {self.token}"} - resp = httpx.get(self.ACCESSIBLE_RESOURCES_URL, headers=headers) - resp.raise_for_status() - resp_json = resp.json() - - if len(resp_json) == 0: - raise ToolExecutionError(message="No workspaces found for the authenticated user.") - - return resp_json[0].get("id") # type: ignore[no-any-return] - - async def request(self, method: str, path: str, **kwargs: Any) -> Any: - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.token}", - } - async with httpx.AsyncClient() as client: - response = await client.request( - method, - f"{self.BASE_URL}/{self.cloud_id}/{self.api_version}/{path.lstrip('/')}", - headers=headers, - **kwargs, - ) - response.raise_for_status() - return response.json() - - async def get(self, path: str, **kwargs: Any) -> Any: - return await self.request("GET", path, **kwargs) - - async def post(self, path: str, **kwargs: Any) -> Any: - return await self.request("POST", path, **kwargs) - - async def put(self, path: str, **kwargs: Any) -> Any: - return await self.request("PUT", path, **kwargs) - - -class ConfluenceClientV1(ConfluenceClient): - def __init__(self, token: str): - super().__init__(token, api_version=ConfluenceAPIVersion.V1) - - def _build_query_cql(self, query: str, enable_fuzzy: bool) -> str: - """Build CQL for a single query (term or phrase). - - Args: - query: The search query (single word term or multi-word phrase) - enable_fuzzy: Whether to enable fuzzy matching for single terms - - Returns: - CQL string for the query - """ - query = query.strip() - if not query: - return "" - - # For phrases (multiple words), don't use fuzzy matching - if " " in query: - return f'(text ~ "{query}" OR title ~ "{query}" OR space.title ~ "{query}")' - else: - # For single terms, optionally use fuzzy matching - term_suffix = "~" if enable_fuzzy else "" - return f'(text ~ "{query}{term_suffix}" OR title ~ "{query}{term_suffix}" OR space.title ~ "{query}{term_suffix}")' # noqa: E501 - - def _build_and_cql(self, queries: list[str], enable_fuzzy: bool) -> str: - """Build CQL for queries that must ALL be present (AND logic). - - Args: - queries: List of queries that must all be present - enable_fuzzy: Whether to enable fuzzy matching for single terms - - Returns: - CQL string with AND logic - """ - and_parts = [] - for query in queries: - query_cql = self._build_query_cql(query, enable_fuzzy) - if query_cql: - and_parts.append(query_cql) - - if not and_parts: - return "" - - return f"({' AND '.join(and_parts)})" - - def _build_or_cql(self, queries: list[str], enable_fuzzy: bool) -> str: - """Build CQL for queries where ANY can be present (OR logic). - - Args: - queries: List of queries where any can be present - enable_fuzzy: Whether to enable fuzzy matching for single terms - - Returns: - CQL string with OR logic - """ - or_parts = [] - for query in queries: - query_cql = self._build_query_cql(query, enable_fuzzy) - if query_cql: - or_parts.append(query_cql) - - if not or_parts: - return "" - - return f"({' OR '.join(or_parts)})" - - def construct_cql( - self, - must_contain_all: list[str] | None, - can_contain_any: list[str] | None, - enable_fuzzy: bool = False, - ) -> str: - """Construct CQL query with AND/OR logic. - - Learn about advanced searching using CQL here: https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/ - - Args: - must_contain_all: Queries that must ALL be present (AND logic) - can_contain_any: Queries where ANY can be present (OR logic) - enable_fuzzy: Whether to enable fuzzy matching for single terms - - Returns: - CQL query string - - Raises: - ToolExecutionError: If no search parameters are provided - """ - cql_parts = [] - - # Handle must_contain_all (AND logic) - if must_contain_all: - and_cql = self._build_and_cql(must_contain_all, enable_fuzzy) - if and_cql: - cql_parts.append(and_cql) - - # Handle can_contain_any (OR logic) - if can_contain_any: - or_cql = self._build_or_cql(can_contain_any, enable_fuzzy) - if or_cql: - cql_parts.append(or_cql) - - # If there's only one part, return it - if len(cql_parts) == 1: - return cql_parts[0] - - # AND the must_contain_all with the can_contain_any - if len(cql_parts) > 1: - return f"({' AND '.join(cql_parts)})" - - raise ToolExecutionError(message="At least one search parameter must be provided") - - def transform_search_content_response( - self, response: dict[str, Any] - ) -> dict[str, list[dict[str, Any]]]: - """ - Transform the response from the GET /search endpoint by converting relative webui paths - to absolute URLs using the base URL from the response. - """ - base_url = response.get("_links", {}).get("base", "") - transformed_results = [] - for result in response.get("results", []): - content = result.get("content", {}) - transformed_result = { - "id": content.get("id"), - "title": content.get("title"), - "type": content.get("type"), - "status": content.get("status"), - "excerpt": result.get("excerpt"), - "url": f"{base_url}{result.get('url')}", - } - transformed_results.append(transformed_result) - - return {"results": transformed_results} - - -class ConfluenceClientV2(ConfluenceClient): - def __init__(self, token: str): - super().__init__(token, api_version=ConfluenceAPIVersion.V2) - - def _transform_links( - self, response: dict[str, Any], base_url: str | None = None - ) -> dict[str, Any]: - """ - Transform the links in a page response by converting relative URLs to absolute URLs. - - Args: - response: A page object from the API - base_url: The base URL to use for the transformation - - Returns: - The transformed response - """ - result = response.copy() - if "_links" in result: - base_url = base_url or result["_links"].get("base", "") - webui_path = result["_links"].get("webui", "") - result["url"] = f"{base_url}{webui_path}" - del result["_links"] - return result - - def transform_get_spaces_response( - self, response: dict[str, Any] - ) -> dict[str, list[dict[str, Any]]]: - """ - Transform the response from the GET /spaces endpoint by converting relative webui paths - to absolute URLs using the base URL from the response. - """ - pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get( - "cursor", - [None], # type: ignore[list-item] - )[0] - - base_url = response.get("_links", {}).get("base", "") - results = response.get("results", []) - - transformed_results = [] - for space in results: - space_copy = space.copy() - if "_links" in space_copy and "webui" in space_copy["_links"]: - webui_path = space_copy["_links"]["webui"] - space_copy["url"] = base_url + webui_path - del space_copy["_links"] - transformed_results.append(space_copy) - - results = {"spaces": transformed_results, "pagination_token": pagination_token} - return remove_none_values(results) - - def transform_list_pages_response(self, response: dict[str, Any]) -> dict[str, Any]: - """Transform the response from the GET /pages endpoint.""" - pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get( - "cursor", - [None], # type: ignore[list-item] - )[0] - - base_url = response.get("_links", {}).get("base", "") - pages = [self._transform_links(page, base_url) for page in response["results"]] - results = {"pages": pages, "pagination_token": pagination_token} - return remove_none_values(results) - - def transform_get_multiple_pages_response( - self, response: dict[str, Any] - ) -> dict[str, list[dict[str, Any]]]: - """Transform the response from the GET /pages endpoint.""" - base_url = response.get("_links", {}).get("base", "") - pages = [self._transform_links(page, base_url) for page in response["results"]] - return {"pages": pages} - - def transform_space_response( - self, response: dict[str, Any], base_url: str | None = None - ) -> dict[str, dict[str, Any]]: - """Transform API responses that return a space object.""" - return {"space": self._transform_links(response, base_url)} - - def transform_page_response(self, response: dict[str, Any]) -> dict[str, dict[str, Any]]: - """Transform API responses that return a page object.""" - return {"page": self._transform_links(response)} - - def transform_get_attachments_response(self, response: dict[str, Any]) -> dict[str, Any]: - """Transform the response from the GET /pages/{id}/attachments endpoint.""" - pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get( - "cursor", - [None], # type: ignore[list-item] - )[0] - - base_url = response.get("_links", {}).get("base", "") - attachments = [] - for attachment in response["results"]: - result = attachment.copy() - if "_links" in result: - webui_path = result["_links"].get("webui", "") - download_path = result["_links"].get("download", "") - result["url"] = f"{base_url}{webui_path}" - result["download_link"] = f"{base_url}{download_path}" - del result["_links"] - del result["webuiLink"] - del result["downloadLink"] - del result["version"] - attachments.append(result) - - return {"attachments": attachments, "pagination_token": pagination_token} - - def prepare_update_page_payload( - self, - page_id: str, - status: str, - title: str, - body_representation: str, - body_value: str, - version_number: int, - version_message: str, - ) -> dict[str, Any]: - """Prepare a payload for the PUT /pages/{id} endpoint.""" - return { - "id": page_id, - "status": status, - "title": title, - "body": { - "representation": body_representation, - "value": body_value, - }, - "version": { - "number": version_number, - "message": version_message, - }, - } - - def prepare_update_page_content_payload( - self, - content: str, - update_mode: PageUpdateMode, - old_content: str, - page_id: str, - status: str, - title: str, - body_representation: BodyFormat, - old_version_number: int, - ) -> dict[str, Any]: - """Prepare a payload for when updating the content of a page - - Args: - content: The content to update the page with - update_mode: The mode of update to use - old_content: The content of the page before the update - page_id: The ID of the page to update - status: The status of the page - title: The title of the page - body_representation: The format that the body (content) is in - old_version_number: The version number of the page before the update - - Returns: - A payload for the PUT /pages/{id} endpoint's json body - """ - updated_content = "" - updated_message = "" - if update_mode == PageUpdateMode.APPEND: - updated_content = f"{old_content}
{content}" - updated_message = "Append content to the page" - elif update_mode == PageUpdateMode.PREPEND: - updated_content = f"{content}
{old_content}" - updated_message = "Prepend content to the page" - elif update_mode == PageUpdateMode.REPLACE: - updated_content = content - updated_message = "Replace the page content" - payload = self.prepare_update_page_payload( - page_id=page_id, - status=status, - title=title, - body_representation=body_representation.to_api_value(), - body_value=updated_content, - version_number=old_version_number + 1, - version_message=updated_message, - ) - return payload - - async def get_root_pages_in_space(self, space_id: str) -> dict[str, Any]: - """ - Get the root pages in a space. - - Requires Confluence scope 'read:page:confluence' - """ - params = { - "depth": "root", - "limit": 250, - } - pages = await self.get(f"spaces/{space_id}/pages", params=params) - base_url = pages.get("_links", {}).get("base", "") - return {"pages": [self._transform_links(page, base_url) for page in pages["results"]]} - - async def get_space_homepage(self, space_id: str) -> dict[str, Any]: - """ - Get the homepage of a space. - - Requires Confluence scope 'read:page:confluence' - """ - root_pages = await self.get_root_pages_in_space(space_id) - for page in root_pages["pages"]: - if page.get("url", "").endswith("overview"): - return self._transform_links(page) - raise ToolExecutionError(message="No homepage found for space.") - - async def get_page_by_id( - self, page_id: str, content_format: BodyFormat = BodyFormat.STORAGE - ) -> dict[str, Any]: - """Get a page by its ID. - - Requires Confluence scope 'read:page:confluence' - - Args: - page_id: The ID of the page to get - content_format: The format of the page content - - Returns: - The page object - """ - params = remove_none_values({ - "body-format": content_format.to_api_value(), - }) - try: - page = await self.get(f"pages/{page_id}", params=params) - except httpx.HTTPStatusError as e: - # If the page is not found, return an empty page object - if e.response.status_code in [400, 404]: - return self.transform_page_response({}) - raise - - return self.transform_page_response(page) - - async def get_page_by_title( - self, page_title: str, content_format: BodyFormat = BodyFormat.STORAGE - ) -> dict[str, Any]: - """Get a page by its title. - - Requires Confluence scope 'read:page:confluence' - - Args: - page_title: The title of the page to get - content_format: The format of the page content - - Returns: - The page object - """ - params = { - "title": page_title, - "body-format": content_format.to_api_value(), - } - response = await self.get("pages", params=params) - pages = response.get("results", []) - if not pages: - # If the page is not found, return an empty page object - return self.transform_page_response({}) - return self.transform_page_response(pages[0]) - - async def get_space_by_id(self, space_id: str) -> dict[str, Any]: - """Get a space by its ID. - - Requires Confluence scope 'read:space:confluence' - - Args: - space_id: The ID of the space to get - - Returns: - The space object - """ - space = await self.get(f"spaces/{space_id}") - return self.transform_space_response(space) - - async def get_space_by_key(self, space_key: str) -> dict[str, Any]: - """Get a space by its key. - - Requires Confluence scope 'read:space:confluence' - - Args: - space_key: The key of the space to get - - Returns: - The space object - """ - response = await self.get("spaces", params={"keys": [space_key]}) - base_url = response.get("_links", {}).get("base", "") - spaces = response.get("results", []) - if not spaces: - raise ToolExecutionError(message=f"No space found with key: '{space_key}'") - return self.transform_space_response(spaces[0], base_url=base_url) - - async def get_space(self, space_identifier: str) -> dict[str, Any]: - """Get a space from its identifier. - - Requires Confluence scope 'read:space:confluence' - - Args: - space_identifier: The identifier of the space to get. Can be a space's ID or key. - """ - return ( - await self.get_space_by_id(space_identifier) - if space_identifier.isdigit() - else await self.get_space_by_key(space_identifier) - ) - - async def get_page_id(self, page_identifier: str) -> str: - """Get the ID of a page from its identifier. - - Args: - page_identifier: The identifier of the page to get. Can be a page's ID or title. - - Returns: - The ID of the page - """ - if page_identifier.isdigit(): - page_id = page_identifier - else: - page = await self.get_page_by_title(page_identifier) - page_id = page.get("page", {}).get("id") - - if not page_id: - raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'") - - return page_id - - async def get_space_id(self, space_identifier: str) -> str: - """Get the ID of a space from its identifier. - - Args: - space_identifier: The identifier of the space to get. Can be a space's ID or title. - """ - if space_identifier.isdigit(): - space_id = space_identifier - else: - space = await self.get_space_by_key(space_identifier) - space_id = space.get("space", {}).get("id") - return space_id - - def create_space_tree(self, space: dict) -> dict: - """Create the initial tree structure for a space. - - Args: - space: The transformed space object - - Returns: - A dictionary representing the root of the space hierarchy tree without any children - """ - space_internal = space.get("space", {}) - return { - "key": space_internal.get("key"), - "id": space_internal.get("id"), - "type": "space", - "url": space_internal.get("url"), - "children": [], - } - - def convert_root_pages_to_tree_nodes(self, pages: list) -> list: - """Convert root pages of a space to tree nodes. - - Args: - pages: List of page objects from the API - - Returns: - A list of tree nodes representing the root pages - """ - return [ - { - "title": page.get("title"), - "id": page.get("id"), - "type": "page", - "url": page.get("url"), - "children": [], - } - for page in pages - ] - - async def process_page_descendants(self, root_children: list, base_url: str) -> None: - """Process descendants for each page and build the hierarchy. - - Args: - root_children: The root children of the space - base_url: The base URL for the Confluence space - - Returns: - None (modifies root_children in place) - """ - descendent_params = {"limit": 250, "depth": 5} - - for i, child in enumerate(root_children): - page_id = child["id"] - descendants = await self.get(f"pages/{page_id}/descendants", params=descendent_params) - - # Transform descendants into our desired format - transformed_children = [] - for descendant in descendants.get("results", []): - transformed_child = { - "title": descendant.get("title"), - "id": descendant.get("id"), - "type": descendant.get("type"), - "parent_id": page_id - if descendant.get("parentId") is None - else descendant.get("parentId"), - "parent_type": descendant.get("parentType", "TODO"), - "url": build_child_url(base_url, descendant), - "children": [], - "status": descendant.get("status"), - } - transformed_children.append(transformed_child) - - # Build the hierarchy for the current root page - build_hierarchy(transformed_children, page_id, root_children[i]) diff --git a/toolkits/confluence/arcade_confluence/enums.py b/toolkits/confluence/arcade_confluence/enums.py deleted file mode 100644 index aeb6ad04..00000000 --- a/toolkits/confluence/arcade_confluence/enums.py +++ /dev/null @@ -1,63 +0,0 @@ -from enum import Enum - - -class BodyFormat(str, Enum): - STORAGE = "storage" # Storage representation for editing, with relative urls in the markup - - def to_api_value(self) -> str: - mapping = { - BodyFormat.STORAGE: "storage", - } - return mapping.get(self, BodyFormat.STORAGE.value) - - -class PageUpdateMode(str, Enum): - """The mode of update for a page""" - - PREPEND = "prepend" # Add content to the beginning of the page. - APPEND = "append" # Add content to the end of the page. - REPLACE = "replace" # Replace the entire page with the new content. - - -class PageSortOrder(str, Enum): - """The order of the pages to sort by""" - - ID_ASCENDING = "id-ascending" - ID_DESCENDING = "id-descending" - TITLE_ASCENDING = "title-ascending" - TITLE_DESCENDING = "title-descending" - CREATED_DATE_ASCENDING = "created-date-oldest-to-newest" - CREATED_DATE_DESCENDING = "created-date-newest-to-oldest" - MODIFIED_DATE_ASCENDING = "modified-date-oldest-to-newest" - MODIFIED_DATE_DESCENDING = "modified-date-newest-to-oldest" - - def to_api_value(self) -> str: - mapping = { - PageSortOrder.ID_ASCENDING: "id", - PageSortOrder.ID_DESCENDING: "-id", - PageSortOrder.TITLE_ASCENDING: "title", - PageSortOrder.TITLE_DESCENDING: "-title", - PageSortOrder.CREATED_DATE_ASCENDING: "created-date", - PageSortOrder.CREATED_DATE_DESCENDING: "-created-date", - PageSortOrder.MODIFIED_DATE_ASCENDING: "modified-date", - PageSortOrder.MODIFIED_DATE_DESCENDING: "-modified-date", - } - return mapping.get(self, PageSortOrder.CREATED_DATE_DESCENDING.value) - - -class AttachmentSortOrder(str, Enum): - """The order of the attachments to sort by""" - - CREATED_DATE_ASCENDING = "created-date-oldest-to-newest" - CREATED_DATE_DESCENDING = "created-date-newest-to-oldest" - MODIFIED_DATE_ASCENDING = "modified-date-oldest-to-newest" - MODIFIED_DATE_DESCENDING = "modified-date-newest-to-oldest" - - def to_api_value(self) -> str: - mapping = { - AttachmentSortOrder.CREATED_DATE_ASCENDING: "created-date", - AttachmentSortOrder.CREATED_DATE_DESCENDING: "-created-date", - AttachmentSortOrder.MODIFIED_DATE_ASCENDING: "modified-date", - AttachmentSortOrder.MODIFIED_DATE_DESCENDING: "-modified-date", - } - return mapping.get(self, AttachmentSortOrder.CREATED_DATE_DESCENDING.value) diff --git a/toolkits/confluence/arcade_confluence/tools/__init__.py b/toolkits/confluence/arcade_confluence/tools/__init__.py deleted file mode 100644 index d9e79dcf..00000000 --- a/toolkits/confluence/arcade_confluence/tools/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from arcade_confluence.tools.attachment import get_attachments_for_page, list_attachments -from arcade_confluence.tools.page import ( - create_page, - get_page, - get_pages_by_id, - list_pages, - rename_page, - update_page_content, -) -from arcade_confluence.tools.search import search_content -from arcade_confluence.tools.space import get_space, get_space_hierarchy, list_spaces - -__all__ = [ - # Attachment - "get_attachments_for_page", - "list_attachments", - # Page - "create_page", - "get_pages_by_id", - "get_page", - "list_pages", - "rename_page", - "update_page_content", - # Search - "search_content", - # Space - "get_space", - "get_space_hierarchy", - "list_spaces", -] diff --git a/toolkits/confluence/arcade_confluence/tools/attachment.py b/toolkits/confluence/arcade_confluence/tools/attachment.py deleted file mode 100644 index 7d4f6466..00000000 --- a/toolkits/confluence/arcade_confluence/tools/attachment.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_confluence.client import ConfluenceClientV2 -from arcade_confluence.enums import AttachmentSortOrder -from arcade_confluence.utils import remove_none_values - - -@tool( - requires_auth=Atlassian( - scopes=["read:attachment:confluence"], - ) -) -async def list_attachments( - context: ToolContext, - sort_order: Annotated[ - AttachmentSortOrder, - "The order of the attachments to sort by. Defaults to created-date-newest-to-oldest", - ] = AttachmentSortOrder.CREATED_DATE_DESCENDING, - limit: Annotated[ - int, "The maximum number of attachments to return. Defaults to 25. Max is 250" - ] = 25, - pagination_token: Annotated[ - str | None, - "The pagination token to use for the next page of results", - ] = None, -) -> Annotated[dict, "The attachments"]: - """List attachments in a workspace""" - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - params = remove_none_values({ - "sort": sort_order.to_api_value(), - "limit": max(1, min(limit, 250)), - "cursor": pagination_token, - }) - attachments = await client.get("attachments", params=params) - return client.transform_get_attachments_response(attachments) - - -@tool( - requires_auth=Atlassian( - scopes=["read:attachment:confluence"], - ) -) -async def get_attachments_for_page( - context: ToolContext, - page_identifier: Annotated[str, "The ID or title of the page to get attachments for"], - limit: Annotated[ - int, "The maximum number of attachments to return. Defaults to 25. Max is 250" - ] = 25, - pagination_token: Annotated[ - str | None, - "The pagination token to use for the next page of results", - ] = None, -) -> Annotated[dict, "The attachments"]: - """Get attachments for a page by its ID or title. - - If a page title is provided, then the first page with an exact matching title will be returned. - """ - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - page_id = await client.get_page_id(page_identifier) - - params = remove_none_values({ - "limit": max(1, min(limit, 250)), - "cursor": pagination_token, - }) - attachments = await client.get(f"pages/{page_id}/attachments", params=params) - return client.transform_get_attachments_response(attachments) diff --git a/toolkits/confluence/arcade_confluence/tools/page.py b/toolkits/confluence/arcade_confluence/tools/page.py deleted file mode 100644 index a0758001..00000000 --- a/toolkits/confluence/arcade_confluence/tools/page.py +++ /dev/null @@ -1,236 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian -from arcade_tdk.errors import ToolExecutionError - -from arcade_confluence.client import ConfluenceClientV2 -from arcade_confluence.enums import BodyFormat, PageSortOrder, PageUpdateMode -from arcade_confluence.utils import remove_none_values, validate_ids - - -@tool( - requires_auth=Atlassian( - scopes=["read:page:confluence", "write:page:confluence"], - ) -) -async def create_page( - context: ToolContext, - space_identifier: Annotated[str, "The ID or title of the space to create the page in"], - title: Annotated[str, "The title of the page"], - content: Annotated[str, "The content of the page. Only plain text is supported"], - parent_id: Annotated[ - str | None, - "The ID of the parent. If not provided, the page will be created at the root of the space.", - ] = None, - is_private: Annotated[ - bool, - "If true, then only the user who creates this page will be able to see it. " - "Defaults to False", - ] = False, - is_draft: Annotated[ - bool, - "If true, then the page will be created as a draft. Defaults to False", - ] = False, -) -> Annotated[dict, "The page"]: - """Create a new page at the root of the given space.""" - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - space_id = await client.get_space_id(space_identifier) - - parent_id = parent_id or (await client.get_space_homepage(space_id)).get("id") - params = remove_none_values({ - "root-level": False, - "private": is_private, - }) - - body = remove_none_values({ - "spaceId": space_id, - "status": "draft" if is_draft else None, - "parentId": parent_id, - "title": title, - "body": { - "storage": { - "value": content, - "representation": BodyFormat.STORAGE.to_api_value(), - } - }, - }) - page = await client.post("pages", params=params, json=body) - return client.transform_page_response(page) - - -@tool( - requires_auth=Atlassian( - scopes=["read:page:confluence", "write:page:confluence"], - ) -) -async def update_page_content( - context: ToolContext, - page_identifier: Annotated[ - str, "The ID or title of the page to update. Numerical titles are NOT supported." - ], - content: Annotated[str, "The content of the page. Only plain text is supported"], - update_mode: Annotated[ - PageUpdateMode, - "The mode of update. Defaults to 'append'.", - ] = PageUpdateMode.APPEND, -) -> Annotated[dict, "The page"]: - """Update a page's content.""" - # Get the page to update - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - page_id = await client.get_page_id(page_identifier) - - page = await get_page(context, page_id) - if not page.get("page"): - raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'") - status = page.get("page", {}).get("status", "current") - title = page.get("page", {}).get("title", "Untitled page") - body = page.get("page", {}).get("body", {}) - old_content = body.get(BodyFormat.STORAGE, {}).get("value", "") - old_version_number = page.get("page", {}).get("version", {}).get("number", 0) - - # Update the page content - payload = client.prepare_update_page_content_payload( - content=content, - update_mode=update_mode, - old_content=old_content, - page_id=page_id, - status=status, - title=title, - body_representation=BodyFormat.STORAGE, - old_version_number=old_version_number, - ) - updated_page = await client.put(f"pages/{page_id}", json=payload) - - return client.transform_page_response(updated_page) - - -@tool( - requires_auth=Atlassian( - scopes=["read:page:confluence", "write:page:confluence"], - ) -) -async def rename_page( - context: ToolContext, - page_identifier: Annotated[ - str, "The ID or title of the page to rename. Numerical titles are NOT supported." - ], - title: Annotated[str, "The title of the page"], -) -> Annotated[dict, "The page"]: - """Rename a page by changing its title.""" - # Get the page to rename - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - page_id = await client.get_page_id(page_identifier) - - page = await get_page(context, page_id) - if not page.get("page"): - raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'") - status = page.get("page", {}).get("status", "current") - content = page.get("page", {}).get("body", {}).get(BodyFormat.STORAGE, {}).get("value", "") - old_version_number = page.get("page", {}).get("version", {}).get("number", 0) - - # Rename the page - payload = client.prepare_update_page_payload( - page_id=page_id, - status=status, - title=title, - body_representation=BodyFormat.STORAGE, - body_value=content, - version_number=old_version_number + 1, - version_message="Rename the page", - ) - updated_page = await client.put(f"pages/{page_id}", json=payload) - - return client.transform_page_response(updated_page) - - -@tool( - requires_auth=Atlassian( - scopes=["read:page:confluence"], - ) -) -async def get_page( - context: ToolContext, - page_identifier: Annotated[ - str, "Can be a page's ID or title. Numerical titles are NOT supported." - ], -) -> Annotated[dict, "The page"]: - """Retrieve a SINGLE page's content by its ID or title. - - If a title is provided, then the first page with an exact matching title will be returned. - - IMPORTANT: For retrieving MULTIPLE pages, use `get_pages_by_id` instead - for a massive performance and efficiency boost. If you call this function multiple times - instead of using `get_pages_by_id`, then the universe will explode. - """ - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - if page_identifier.isdigit(): - return await client.get_page_by_id(page_identifier, BodyFormat.STORAGE) - else: - return await client.get_page_by_title(page_identifier, BodyFormat.STORAGE) - - -@tool( - requires_auth=Atlassian( - scopes=["read:page:confluence"], - ) -) -async def get_pages_by_id( - context: ToolContext, - page_ids: Annotated[ - list[str], - "The IDs of the pages to get. IDs are numeric. Titles of pages are NOT supported. " - "Maximum of 250 page ids supported.", - ], -) -> Annotated[dict, "The pages"]: - """Get the content of MULTIPLE pages by their ID in a single efficient request. - - IMPORTANT: Always use this function when you need to retrieve content from more than one page, - rather than making multiple separate calls to get_page, because this function is significantly - more efficient than calling get_page multiple times. - """ - validate_ids(page_ids, max_length=250) - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - pages = await client.get( - "pages", params={"id": page_ids, "body-format": BodyFormat.STORAGE.to_api_value()} - ) - return client.transform_get_multiple_pages_response(pages) - - -@tool( - requires_auth=Atlassian( - scopes=["read:page:confluence"], - ) -) -async def list_pages( - context: ToolContext, - space_ids: Annotated[ - list[str] | None, - "Restrict the response to only include pages in these spaces. " - "Only space IDs are supported. Titles of spaces are NOT supported. " - "If not provided, then no restriction is applied. " - "Maximum of 100 space ids supported.", - ] = None, - sort_by: Annotated[ - PageSortOrder, - "The order of the pages to sort by. Defaults to created-date-newest-to-oldest", - ] = PageSortOrder.CREATED_DATE_DESCENDING, - limit: Annotated[int, "The maximum number of pages to return. Defaults to 25. Max is 250"] = 25, - pagination_token: Annotated[ - str | None, - "The pagination token to use for the next page of results", - ] = None, -) -> Annotated[dict, "The pages"]: - """Get the content of multiple pages by their ID""" - validate_ids(space_ids, max_length=100) - limit = max(1, min(limit, 250)) - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - params = remove_none_values({ - "space-id": space_ids, - "sort": sort_by.to_api_value(), - "body-format": BodyFormat.STORAGE.to_api_value(), - "limit": limit, - "cursor": pagination_token, - }) - pages = await client.get("pages", params=params) - return client.transform_list_pages_response(pages) diff --git a/toolkits/confluence/arcade_confluence/tools/search.py b/toolkits/confluence/arcade_confluence/tools/search.py deleted file mode 100644 index 478199ea..00000000 --- a/toolkits/confluence/arcade_confluence/tools/search.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_confluence.client import ConfluenceClientV1 - - -@tool( - requires_auth=Atlassian( - scopes=["search:confluence"], - ) -) -async def search_content( - context: ToolContext, - must_contain_all: Annotated[ - list[str] | None, - "Words/phrases that content MUST contain (AND logic). Each item can be:\n" - "- Single word: 'banana' - content must contain this word\n" - "- Multi-word phrase: 'How to' - content must contain all these words (in any order)\n" - "- All items in this list must be present for content to match\n" - "- Example: ['banana', 'apple'] finds content containing BOTH 'banana' AND 'apple'", - ] = None, - can_contain_any: Annotated[ - list[str] | None, - "Words/phrases where content can contain ANY of these (OR logic). Each item can be:\n" - "- Single word: 'project' - content containing this word will match\n" - "- Multi-word phrase: 'pen & paper' - content containing all these words will match\n" - "- Content matching ANY item in this list will be included\n" - "- Example: ['project', 'documentation'] finds content with 'project' OR 'documentation'", - ] = None, - enable_fuzzy: Annotated[ - bool, - "Enable fuzzy matching to find similar terms (e.g. 'roam' will find 'foam'). " - "Defaults to True", - ] = True, - limit: Annotated[int, "Maximum number of results to return (1-100). Defaults to 25"] = 25, -) -> Annotated[dict, "Search results containing content items matching the criteria"]: - """Search for content in Confluence. - - The search is performed across all content in the authenticated user's Confluence workspace. - All search terms in Confluence are case insensitive. - - You can use the parameters in different ways: - - must_contain_all: For AND logic - content must contain ALL of these - - can_contain_any: For OR logic - content can contain ANY of these - - Combine them: must_contain_all=['banana'] AND can_contain_any=['database', 'guide'] - """ - client = ConfluenceClientV1(context.get_auth_token_or_empty()) - cql = client.construct_cql(must_contain_all, can_contain_any, enable_fuzzy) - response = await client.get("search", params={"cql": cql, "limit": max(1, min(limit, 100))}) - - return client.transform_search_content_response(response) diff --git a/toolkits/confluence/arcade_confluence/tools/space.py b/toolkits/confluence/arcade_confluence/tools/space.py deleted file mode 100644 index 1d0ec0bc..00000000 --- a/toolkits/confluence/arcade_confluence/tools/space.py +++ /dev/null @@ -1,95 +0,0 @@ -import re -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_confluence.client import ConfluenceClientV2 -from arcade_confluence.utils import remove_none_values - - -@tool( - requires_auth=Atlassian( - scopes=["read:space:confluence"], - ) -) -async def get_space( - context: ToolContext, - space_identifier: Annotated[ - str, "Can be a space's ID or key. Numerical keys are NOT supported" - ], -) -> Annotated[dict, "The space"]: - """Get the details of a space by its ID or key.""" - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - if space_identifier.isdigit(): - return await client.get_space_by_id(space_identifier) - else: - return await client.get_space_by_key(space_identifier) - - -@tool( - requires_auth=Atlassian( - scopes=["read:space:confluence"], - ) -) -async def list_spaces( - context: ToolContext, - limit: Annotated[ - int, "The maximum number of spaces to return. Defaults to 25. Max is 250" - ] = 25, - pagination_token: Annotated[ - str | None, "The pagination token to use for the next page of results" - ] = None, -) -> Annotated[dict, "The spaces"]: - """List all spaces sorted by name in ascending order.""" - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - params = {"limit": max(1, min(limit, 250)), "sort": "name", "cursor": pagination_token} - params = remove_none_values(params) - spaces = await client.get("spaces", params=params) - return client.transform_get_spaces_response(spaces) - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:page:confluence", # needed for getting the space's root pages - "read:space:confluence", # needed for when a space key is provided - "read:hierarchical-content:confluence", # needed for getting the descendents of a page - ], - ) -) -async def get_space_hierarchy( - context: ToolContext, - space_identifier: Annotated[ - str, "Can be a space's ID or key. Numerical keys are NOT supported" - ], -) -> Annotated[dict, "The space hierarchy"]: - """Retrieve the full hierarchical structure of a Confluence space as a tree structure - - Only structural metadata is returned (not content). - The response is akin to the sidebar in the Confluence UI. - - Includes all pages, folders, whiteboards, databases, - smart links, etc. organized by parent-child relationships. - """ - client = ConfluenceClientV2(context.get_auth_token_or_empty()) - - space = await client.get_space(space_identifier) - tree = client.create_space_tree(space) - - # Get root pages - root_pages = await client.get_root_pages_in_space(space["space"]["id"]) - tree["children"] = client.convert_root_pages_to_tree_nodes(root_pages["pages"]) - - if not tree["children"]: - return {} - - # Extract base URL for children URLs. The base URL is the space's URL. - root_page_url = tree["url"] - match = re.match(r"(.*?/spaces/[^/]+)", root_page_url) - children_base_url = match.group(1) if match else "" - - # Get and descendants for each root page - await client.process_page_descendants(tree["children"], children_base_url) - - return tree diff --git a/toolkits/confluence/arcade_confluence/utils.py b/toolkits/confluence/arcade_confluence/utils.py deleted file mode 100644 index 349f41d9..00000000 --- a/toolkits/confluence/arcade_confluence/utils.py +++ /dev/null @@ -1,89 +0,0 @@ -import re - -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - - -def remove_none_values(data: dict) -> dict: - """Remove all keys with None values from the dictionary.""" - return {k: v for k, v in data.items() if v is not None} - - -def validate_ids(ids: list[str] | None, max_length: int) -> None: - """Validate a list of IDs. The ids can be page ids, space ids, etc. - - A valid id is a string that is a number. - - Args: - ids: A list of IDs to validate. - - Returns: - None - - Raises: - ToolExecutionError: If any of the IDs are not valid. - RetryableToolError: If the number of IDs is greater than the max length. - """ - if not ids: - return - if len(ids) > max_length: - raise RetryableToolError( - message=f"The 'ids' parameter must have less than {max_length} items. Got {len(ids)}" - ) - if any(not id_.isdigit() for id_ in ids): - raise ToolExecutionError(message="Invalid ID provided. IDs are numeric") - - -def build_child_url(base_url: str, child: dict) -> str | None: - """Build URL for a child node based on its type and status. - - Args: - base_url: The base URL for the Confluence space - child: A dictionary representing a Confluence content item - - Returns: - The URL for the child, or None if it can't be determined - """ - if child["type"] in ("whiteboard", "database", "embed"): - return f"{base_url}/{child['type']}/{child['id']}" - elif child["type"] == "folder": - return None - elif child["type"] == "page": - parsed_title = re.sub(r"[ '\s]+", "+", child["title"].strip()) - if child.get("status") == "draft": - return f"{base_url}/{child['type']}s/edit-v2/{child['id']}" - else: - return f"{base_url}/{child['type']}s/{child['id']}/{parsed_title}" - return None - - -def build_hierarchy(transformed_children: list, parent_id: str, parent_node: dict) -> None: - """Build parent-child hierarchy from a flat list of descendants. - - This function takes a flat list of items that have parent_id references and - builds a hierarchical tree structure. It modifies the parent_node in place. - - Args: - transformed_children: List of child nodes with parent_id fields - parent_id: The ID of the parent node - parent_node: The parent node to attach direct children to - - Returns: - None (modifies parent_node in place) - """ - # Create a map of children by their ID for efficient lookups - child_map = {child["id"]: child for child in transformed_children} - - # Find all direct children of the given parent_id - direct_children = [] - for child in transformed_children: - if child.get("parent_id") == parent_id: - direct_children.append(child) - elif child.get("parent_id") in child_map: - # Add child to its parent's children list - parent = child_map[child.get("parent_id")] - if "children" not in parent: - parent["children"] = [] - parent["children"].append(child) - - # Set the direct children on the parent node - parent_node["children"] = direct_children diff --git a/toolkits/confluence/evals/conversations.py b/toolkits/confluence/evals/conversations.py deleted file mode 100644 index 3cab3ad1..00000000 --- a/toolkits/confluence/evals/conversations.py +++ /dev/null @@ -1,89 +0,0 @@ -# A conversation where a user asks for the structure of a space, -# and an LLM calls a tool to get the structure. -get_space_hierarchy_1 = [ - {"role": "system", "content": "Today is 2025-05-16, Friday."}, - {"role": "user", "content": "get structure of 98308"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_exyK4LmJEHSDn1Xw5oVfS9Xx", - "type": "function", - "function": { - "name": "Confluence_GetSpaceHierarchy", - "arguments": '{"space_identifier":"98308"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"98418":{"children":[{"children":[],"id":"4653060","parent_id":"98418","parent_type":"TODO","title":"Mydb","type":"database","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/database/4653060"},{"children":[{"children":[],"id":"4685837","parent_id":"4653064","parent_type":"TODO","title":"10-03-98","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/4685837/10-03-98"}],"id":"4653064","parent_id":"98418","parent_type":"TODO","title":"DailyNotes","type":"folder","url":null},{"children":[{"children":[],"id":"5242883","parent_id":"5275650","parent_type":"TODO","title":"To be or not tobe","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242883/To+be+or+not+to+be"},{"children":[],"id":"5275653","parent_id":"5275650","parent_type":"TODO","title":"Two roadsdiverged","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/edit-v2/5275653"},{"children":[],"id":"5242903","parent_id":"5275650","parent_type":"TODO","title":"Eternity","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242903/Eternity"},{"children":[],"id":"5242913","parent_id":"5275650","parent_type":"TODO","title":"BlackHole","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242913/Black+Hole"}],"id":"5275650","parent_id":"98418","parent_type":"TODO","title":"Poetry","type":"folder","url":null},{"children":[],"id":"5275676","parent_id":"98418","parent_type":"TODO","title":"Drawings","type":"whiteboard","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/whiteboard/5275676"}],"id":"98418","parent_id":null,"parent_type":null,"title":"testspace Home","type":"page","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/overview"}}', # noqa: E501 - "tool_call_id": "call_exyK4LmJEHSDn1Xw5oVfS9Xx", - "name": "Confluence_GetSpaceHierarchy", - }, - { - "role": "assistant", - "content": 'Here is the structure for the space with the identifier 98308:\n\n- **testspace Home** [Page](https://ericconfluence.atlassian.net/wiki/spaces/testspace/overview)\n - **My db** [Database](https://ericconfluence.atlassian.net/wiki/spaces/testspace/database/4653060)\n - **Daily Notes** (Folder)\n - [10-03-98](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/4685837/10-03-98) (Page)\n - **Poetry** (Folder)\n - [To be or not to be](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242883/To+be+or+not+to+be) (Page)\n - [Two roads diverged](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/edit-v2/5275653) (Page)\n - [Eternity](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242903/Eternity) (Page)\n - [Black Hole](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5242913/Black+Hole) (Page)\n - **Drawings** [Whiteboard](https://ericconfluence.atlassian.net/wiki/spaces/testspace/whiteboard/5275676)\n\nThis structure includes various elements such as pages, a database, folders, and a whiteboard within the "testspace" space.', # noqa: E501 - }, -] - -# A conversation where a user asks the assistant to create a page with content -create_page_1 = [ - { - "role": "user", - "content": "make a new page within the testspace space under the 4653064 folder. Within it, make a list of all the TODOs for the day which are 1. Write a blog 2. Walk the dog", # noqa: E501 - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_xuvOuXpLAmX57RO1zj5hqQ1r", - "type": "function", - "function": { - "name": "Confluence_CreatePage", - "arguments": '{"space_identifier":"testspace","title":"TODOs forToday","content":"

TODOs for Today

  1. Write a blog
  2. Walk thedog
","parent_id":"4653064"}', # noqa: E501 - }, - } - ], - }, - { - "role": "tool", - "content": '{"page":{"authorId":"712020:93b309a8-e9af-4615-b289-07bf23e6f13f","body":{"storage":{"representation":"storage","value":"\\u003ch1\\u003eTODOs for Today\\u003c/h1\\u003e\\u003col\\u003e\\u003cli\\u003eWrite a blog\\u003c/li\\u003e\\u003cli\\u003eWalk the dog\\u003c/li\\u003e\\u003c/ol\\u003e"}},"createdAt":"2025-05-16T22:29:59.969Z","id":"5439489","lastOwnerId":null,"ownerId":"712020:93b309a8-e9af-4615-b289-07bf23e6f13f","parentId":"4653064","parentType":"folder","position":1675,"spaceId":"98308","status":"current","title":"TODOs for Today","url":"https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5439489/TODOs+for+Today","version":{"authorId":"712020:93b309a8-e9af-4615-b289-07bf23e6f13f","createdAt":"2025-05-16T22:29:59.969Z","message":"","minorEdit":false,"ncsStepVersion":"1","number":1}}}', # noqa: E501 - "tool_call_id": "call_xuvOuXpLAmX57RO1zj5hqQ1r", - "name": "Confluence_CreatePage", - }, - { - "role": "assistant", - "content": 'I have created a new page titled "TODOs for Today" in the "testspace" space, under the folder with ID 4653064. The list of your tasks for the day is included:\n\n1. Write a blog\n2. Walk the dog\n\nYou can view the page [here](https://ericconfluence.atlassian.net/wiki/spaces/testspace/pages/5439489/TODOs+for+Today).', # noqa: E501 - }, -] - -# A conversation where a user asks the assistant to list one space. -list_spaces_1 = [ - {"role": "system", "content": "Today is 2025-05-19, Monday."}, - {"role": "user", "content": "get 1 space"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_ur97pVeXzdB7ECNrr0zQBJmE", - "type": "function", - "function": {"name": "Confluence_ListSpaces", "arguments": '{"limit":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"pagination_token":"eyJpZCI6MjMyNjc5NCwic3BhY2VTb3J0T3JkZXIiOnsiZmllbGQiOiJOQU1FIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn0sInNwYWNlU29ydE9yZGVyVmFsdWUiOiJlcmljYXJjYWRlIn0=","spaces":[{"authorId":"712020:f889c42e-f53e-4500-a7c6-706c3ef10951","createdAt":"2025-05-12T21:52:24.230Z","currentActiveAlias":"~712020f889c42ef53e4500a7c6706c3ef10951","description":null,"homepageId":"2327032","icon":null,"id":"2326794","key":"~712020f889c42ef53e4500a7c6706c3ef10951","name":"ericarcade","spaceOwnerId":"712020:f889c42e-f53e-4500-a7c6-706c3ef10951","status":"current","type":"personal","url":"https://ericconfluence.atlassian.net/wiki/spaces/~712020f889c42ef53e4500a7c6706c3ef10951"}]}', - "tool_call_id": "call_ur97pVeXzdB7ECNrr0zQBJmE", - "name": "Confluence_ListSpaces", - }, - { - "role": "assistant", - "content": "Here is one space from Confluence:\n\n- **Name**: ericarcade\n- **Key**: ~712020f889c42ef53e4500a7c6706c3ef10951\n- **Type**:Personal\n- **Status**: Current\n- **Author ID**: 712020:f889c42e-f53e-4500-a7c6-706c3ef10951\n- **Created At**: 2025-05-12\n- **Homepage ID**:2327032\n- **URL**: [ericarcade Space](https://ericconfluence.atlassian.net/wiki/spaces/~712020f889c42ef53e4500a7c6706c3ef10951)", # noqa: E501 - }, -] diff --git a/toolkits/confluence/evals/critics.py b/toolkits/confluence/evals/critics.py deleted file mode 100644 index f2c90a75..00000000 --- a/toolkits/confluence/evals/critics.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from arcade_evals.critic import Critic - - -@dataclass -class ListCritic(Critic): - """ - A critic for comparing two lists. - """ - - def __init__( - self, - critic_field: str, - weight: float = 1.0, - order_matters: bool = True, - duplicates_matter: bool = True, - case_sensitive: bool = False, - ): - self.critic_field = critic_field - self.weight = weight - self.order_matters = order_matters - self.duplicates_matter = duplicates_matter - self.case_sensitive = case_sensitive - - def evaluate(self, expected: list[Any], actual: list[Any]) -> dict[str, float | bool]: - if not self.case_sensitive: - actual = [item.lower() for item in actual] - expected = [item.lower() for item in expected] - - match = actual == expected if self.order_matters else set(actual) == set(expected) - if self.duplicates_matter: - match = match and len(actual) == len(expected) - - return {"match": match, "score": self.weight if match else 0.0} diff --git a/toolkits/confluence/evals/eval_page.py b/toolkits/confluence/evals/eval_page.py deleted file mode 100644 index ef46c264..00000000 --- a/toolkits/confluence/evals/eval_page.py +++ /dev/null @@ -1,208 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_confluence -from arcade_confluence.enums import PageUpdateMode -from arcade_confluence.tools import ( - create_page, - get_page, - get_pages_by_id, - rename_page, - update_page_content, -) -from evals.conversations import create_page_1, get_space_hierarchy_1 -from evals.critics import ListCritic - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_confluence) - - -@tool_eval() -def confluence_get_page_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence get_page tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get page by ID", - user_message="Get page 65816", - expected_tool_calls=[ExpectedToolCall(func=get_page, args={"page_identifier": 65816})], - rubric=rubric, - critics=[BinaryCritic(critic_field="page_identifier", weight=1)], - ) - - suite.add_case( - name="Get page by title", - user_message="Get my 'Poem - May 24th' page", - expected_tool_calls=[ - ExpectedToolCall(func=get_page, args={"page_identifier": "Poem - May 24th"}) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="page_identifier", weight=1), - ], - ) - - suite.add_case( - name="Get page based on previous conversation", - user_message=( - "Get the content of my daily note. You MUST use the page's title when getting the page." - ), - expected_tool_calls=[ExpectedToolCall(func=get_page, args={"page_identifier": "10-03-98"})], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="page_identifier", weight=1), - ], - additional_messages=get_space_hierarchy_1, - ) - - return suite - - -@tool_eval() -def confluence_get_multiple_pages_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence get_multiple_pages_by_id tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get multiple pages by ID", - user_message="Get 98418, 4685837, 5242883, 5275653, 5242903, 5242913 pages", - expected_tool_calls=[ - ExpectedToolCall( - func=get_pages_by_id, - args={"page_ids": ["98418", "4685837", "5242883", "5275653", "5242903", "5242913"]}, - ) - ], - rubric=rubric, - critics=[ - ListCritic(critic_field="page_ids", order_matters=False, weight=1), - ], - ) - - suite.add_case( - name="Get multiple pages by ID with existing conversation", - user_message=("Get the content of all pages in the space except 4685837"), - expected_tool_calls=[ - ExpectedToolCall( - func=get_pages_by_id, - args={"page_ids": ["98418", "5242883", "5275653", "5242903", "5242913"]}, - ) - ], - rubric=rubric, - critics=[ - ListCritic(critic_field="page_ids", order_matters=False, weight=1), - ], - additional_messages=get_space_hierarchy_1, - ) - - return suite - - -@tool_eval() -def confluence_create_page_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence create_page tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create a page", - user_message=( - "Make a page within the softwareeng space called 'TODOs' under the 4830960 folder. " - "Within it, make a list of all the TODOs for the day which are " - "1. Write a blog post about the future of AI" - "2. Write an agent that calls my mom at 5:30 every Friday evening" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_page, - args={ - "space_identifier": "softwareeng", - "title": "TODOs", - "content": "1. Write a blog post about the future of AI\n2. Write an agent that calls my mom at 5:30 every Friday evening", # noqa: E501 - "parent_id": "4830960", - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="space_identifier", weight=1 / 4), - BinaryCritic(critic_field="title", weight=1 / 4), - SimilarityCritic(critic_field="content", weight=1 / 4), - BinaryCritic(critic_field="parent_id", weight=1 / 4), - ], - ) - - return suite - - -@tool_eval() -def confluence_update_page_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence update page tools evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Update page content", - user_message="Thanks, now append '3. Walk the dog' to the end of the list", - expected_tool_calls=[ - ExpectedToolCall( - func=update_page_content, - args={ - "page_identifier": "5439489", - "content": "3. Walk the dog", - "update_mode": PageUpdateMode.APPEND, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="page_identifier", weight=1 / 3), - SimilarityCritic(critic_field="content", weight=1 / 3), - BinaryCritic(critic_field="update_mode", weight=1 / 3), - ], - additional_messages=create_page_1, - ) - - suite.extend_case( - name="Rename page", - user_message="Actually, rename it to 'My TODOs'", - expected_tool_calls=[ - ExpectedToolCall( - func=rename_page, - args={"page_identifier": "5439489", "title": "My TODOs"}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="page_identifier", weight=1 / 2), - BinaryCritic(critic_field="title", weight=1 / 2), - ], - ) - return suite diff --git a/toolkits/confluence/evals/eval_search.py b/toolkits/confluence/evals/eval_search.py deleted file mode 100644 index 7c361fc6..00000000 --- a/toolkits/confluence/evals/eval_search.py +++ /dev/null @@ -1,111 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_confluence -from arcade_confluence.tools import ( - search_content, -) -from evals.critics import ListCritic - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_confluence) - - -@tool_eval() -def confluence_search_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence search content tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search for content - easy case", - user_message="Find all pages that contain 'Arcade.dev'", - expected_tool_calls=[ - ExpectedToolCall(func=search_content, args={"can_contain_any": ["Arcade.dev"]}) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="can_contain_any", weight=1), - ], - ) - - suite.add_case( - name="Search for content - medium case", - user_message=("Find 20 pages that contain 'Arcade' or 'AI', or 'tool calls'"), - expected_tool_calls=[ - ExpectedToolCall( - func=search_content, - args={ - "can_contain_any": ["Arcade", "AI", "tool calls"], - "limit": 20, - }, - ) - ], - rubric=rubric, - critics=[ - ListCritic( - critic_field="can_contain_any", - weight=0.9, - case_sensitive=False, - order_matters=False, - ), - BinaryCritic(critic_field="limit", weight=0.1), - ], - ) - - suite.add_case( - name="Search for content - hard case", - user_message=( - "Look for 25 databases that have 'How to', " - "and also have 'carborator' in the content and " - "also has one of the following: 'money', 'redbull gives you wings', " - "'honey hole', 'don't snap the pasta!'." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=search_content, - args={ - "must_contain_all": ["carborator", "How to"], - "can_contain_any": [ - "money", - "redbull gives you wings", - "honey hole", - "don't snap the pasta!", - ], - }, - ), - ], - rubric=rubric, - critics=[ - ListCritic( - critic_field="must_contain_all", - weight=0.5, - case_sensitive=False, - order_matters=False, - ), - ListCritic( - critic_field="can_contain_any", - weight=0.5, - case_sensitive=False, - order_matters=False, - ), - ], - ) - - return suite diff --git a/toolkits/confluence/evals/eval_space.py b/toolkits/confluence/evals/eval_space.py deleted file mode 100644 index bc07848f..00000000 --- a/toolkits/confluence/evals/eval_space.py +++ /dev/null @@ -1,113 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_confluence -from arcade_confluence.tools import ( - get_space, - get_space_hierarchy, - list_spaces, -) -from evals.conversations import list_spaces_1 - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_confluence) - - -@tool_eval() -def confluence_get_space_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence get_space tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get two spaces - one by ID, and one by key", - user_message="Get spaces 3498573 and 'Poetry'", - expected_tool_calls=[ - ExpectedToolCall(func=get_space, args={"space_identifier": 3498573}), - ExpectedToolCall(func=get_space, args={"space_identifier": "Poetry"}), - ], - rubric=rubric, - critics=[BinaryCritic(critic_field="space_identifier", weight=1)], - ) - - return suite - - -@tool_eval() -def confluence_list_spaces_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence list_spaces tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List the next space using a pagination token", - user_message="get the next one", - expected_tool_calls=[ - ExpectedToolCall( - func=list_spaces, - args={ - "limit": 1, - "pagination_token": "eyJpZCI6MjMyNjc5NCwic3BhY2VTb3J0T3JkZXIiOnsiZmllbGQiOiJOQU1FIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn0sInNwYWNlU29ydE9yZGVyVmFsdWUiOiJlcmljYXJjYWRlIn0=", # noqa: E501 - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.3), - BinaryCritic(critic_field="pagination_token", weight=0.7), - ], - additional_messages=list_spaces_1, - ) - - return suite - - -@tool_eval() -def confluence_get_space_hierarchy_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Confluence get_space_hierarchy tool evaluation", - system_message="You are an AI assistant with access to Confluence tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get the hierarchy of a space", - user_message=( - "What is the best file location within my 'Poetry' space to create a new page " - "named 'Rough Draft - Poem for King Henry VIII'?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_space_hierarchy, - args={ - "space_identifier": "Poetry", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="space_identifier", weight=1), - ], - ) - - return suite diff --git a/toolkits/confluence/pyproject.toml b/toolkits/confluence/pyproject.toml deleted file mode 100644 index 9df2d9c6..00000000 --- a/toolkits/confluence/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_confluence" -version = "0.0.4" -description = "Arcade.dev LLM tools for Confluence" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_confluence/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_confluence",] diff --git a/toolkits/confluence/tests/__init__.py b/toolkits/confluence/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/confluence/tests/test_client_v1.py b/toolkits/confluence/tests/test_client_v1.py deleted file mode 100644 index d6561212..00000000 --- a/toolkits/confluence/tests/test_client_v1.py +++ /dev/null @@ -1,120 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_confluence.client import ConfluenceClientV1 - - -@pytest.fixture -def client_v1() -> ConfluenceClientV1: - """Fixture that provides a ConfluenceClientV1 instance with mocked cloud_id.""" - with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None): - return ConfluenceClientV1("fake-token") - - -@pytest.mark.parametrize( - "query, enable_fuzzy, expected_cql", - [ - ("foo", False, '(text ~ "foo" OR title ~ "foo" OR space.title ~ "foo")'), - ("foo bar", False, '(text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")'), - ("foo", True, '(text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~")'), - ("foo bar", True, '(text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")'), - ], -) -def test_build_query_cql(client_v1: ConfluenceClientV1, query, enable_fuzzy, expected_cql) -> None: - cql = client_v1._build_query_cql(query, enable_fuzzy) - assert cql == expected_cql - - -@pytest.mark.parametrize( - "queries, enable_fuzzy, expected_cql", - [ - ( - ["foo", "foo bar"], - False, - '((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ( - ["foo", "foo bar"], - True, - '((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ], -) -def test_build_and_cql(client_v1: ConfluenceClientV1, queries, enable_fuzzy, expected_cql) -> None: - cql = client_v1._build_and_cql(queries, enable_fuzzy) - assert cql == expected_cql - - -@pytest.mark.parametrize( - "queries, enable_fuzzy, expected_cql", - [ - ( - ["foo", "foo bar"], - False, - '((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ( - ["foo", "foo bar"], - True, - '((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ], -) -def test_build_or_cql(client_v1: ConfluenceClientV1, queries, enable_fuzzy, expected_cql) -> None: - cql = client_v1._build_or_cql(queries, enable_fuzzy) - assert cql == expected_cql - - -@pytest.mark.parametrize( - "must_contain_all, can_contain_any, enable_fuzzy, expected_cql", - [ - (None, None, False, ""), - ( - ["foo", "foo bar"], - None, - False, - '((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ( - ["foo", "foo bar"], - None, - True, - '((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ( - None, - ["foo", "foo bar"], - False, - '((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ( - None, - ["foo", "foo bar"], - True, - '((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar"))', # noqa: E501 - ), - ( - ["foo", "foo bar"], - ["foo", "foo bar"], - False, - '(((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")) AND ((text ~ "foo" OR title ~ "foo" OR space.title ~ "foo") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")))', # noqa: E501 - ), - ( - ["foo", "foo bar"], - ["foo", "foo bar"], - True, - '(((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") AND (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")) AND ((text ~ "foo~" OR title ~ "foo~" OR space.title ~ "foo~") OR (text ~ "foo bar" OR title ~ "foo bar" OR space.title ~ "foo bar")))', # noqa: E501 - ), - ], -) -def test_construct_cql( - client_v1: ConfluenceClientV1, must_contain_all, can_contain_any, enable_fuzzy, expected_cql -) -> None: - if not expected_cql: - with pytest.raises(ToolExecutionError): - client_v1.construct_cql(must_contain_all, can_contain_any, enable_fuzzy) - else: - cql = client_v1.construct_cql(must_contain_all, can_contain_any, enable_fuzzy) - assert cql == expected_cql diff --git a/toolkits/confluence/tests/test_client_v2.py b/toolkits/confluence/tests/test_client_v2.py deleted file mode 100644 index 4958e55e..00000000 --- a/toolkits/confluence/tests/test_client_v2.py +++ /dev/null @@ -1,103 +0,0 @@ -from unittest.mock import patch - -import pytest - -from arcade_confluence.client import ConfluenceClientV2 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "space_identifier, is_id", - [ - ( - "12345", - True, - ), - ( - "test-space", - False, - ), - ], -) -async def test_get_space_id(space_identifier, is_id) -> None: - with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None): - client_v2 = ConfluenceClientV2("fake-token") - with patch( - "arcade_confluence.client.ConfluenceClientV2.get_space_by_key", - return_value={"space": {"id": "12345"}}, - ): - space_id = await client_v2.get_space_id(space_identifier) - if is_id: - assert space_id == space_identifier - else: - assert space_id == "12345" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "page_identifier, is_id", - [ - ( - "67890", - True, - ), - ( - "test-page", - False, - ), - ], -) -async def test_get_page_id(page_identifier, is_id) -> None: - with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None): - client_v2 = ConfluenceClientV2("fake-token") - with patch( - "arcade_confluence.client.ConfluenceClientV2.get_page_by_title", - return_value={"page": {"id": "67890"}}, - ): - page_id = await client_v2.get_page_id(page_identifier) - if is_id: - assert page_id == page_identifier - else: - assert page_id == "67890" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "space_identifier, is_id", - [ - ( - "12345", - True, - ), - ( - "test-space", - False, - ), - ], -) -async def test_get_space(space_identifier, is_id) -> None: - with patch("arcade_confluence.client.ConfluenceClient._get_cloud_id", return_value=None): - client_v2 = ConfluenceClientV2("fake-token") - - mock_space = {"space": {"id": "12345", "key": "TEST"}} - - with ( - patch( - "arcade_confluence.client.ConfluenceClientV2.get_space_by_id", - return_value=mock_space, - ) as mock_by_id, - patch( - "arcade_confluence.client.ConfluenceClientV2.get_space_by_key", - return_value=mock_space, - ) as mock_by_key, - ): - result = await client_v2.get_space(space_identifier) - - if is_id: - mock_by_id.assert_called_once_with(space_identifier) - mock_by_key.assert_not_called() - else: - mock_by_id.assert_not_called() - mock_by_key.assert_called_once_with(space_identifier) - - assert result == mock_space diff --git a/toolkits/confluence/tests/test_utils.py b/toolkits/confluence/tests/test_utils.py deleted file mode 100644 index 46bed976..00000000 --- a/toolkits/confluence/tests/test_utils.py +++ /dev/null @@ -1,195 +0,0 @@ -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_confluence.utils import build_child_url, build_hierarchy, validate_ids - - -@pytest.mark.parametrize( - "ids, max_length, expected_error", - [ - (None, 250, None), - (["123", "456"], 250, None), - (["123", "foo"], 250, ToolExecutionError), - (["123", "456"], 1, RetryableToolError), - ], -) -def test_validate_ids(ids: list[str], max_length: int, expected_error: Exception | None) -> None: - if expected_error: - with pytest.raises(expected_error): - validate_ids(ids, max_length) - else: - validate_ids(ids, max_length) - - -@pytest.mark.parametrize( - "base_url, child, expected", - [ - ( # Published page - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT", - {"type": "page", "title": "Test Title-1", "id": "123", "status": "current"}, - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/pages/123/Test+Title-1", - ), - ( # Draft page - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT", - {"type": "page", "title": "Test Title-1", "id": "123", "status": "draft"}, - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/pages/edit-v2/123", - ), - ( # Whiteboard - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT", - {"type": "whiteboard", "title": "Test Title-1", "id": "123", "status": "current"}, - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/whiteboard/123", - ), - ( # Database - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT", - {"type": "database", "title": "Test Title-1", "id": "123", "status": "current"}, - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/database/123", - ), - ( # Embed - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT", - {"type": "embed", "title": "Test Title-1", "id": "123", "status": "current"}, - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT/embed/123", - ), - ( # Folder - "https://tes.atlassian.net/wiki/spaces/SOFTWAREDEVELOPMENT", - {"type": "folder", "title": "Test Title-1", "id": "123", "status": "current"}, - None, # Folders don't have URLs - ), - ], -) -def test_build_child_url(base_url: str, child: dict, expected: str) -> None: - assert build_child_url(base_url, child) == expected - - -@pytest.mark.parametrize( - "input_transformed_children, input_parent_id, input_parent_node, expected_parent_node", - [ - ( # Parent node is a leaf - [], - "2195457", - { - "title": "A One Sentence Story About Trees", - "id": "2195457", - "type": "page", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2195457/A+One+Sentence+Story+About+Trees", - "children": [], - }, - { - "title": "A One Sentence Story About Trees", - "id": "2195457", - "type": "page", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2195457/A+One+Sentence+Story+About+Trees", - "children": [], - }, - ), - ( - [ - { - "title": "The Importance of Trees", - "id": "2555906", - "type": "page", - "parent_id": "2162740", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2555906/The+Importance+of+Trees", - "children": [], - "status": "current", - }, - { - "title": "Erics page", - "id": "2686977", - "type": "page", - "parent_id": "2555906", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2686977/Erics+page", - "children": [], - "status": "current", - }, - { - "title": "Erics page", - "id": "2719745", - "type": "page", - "parent_id": "2555906", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/edit-v2/2719745", - "children": [], - "status": "draft", - }, - { - "title": "Execute tools", - "id": "2621441", - "type": "page", - "parent_id": "2162740", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2621441/Execute+tools", - "children": [], - "status": "current", - }, - ], - "2162740", - { - "title": "Trees", - "id": "2162740", - "type": "page", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2162740/Trees", - "children": [], - }, - { - "title": "Trees", - "id": "2162740", - "type": "page", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2162740/Trees", - "children": [ - { - "title": "The Importance of Trees", - "id": "2555906", - "type": "page", - "parent_id": "2162740", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2555906/The+Importance+of+Trees", - "children": [ - { - "title": "Erics page", - "id": "2686977", - "type": "page", - "parent_id": "2555906", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2686977/Erics+page", - "children": [], - "status": "current", - }, - { - "title": "Erics page", - "id": "2719745", - "type": "page", - "parent_id": "2555906", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/edit-v2/2719745", - "children": [], - "status": "draft", - }, - ], - "status": "current", - }, - { - "title": "Execute tools", - "id": "2621441", - "type": "page", - "parent_id": "2162740", - "parent_type": "TODO", - "url": "https://ericconfluence.atlassian.net/wiki/spaces/SOFTWAREDE/pages/2621441/Execute+tools", - "children": [], - "status": "current", - }, - ], - }, - ), - ], -) -def test_build_hierarchy( - input_transformed_children: list[dict], - input_parent_id: int, - input_parent_node: dict, - expected_parent_node: dict, -) -> None: - # build_hierarchy modifies the input_parent_node in-place - build_hierarchy(input_transformed_children, input_parent_id, input_parent_node) - assert input_parent_node == expected_parent_node diff --git a/toolkits/dropbox/.pre-commit-config.yaml b/toolkits/dropbox/.pre-commit-config.yaml deleted file mode 100644 index 866e6e38..00000000 --- a/toolkits/dropbox/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/dropbox/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/dropbox/.ruff.toml b/toolkits/dropbox/.ruff.toml deleted file mode 100644 index bacd9161..00000000 --- a/toolkits/dropbox/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py39" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/dropbox/LICENSE b/toolkits/dropbox/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/dropbox/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/dropbox/Makefile b/toolkits/dropbox/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/dropbox/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/dropbox/arcade_dropbox/__init__.py b/toolkits/dropbox/arcade_dropbox/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/dropbox/arcade_dropbox/constants.py b/toolkits/dropbox/arcade_dropbox/constants.py deleted file mode 100644 index 15e6c9af..00000000 --- a/toolkits/dropbox/arcade_dropbox/constants.py +++ /dev/null @@ -1,34 +0,0 @@ -from enum import Enum - - -class EndpointType(Enum): - API = "api" - CONTENT = "content" - - -class Endpoint(Enum): - LIST_FOLDER = "/files/list_folder" - SEARCH_FILES = "/files/search" - DOWNLOAD_FILE = "/files/download" - - -class ItemCategory(Enum): - IMAGE = "image" - DOCUMENT = "document" - PDF = "pdf" - SPREADSHEET = "spreadsheet" - PRESENTATION = "presentation" - AUDIO = "audio" - VIDEO = "video" - FOLDER = "folder" - PAPER = "paper" - - -API_BASE_URL = "https://{endpoint_type}.dropboxapi.com" -API_VERSION = "2" -ENDPOINT_URL_MAP = { - Endpoint.LIST_FOLDER: (EndpointType.API, "files/list_folder"), - Endpoint.SEARCH_FILES: (EndpointType.API, "files/search_v2"), - Endpoint.DOWNLOAD_FILE: (EndpointType.CONTENT, "files/download"), -} -MAX_RESPONSE_BODY_SIZE = 10 * 1024 * 1024 # 10 MiB diff --git a/toolkits/dropbox/arcade_dropbox/critics.py b/toolkits/dropbox/arcade_dropbox/critics.py deleted file mode 100644 index 7bfd4343..00000000 --- a/toolkits/dropbox/arcade_dropbox/critics.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Any - -from arcade_evals import BinaryCritic - - -class DropboxPathCritic(BinaryCritic): - def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]: - """ - Ignores leading slash in the actual value when comparing to the expected value. - - Note: sometimes the LLM won't start the path with a slash, so this critic ignores it when - comparing. Dropbox tools will add the slash, when needed, so no worries about API errors. - - Args: - expected: The expected value. - actual: The actual value to compare, cast to the type of expected. - - Returns: - dict: A dictionary containing the match status and score. - """ - try: - actual_casted = self.cast_actual(expected, actual) - # TODO log or something better here - except TypeError: - actual_casted = actual - - if isinstance(expected, str): - expected = expected.lstrip("/") - - if isinstance(actual_casted, str): - actual_casted = actual_casted.lstrip("/") - - match = expected == actual_casted - return {"match": match, "score": self.weight if match else 0.0} diff --git a/toolkits/dropbox/arcade_dropbox/exceptions.py b/toolkits/dropbox/arcade_dropbox/exceptions.py deleted file mode 100644 index 2af36e34..00000000 --- a/toolkits/dropbox/arcade_dropbox/exceptions.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Optional - - -class DropboxApiError(Exception): - def __init__( - self, - status_code: int, - error_summary: str, - user_message: Optional[str], - ): - if "path/not_found" in error_summary: - self.message = "The specified path was not found by Dropbox" - elif "unsupported_file" in error_summary: - self.message = "The specified file is not supported for the requested operation" - else: - self.message = user_message or error_summary - - self.status_code = status_code diff --git a/toolkits/dropbox/arcade_dropbox/tools/browse.py b/toolkits/dropbox/arcade_dropbox/tools/browse.py deleted file mode 100644 index 6df1d6a9..00000000 --- a/toolkits/dropbox/arcade_dropbox/tools/browse.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import Annotated, Optional - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Dropbox -from arcade_tdk.errors import ToolExecutionError - -from arcade_dropbox.constants import Endpoint, ItemCategory -from arcade_dropbox.exceptions import DropboxApiError -from arcade_dropbox.utils import ( - build_dropbox_json, - clean_dropbox_entries, - parse_dropbox_path, - send_dropbox_request, -) - - -@tool( - requires_auth=Dropbox( - scopes=["files.metadata.read"], - ) -) -async def list_items_in_folder( - context: ToolContext, - folder_path: Annotated[ - str, - "The path to the folder to list the contents of. E.g. '/AcmeInc/Reports'. " - "Defaults to an empty string (list items in the Dropbox root folder).", - ] = "", - limit: Annotated[ - int, - "The maximum number of items to return. Defaults to 100. Maximum allowed is 2000.", - ] = 100, - cursor: Annotated[ - Optional[str], - "The cursor token for the next page of results. " - "Defaults to None (returns the first page of results).", - ] = None, -) -> Annotated[ - dict, "Dictionary containing the list of files and folders in the specified folder path" -]: - """Provides a dictionary containing the list of items in the specified folder path. - - Note 1: when paginating, it is not necessary to provide any other argument besides the cursor. - Note 2: when paginating, any given item (file or folder) may be returned in multiple pages. - """ - limit = min(limit, 2000) - - try: - result = await send_dropbox_request( - context.get_auth_token_or_empty(), - endpoint=Endpoint.LIST_FOLDER, - path=parse_dropbox_path(folder_path), - limit=limit, - cursor=cursor, - ) - except DropboxApiError as api_error: - return {"error": api_error.message} - - return { - "items": clean_dropbox_entries(result["entries"]), - "cursor": result.get("cursor"), - "has_more": result.get("has_more", False), - } - - -@tool( - requires_auth=Dropbox( - scopes=["files.metadata.read"], - ) -) -async def search_files_and_folders( - context: ToolContext, - keywords: Annotated[ - str, - "The keywords to search for. E.g. 'quarterly report'. " - "Maximum length allowed by the Dropbox API is 1000 characters. ", - ], - search_in_folder_path: Annotated[ - Optional[str], - "Restricts the search to the specified folder path. E.g. '/AcmeInc/Reports'. " - "Defaults to None (search in the entire Dropbox).", - ] = None, - filter_by_category: Annotated[ - Optional[list[ItemCategory]], - "Restricts the search to the specified category(ies) of items. " - "Provide None, one or multiple, if needed. Defaults to None (returns all categories).", - ] = None, - limit: Annotated[ - int, - "The maximum number of items to return. Defaults to 100. Maximum allowed is 1000.", - ] = 100, - cursor: Annotated[ - Optional[str], - "The cursor token for the next page of results. Defaults to None (first page of results).", - ] = None, -) -> Annotated[dict, "List of items in the specified folder path matching the search criteria"]: - """Returns a list of items in the specified folder path matching the search criteria. - - Note 1: the Dropbox API will return up to 10,000 (ten thousand) items cumulatively across - multiple pagination requests using the cursor token. - Note 2: when paginating, it is not necessary to provide any other argument besides the cursor. - Note 3: when paginating, any given item (file or folder) may be returned in multiple pages. - """ - if len(keywords) > 1000: - raise ToolExecutionError( - "The keywords argument must be a string with up to 1000 characters." - ) - - limit = min(limit, 1000) - - filter_by_category = filter_by_category or [] - - options = build_dropbox_json( - file_status="active", - filename_only=False, - path=parse_dropbox_path(search_in_folder_path), - max_results=limit, - file_categories=[category.value for category in filter_by_category], - ) - - try: - result = await send_dropbox_request( - context.get_auth_token_or_empty(), - endpoint=Endpoint.SEARCH_FILES, - query=keywords, - options=options, - cursor=cursor, - ) - except DropboxApiError as api_error: - return {"error": api_error.message} - - return { - "items": clean_dropbox_entries([ - match["metadata"]["metadata"] for match in result["matches"] - ]), - "cursor": result.get("cursor"), - "has_more": result.get("has_more", False), - } diff --git a/toolkits/dropbox/arcade_dropbox/tools/files.py b/toolkits/dropbox/arcade_dropbox/tools/files.py deleted file mode 100644 index d30f8e04..00000000 --- a/toolkits/dropbox/arcade_dropbox/tools/files.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Annotated, Optional - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Dropbox -from arcade_tdk.errors import ToolExecutionError - -from arcade_dropbox.constants import Endpoint -from arcade_dropbox.exceptions import DropboxApiError -from arcade_dropbox.utils import parse_dropbox_path, send_dropbox_request - - -@tool( - requires_auth=Dropbox( - scopes=["files.content.read"], - ) -) -async def download_file( - context: ToolContext, - file_path: Annotated[ - Optional[str], - "The path of the file to download. E.g. '/AcmeInc/Reports/Q1_2025.txt'. Defaults to None.", - ] = None, - file_id: Annotated[ - Optional[str], - "The ID of the file to download. E.g. 'id:a4ayc_80_OEAAAAAAAAAYa'. Defaults to None.", - ] = None, -) -> Annotated[dict, "Contents of the specified file"]: - """Downloads the specified file. - - Note: either one of `file_path` or `file_id` must be provided. - """ - if not file_path and not file_id: - raise ToolExecutionError("Either `file_path` or `file_id` must be provided.") - - if file_path and file_id: - raise ToolExecutionError("Only one of `file_path` or `file_id` can be provided.") - - try: - result = await send_dropbox_request( - context.get_auth_token_or_empty(), - endpoint=Endpoint.DOWNLOAD_FILE, - path=parse_dropbox_path(file_path) or file_id, - ) - except DropboxApiError as api_error: - return {"error": api_error.message} - - return {"file": result} diff --git a/toolkits/dropbox/arcade_dropbox/utils.py b/toolkits/dropbox/arcade_dropbox/utils.py deleted file mode 100644 index a82664e4..00000000 --- a/toolkits/dropbox/arcade_dropbox/utils.py +++ /dev/null @@ -1,106 +0,0 @@ -import json -from typing import Any, Optional - -import httpx - -from arcade_dropbox.constants import ( - API_BASE_URL, - API_VERSION, - ENDPOINT_URL_MAP, - Endpoint, - EndpointType, -) -from arcade_dropbox.exceptions import DropboxApiError - - -def build_dropbox_url(endpoint_type: EndpointType, endpoint_path: str) -> str: - base_url = API_BASE_URL.format(endpoint_type=endpoint_type.value) - return f"{base_url}/{API_VERSION}/{endpoint_path.strip('/')}" - - -def build_dropbox_headers(token: Optional[str]) -> dict[str, str]: - return {"Authorization": f"Bearer {token}"} if token else {} - - -def build_dropbox_json(**kwargs: Any) -> dict: - return {key: value for key, value in kwargs.items() if value is not None} - - -async def send_dropbox_request( - authorization_token: Optional[str], - endpoint: Endpoint, - **kwargs: Any, -) -> Any: - endpoint_type, endpoint_path = ENDPOINT_URL_MAP[endpoint] - url = build_dropbox_url(endpoint_type, endpoint_path) - headers = build_dropbox_headers(authorization_token) - json_data = build_dropbox_json(**kwargs) - - if json_data.get("cursor"): - url += "/continue" - # If cursor is provided, every other argument must be ignored to avoid API error - json_data = {"cursor": json_data["cursor"]} - - if endpoint_type == EndpointType.CONTENT: - headers["Dropbox-API-Arg"] = json.dumps(json_data) - json_data = {} - - async with httpx.AsyncClient() as client: - request_args: dict[str, Any] = {"url": url, "headers": headers} - - if json_data: - request_args["json"] = json_data - - response = await client.post(**request_args) - - try: - data = response.json() - except Exception: - data = {} - - if response.status_code != 200: - raise DropboxApiError( - status_code=response.status_code, - error_summary=data.get("error_summary", response.text), - user_message=data.get("user_message"), - ) - - if endpoint_type == EndpointType.CONTENT: - data = json.loads(response.headers["Dropbox-API-Result"]) - data = clean_dropbox_entry(data, default_type="file") - data["content"] = response.text - return data - - return response.json() - - -def clean_dropbox_entry(entry: dict, default_type: Optional[str] = None) -> dict: - return { - "type": entry.get(".tag", default_type), - "id": entry.get("id"), - "name": entry.get("name"), - "path": entry.get("path_display"), - "size_in_bytes": entry.get("size"), - "modified_datetime": entry.get("server_modified"), - } - - -def clean_dropbox_entries(entries: list[dict]) -> list[dict]: - return [clean_dropbox_entry(entry) for entry in entries] - - -def parse_dropbox_path(path: Optional[str]) -> Optional[str]: - if not isinstance(path, str): - return "" - - if not path: - return "" - - if path in ["/", "\\"]: - return "" - - # Normalize windows-style paths to unix-style paths - path = path.replace("\\", "/") - - # Dropbox expects the path to always start with a slash - return "/" + path.strip("/") diff --git a/toolkits/dropbox/conftest.py b/toolkits/dropbox/conftest.py deleted file mode 100644 index 6bcdea17..00000000 --- a/toolkits/dropbox/conftest.py +++ /dev/null @@ -1,45 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_httpx_client(mocker): - with patch("arcade_dropbox.utils.httpx") as mock_httpx: - yield mock_httpx.AsyncClient().__aenter__.return_value - - -@pytest.fixture -def sample_folder_entry(): - return { - ".tag": "folder", - "name": "test.txt", - "path_display": "/TestFolder", - "path_lower": "/testfolder", - "id": "1234567890", - "client_modified": "2025-01-01T00:00:00Z", - "server_modified": "2025-01-01T00:00:00Z", - "rev": "1234567890", - } - - -@pytest.fixture -def sample_file_entry(): - return { - ".tag": "file", - "name": "test.txt", - "path_display": "/TestFile.txt", - "path_lower": "/testfile.txt", - "id": "1234567890", - "client_modified": "2025-01-01T00:00:00Z", - "server_modified": "2025-01-01T00:00:00Z", - "rev": "1234567890", - "size": 1024, - } diff --git a/toolkits/dropbox/evals/eval_download_file.py b/toolkits/dropbox/evals/eval_download_file.py deleted file mode 100644 index c2d8c521..00000000 --- a/toolkits/dropbox/evals/eval_download_file.py +++ /dev/null @@ -1,88 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_dropbox -from arcade_dropbox.critics import DropboxPathCritic -from arcade_dropbox.tools.files import download_file - -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_dropbox) - - -@tool_eval() -def download_file_eval_suite() -> EvalSuite: - """Create an evaluation suite for the download_file tool.""" - suite = EvalSuite( - name="download_file", - system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Download file in the root folder by file path", - user_message="Download the file test.txt from Dropbox", - expected_tool_calls=[ - ExpectedToolCall( - func=download_file, - args={ - "file_path": "test.txt", - "file_id": None, - }, - ), - ], - critics=[ - DropboxPathCritic(critic_field="file_path", weight=0.5), - BinaryCritic(critic_field="file_id", weight=0.5), - ], - ) - - suite.add_case( - name="Download file with a sub-folder structure", - user_message="Download the file Q1report.ppt in the folder AcmeInc/Reports from Dropbox", - expected_tool_calls=[ - ExpectedToolCall( - func=download_file, - args={ - "file_path": "/AcmeInc/Reports/Q1report.ppt", - "file_id": None, - }, - ), - ], - critics=[ - DropboxPathCritic(critic_field="file_path", weight=0.5), - BinaryCritic(critic_field="file_id", weight=0.5), - ], - ) - - suite.add_case( - name="Download file by ID", - user_message="Download the file id:a4ayc_80_OEAAAAAAAAAYa from Dropbox", - expected_tool_calls=[ - ExpectedToolCall( - func=download_file, - args={ - "file_path": None, - "file_id": "id:a4ayc_80_OEAAAAAAAAAYa", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="file_path", weight=0.5), - BinaryCritic(critic_field="file_id", weight=0.5), - ], - ) - - return suite diff --git a/toolkits/dropbox/evals/eval_list_items.py b/toolkits/dropbox/evals/eval_list_items.py deleted file mode 100644 index 53100cb3..00000000 --- a/toolkits/dropbox/evals/eval_list_items.py +++ /dev/null @@ -1,94 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_dropbox -from arcade_dropbox.critics import DropboxPathCritic -from arcade_dropbox.tools.browse import list_items_in_folder - -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_dropbox) - - -@tool_eval() -def list_items_in_folder_eval_suite() -> EvalSuite: - """Create an evaluation suite for the list_items_in_folder tool.""" - suite = EvalSuite( - name="list_items_in_folder", - system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List items in the Dropbox root folder", - user_message="List the items in the Dropbox root folder", - expected_tool_calls=[ - ExpectedToolCall( - func=list_items_in_folder, - args={ - "folder_path": "", - "limit": 100, - "cursor": None, - }, - ), - ], - critics=[ - DropboxPathCritic(critic_field="folder_path", weight=0.6), - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="cursor", weight=0.2), - ], - ) - - suite.add_case( - name="List items in a sub-folder", - user_message="List the items in the folder AcmeInc/Reports", - expected_tool_calls=[ - ExpectedToolCall( - func=list_items_in_folder, - args={ - "folder_path": "/AcmeInc/Reports", - "limit": 100, - "cursor": None, - }, - ), - ], - critics=[ - DropboxPathCritic(critic_field="folder_path", weight=0.6), - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="cursor", weight=0.2), - ], - ) - - suite.add_case( - name="List items in a sub-folder with custom limit", - user_message="List the first 50 items in the folder AcmeInc/Reports", - expected_tool_calls=[ - ExpectedToolCall( - func=list_items_in_folder, - args={ - "folder_path": "/AcmeInc/Reports", - "limit": 50, - "cursor": None, - }, - ), - ], - critics=[ - DropboxPathCritic(critic_field="folder_path", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.4), - BinaryCritic(critic_field="cursor", weight=0.2), - ], - ) - - return suite diff --git a/toolkits/dropbox/evals/eval_search.py b/toolkits/dropbox/evals/eval_search.py deleted file mode 100644 index eecfc471..00000000 --- a/toolkits/dropbox/evals/eval_search.py +++ /dev/null @@ -1,131 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_dropbox -from arcade_dropbox.constants import ItemCategory -from arcade_dropbox.critics import DropboxPathCritic -from arcade_dropbox.tools.browse import search_files_and_folders - -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_dropbox) - - -@tool_eval() -def search_files_and_folders_eval_suite() -> EvalSuite: - """Create an evaluation suite for the search_files_and_folders tool.""" - suite = EvalSuite( - name="list_items_in_folder", - system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search for files about 'quarterly report' in my Dropbox", - user_message="Search for files about 'quarterly report' in my Dropbox", - expected_tool_calls=[ - ExpectedToolCall( - func=search_files_and_folders, - args={ - "keywords": "quarterly report", - "search_in_folder_path": None, - "filter_by_category": None, - "limit": 100, - "cursor": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="keywords", weight=0.6), - BinaryCritic(critic_field="search_in_folder_path", weight=0.1), - BinaryCritic(critic_field="filter_by_category", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="cursor", weight=0.1), - ], - ) - - suite.add_case( - name="Search for files about 'quarterly report' in a sub-folder", - user_message="Search for files about 'quarterly report' in the folder AcmeInc/Reports", - expected_tool_calls=[ - ExpectedToolCall( - func=search_files_and_folders, - args={ - "keywords": "quarterly report", - "search_in_folder_path": "/AcmeInc/Reports", - "filter_by_category": None, - "limit": 100, - "cursor": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="keywords", weight=0.35), - DropboxPathCritic(critic_field="search_in_folder_path", weight=0.35), - BinaryCritic(critic_field="filter_by_category", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="cursor", weight=0.1), - ], - ) - - suite.add_case( - name="Search for PDF files about 'quarterly report' in a sub-folder", - user_message="Search for PDF files about 'quarterly report' in the folder AcmeInc/Reports", - expected_tool_calls=[ - ExpectedToolCall( - func=search_files_and_folders, - args={ - "keywords": "quarterly report", - "search_in_folder_path": "/AcmeInc/Reports", - "filter_by_category": [ItemCategory.PDF.value], - "limit": 100, - "cursor": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="keywords", weight=0.25), - DropboxPathCritic(critic_field="search_in_folder_path", weight=0.25), - BinaryCritic(critic_field="filter_by_category", weight=0.25), - BinaryCritic(critic_field="limit", weight=0.125), - BinaryCritic(critic_field="cursor", weight=0.125), - ], - ) - - suite.add_case( - name="Search for PDF files about 'quarterly report' in a sub-folder", - user_message="Return the first 10 PDF files about 'quarterly report' in the folder AcmeInc/Reports", - expected_tool_calls=[ - ExpectedToolCall( - func=search_files_and_folders, - args={ - "keywords": "quarterly report", - "search_in_folder_path": "/AcmeInc/Reports", - "filter_by_category": [ItemCategory.PDF.value], - "limit": 10, - "cursor": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="keywords", weight=0.2), - DropboxPathCritic(critic_field="search_in_folder_path", weight=0.2), - BinaryCritic(critic_field="filter_by_category", weight=0.2), - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="cursor", weight=0.2), - ], - ) - - return suite diff --git a/toolkits/dropbox/pyproject.toml b/toolkits/dropbox/pyproject.toml deleted file mode 100644 index c5573818..00000000 --- a/toolkits/dropbox/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_dropbox" -version = "0.1.4" -description = "Arcade tools designed for LLMs to interact with Dropbox" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_dropbox/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_dropbox",] diff --git a/toolkits/dropbox/tests/test_download_file.py b/toolkits/dropbox/tests/test_download_file.py deleted file mode 100644 index 18e32948..00000000 --- a/toolkits/dropbox/tests/test_download_file.py +++ /dev/null @@ -1,119 +0,0 @@ -import json -from unittest.mock import MagicMock - -import httpx -import pytest - -from arcade_dropbox.tools.files import download_file -from arcade_dropbox.utils import clean_dropbox_entry - - -@pytest.fixture -def file_content_response_header(): - return json.dumps({ - "id": "123", - "name": "test.txt", - "path_display": "/test.txt", - "size": 1024, - "server_modified": "2021-01-01T00:00:00Z", - "content_hash": "1234567890", - "is_downloadable": True, - "rev": "a1c10ce0dd78", - "sharing_info": { - "modified_by": "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc", - "parent_shared_folder_id": "84528192421", - "read_only": True, - }, - }) - - -@pytest.mark.asyncio -async def test_download_file_success( - mock_context, - mock_httpx_client, - file_content_response_header, -): - file_content = "test file content" - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.side_effect = ValueError("not json") - mock_httpx_response.headers = {"Dropbox-API-Result": file_content_response_header} - mock_httpx_response.text = file_content - - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await download_file( - context=mock_context, - file_path="test.txt", - ) - - expected_response = clean_dropbox_entry(json.loads(file_content_response_header)) - expected_response["content"] = file_content - expected_response["type"] = "file" - - assert tool_response == {"file": expected_response} - - -@pytest.mark.asyncio -async def test_download_file_path_not_found( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 409 - mock_httpx_response.json.return_value = {"error_summary": "path/not_found"} - - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await download_file( - context=mock_context, - file_path="test.txt", - ) - - assert tool_response == { - "error": "The specified path was not found by Dropbox", - } - - -@pytest.mark.asyncio -async def test_download_file_unsupported_file( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 409 - mock_httpx_response.json.return_value = {"error_summary": "unsupported_file/not_supported"} - - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await download_file( - context=mock_context, - file_path="test.txt", - ) - - assert tool_response == { - "error": "The specified file is not supported for the requested operation", - } - - -@pytest.mark.asyncio -async def test_download_file_server_error( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 500 - mock_httpx_response.text = "500 Internal server error" - mock_httpx_response.json.side_effect = ValueError("not json") - - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await download_file( - context=mock_context, - file_path="test.txt", - ) - - assert tool_response == { - "error": "500 Internal server error", - } diff --git a/toolkits/dropbox/tests/test_list_items.py b/toolkits/dropbox/tests/test_list_items.py deleted file mode 100644 index b84c578f..00000000 --- a/toolkits/dropbox/tests/test_list_items.py +++ /dev/null @@ -1,145 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest - -from arcade_dropbox.tools.browse import list_items_in_folder -from arcade_dropbox.utils import clean_dropbox_entries - - -@pytest.mark.asyncio -async def test_list_items_success_empty_folder( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"entries": [], "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await list_items_in_folder( - context=mock_context, - folder_path="/path/to/folder", - ) - - assert tool_response == { - "items": [], - "cursor": None, - "has_more": False, - } - - -@pytest.mark.asyncio -async def test_list_items_success_with_folder_entries( - mock_context, - mock_httpx_client, - sample_folder_entry, - sample_file_entry, -): - entries = [sample_folder_entry, sample_file_entry] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"entries": entries, "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await list_items_in_folder( - context=mock_context, - folder_path="/path/to/folder", - ) - - assert tool_response == { - "items": clean_dropbox_entries(entries), - "cursor": None, - "has_more": False, - } - - -@pytest.mark.asyncio -async def test_list_items_success_with_more_items_to_paginate( - mock_context, - mock_httpx_client, - sample_folder_entry, - sample_file_entry, -): - entries = [sample_folder_entry, sample_file_entry] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = { - "entries": entries, - "cursor": "cursor", - "has_more": True, - } - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await list_items_in_folder( - context=mock_context, - folder_path="/path/to/folder", - ) - - assert tool_response == { - "items": clean_dropbox_entries(entries), - "cursor": "cursor", - "has_more": True, - } - - -@pytest.mark.asyncio -async def test_list_items_success_providing_cursor( - mock_context, - mock_httpx_client, - sample_folder_entry, - sample_file_entry, -): - entries = [sample_folder_entry, sample_file_entry] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = { - "entries": entries, - "cursor": "cursor2", - "has_more": True, - } - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await list_items_in_folder( - context=mock_context, - folder_path="/path/to/folder", - cursor="cursor1", - limit=2, - ) - - assert tool_response == { - "items": clean_dropbox_entries(entries), - "cursor": "cursor2", - "has_more": True, - } - - # Check that the request was made with the cursor and not the other arguments - mock_httpx_client.post.assert_called_with( - url="https://api.dropboxapi.com/2/files/list_folder/continue", - headers={"Authorization": "Bearer fake-token"}, - json={"cursor": "cursor1"}, - ) - - -@pytest.mark.asyncio -async def test_list_items_path_not_found( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 409 - mock_httpx_response.json.return_value = {"error_summary": "path/not_found"} - - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await list_items_in_folder( - context=mock_context, - folder_path="/not/exist/folder", - ) - - assert tool_response == { - "error": "The specified path was not found by Dropbox", - } diff --git a/toolkits/dropbox/tests/test_search_files.py b/toolkits/dropbox/tests/test_search_files.py deleted file mode 100644 index 45bf48f6..00000000 --- a/toolkits/dropbox/tests/test_search_files.py +++ /dev/null @@ -1,286 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest - -from arcade_dropbox.constants import ItemCategory -from arcade_dropbox.tools.browse import search_files_and_folders -from arcade_dropbox.utils import clean_dropbox_entry - - -@pytest.fixture -def sample_folder_match(sample_folder_entry): - return { - "metadata": { - ".tag": "metadata", - "metadata": sample_folder_entry, - } - } - - -@pytest.fixture -def sample_file_match(sample_file_entry): - return { - "metadata": { - ".tag": "metadata", - "metadata": sample_file_entry, - } - } - - -@pytest.mark.asyncio -async def test_search_files_success_empty_results( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"matches": [], "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="do not match anything", - ) - - assert tool_response == { - "items": [], - "cursor": None, - "has_more": False, - } - - -@pytest.mark.asyncio -async def test_search_files_success_with_matches( - mock_context, - mock_httpx_client, - sample_file_match, - sample_folder_match, - sample_file_entry, - sample_folder_entry, -): - matches = [ - sample_file_match, - sample_folder_match, - ] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="test", - ) - - assert tool_response == { - "items": [ - clean_dropbox_entry(sample_file_entry), - clean_dropbox_entry(sample_folder_entry), - ], - "cursor": None, - "has_more": False, - } - - -@pytest.mark.asyncio -async def test_search_files_success_with_path_missing_leading_slash( - mock_context, - mock_httpx_client, - sample_file_match, - sample_folder_match, - sample_file_entry, - sample_folder_entry, -): - matches = [ - sample_file_match, - sample_folder_match, - ] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="test", - search_in_folder_path="TestFolder", - ) - - assert tool_response == { - "items": [ - clean_dropbox_entry(sample_file_entry), - clean_dropbox_entry(sample_folder_entry), - ], - "cursor": None, - "has_more": False, - } - - mock_httpx_client.post.assert_called_once_with( - url="https://api.dropboxapi.com/2/files/search_v2", - headers={"Authorization": "Bearer fake-token"}, - json={ - "query": "test", - "options": { - "file_categories": [], - "path": "/TestFolder", - "file_status": "active", - "filename_only": False, - "max_results": 100, - }, - }, - ) - - -@pytest.mark.asyncio -async def test_search_files_success_with_more_results_to_paginate( - mock_context, - mock_httpx_client, - sample_file_match, - sample_folder_match, - sample_file_entry, - sample_folder_entry, -): - matches = [ - sample_file_match, - sample_folder_match, - ] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = { - "matches": matches, - "cursor": "cursor", - "has_more": True, - } - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="test", - ) - - assert tool_response == { - "items": [ - clean_dropbox_entry(sample_file_entry), - clean_dropbox_entry(sample_folder_entry), - ], - "cursor": "cursor", - "has_more": True, - } - - -@pytest.mark.asyncio -async def test_search_files_success_providing_pagination_cursor( - mock_context, - mock_httpx_client, - sample_file_match, - sample_folder_match, - sample_file_entry, - sample_folder_entry, -): - matches = [ - sample_file_match, - sample_folder_match, - ] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="test", - cursor="cursor", - ) - - assert tool_response == { - "items": [ - clean_dropbox_entry(sample_file_entry), - clean_dropbox_entry(sample_folder_entry), - ], - "cursor": None, - "has_more": False, - } - - # Assert that the request was made with the correct cursor and not other arguments - mock_httpx_client.post.assert_called_once_with( - url="https://api.dropboxapi.com/2/files/search_v2/continue", - headers={"Authorization": "Bearer fake-token"}, - json={"cursor": "cursor"}, - ) - - -@pytest.mark.asyncio -async def test_search_files_path_not_found( - mock_context, - mock_httpx_client, -): - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 409 - mock_httpx_response.json.return_value = {"error_summary": "path/not_found"} - - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="test", - search_in_folder_path="/not/exist/folder", - ) - - assert tool_response == { - "error": "The specified path was not found by Dropbox", - } - - -@pytest.mark.asyncio -async def test_search_files_success_filtering_by_category( - mock_context, - mock_httpx_client, - sample_file_match, - sample_folder_match, - sample_file_entry, - sample_folder_entry, -): - matches = [ - sample_file_match, - sample_folder_match, - ] - - mock_httpx_response = MagicMock(spec=httpx.Response) - mock_httpx_response.status_code = 200 - mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False} - mock_httpx_client.post.return_value = mock_httpx_response - - tool_response = await search_files_and_folders( - context=mock_context, - keywords="test", - filter_by_category=[ItemCategory.PDF], - ) - - assert tool_response == { - "items": [ - clean_dropbox_entry(sample_file_entry), - clean_dropbox_entry(sample_folder_entry), - ], - "cursor": None, - "has_more": False, - } - - mock_httpx_client.post.assert_called_once_with( - url="https://api.dropboxapi.com/2/files/search_v2", - headers={"Authorization": "Bearer fake-token"}, - json={ - "query": "test", - "options": { - "path": "", - "file_status": "active", - "filename_only": False, - "max_results": 100, - "file_categories": [ItemCategory.PDF.value], - }, - }, - ) diff --git a/toolkits/e2b/.pre-commit-config.yaml b/toolkits/e2b/.pre-commit-config.yaml deleted file mode 100644 index fd19ccd8..00000000 --- a/toolkits/e2b/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/e2b/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/e2b/.ruff.toml b/toolkits/e2b/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/e2b/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/e2b/LICENSE b/toolkits/e2b/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/e2b/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/e2b/Makefile b/toolkits/e2b/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/e2b/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/e2b/arcade_e2b/__init__.py b/toolkits/e2b/arcade_e2b/__init__.py deleted file mode 100644 index 4a13c163..00000000 --- a/toolkits/e2b/arcade_e2b/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_e2b.tools import create_static_matplotlib_chart, run_code - -__all__ = ["create_static_matplotlib_chart", "run_code"] diff --git a/toolkits/e2b/arcade_e2b/enums.py b/toolkits/e2b/arcade_e2b/enums.py deleted file mode 100644 index 34ff05bc..00000000 --- a/toolkits/e2b/arcade_e2b/enums.py +++ /dev/null @@ -1,10 +0,0 @@ -from enum import Enum - - -# Models and enums for the e2b code interpreter -class E2BSupportedLanguage(str, Enum): - PYTHON = "python" - JAVASCRIPT = "js" - R = "r" - JAVA = "java" - BASH = "bash" diff --git a/toolkits/e2b/arcade_e2b/tools/__init__.py b/toolkits/e2b/arcade_e2b/tools/__init__.py deleted file mode 100644 index 2ceb050e..00000000 --- a/toolkits/e2b/arcade_e2b/tools/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from arcade_e2b.tools.create_chart import create_static_matplotlib_chart -from arcade_e2b.tools.run_code import run_code - -__all__ = ["create_static_matplotlib_chart", "run_code"] diff --git a/toolkits/e2b/arcade_e2b/tools/create_chart.py b/toolkits/e2b/arcade_e2b/tools/create_chart.py deleted file mode 100644 index 003d8fc8..00000000 --- a/toolkits/e2b/arcade_e2b/tools/create_chart.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from e2b_code_interpreter import Sandbox - -# See https://e2b.dev/docs to learn more about E2B - - -# Note: Not recommended to use tool_choice='generate' with this tool -# since it contains base64 encoded image. -@tool(requires_secrets=["E2B_API_KEY"]) -def create_static_matplotlib_chart( - context: ToolContext, - code: Annotated[str, "The Python code to run"], -) -> Annotated[dict, "A dictionary with the following keys: base64_image, logs, error"]: - """ - Run the provided Python code to generate a static matplotlib chart. - The resulting chart is returned as a base64 encoded image. - """ - api_key = context.get_secret("E2B_API_KEY") - - with Sandbox(api_key=api_key) as sbx: - execution = sbx.run_code(code=code) - - result = { - "base64_image": execution.results[0].png if execution.results else None, - "logs": execution.logs.to_json(), - "error": execution.error.to_json() if execution.error else None, - } - - return result diff --git a/toolkits/e2b/arcade_e2b/tools/run_code.py b/toolkits/e2b/arcade_e2b/tools/run_code.py deleted file mode 100644 index 83ac729f..00000000 --- a/toolkits/e2b/arcade_e2b/tools/run_code.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from e2b_code_interpreter import Sandbox - -from arcade_e2b.enums import E2BSupportedLanguage - -# See https://e2b.dev/docs to learn more about E2B - - -@tool(requires_secrets=["E2B_API_KEY"]) -def run_code( - context: ToolContext, - code: Annotated[str, "The code to run"], - language: Annotated[ - E2BSupportedLanguage, "The language of the code" - ] = E2BSupportedLanguage.PYTHON, -) -> Annotated[str, "The sandbox execution as a JSON string"]: - """ - Run code in a sandbox and return the output. - """ - api_key = context.get_secret("E2B_API_KEY") - - with Sandbox(api_key=api_key) as sbx: - execution = sbx.run_code(code=code, language=language) - - return str(execution.to_json()) diff --git a/toolkits/e2b/evals/eval_e2b.py b/toolkits/e2b/evals/eval_e2b.py deleted file mode 100644 index 1be1028d..00000000 --- a/toolkits/e2b/evals/eval_e2b.py +++ /dev/null @@ -1,120 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_e2b -from arcade_e2b.enums import E2BSupportedLanguage -from arcade_e2b.tools.create_chart import create_static_matplotlib_chart -from arcade_e2b.tools.run_code import run_code - -merge_sort_code = """ -def merge_sort(arr): - if len(arr) <= 1: - return arr - - mid = len(arr) // 2 - left = merge_sort(arr[:mid]) - right = merge_sort(arr[mid:]) - - return merge(left, right) - -def merge(left, right): - result = [] - i, j = 0, 0 - - while i < len(left) and j < len(right): - if left[i] < right[j]: - result.append(left[i]) - i += 1 - else: - result.append(right[j]) - j += 1 - - result.extend(left[i:]) - result.extend(right[j:]) - - return result - -sample_list = ["banana", "apple", "cherry", "date", "elderberry"] - -sorted_list = merge_sort(sample_list) -print("Sorted list:", sorted_list) -""" - -matplotlib_chart_code = """ -import matplotlib.pyplot as plt - -labels = ['Apples', 'Bananas', 'Cherries', 'Dates'] -sizes = [30, 25, 20, 25] -colors = ['red', 'yellow', 'purple', 'brown'] - -plt.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90) - -plt.axis('equal') - -plt.title('Fruit Distribution') - -plt.savefig('fruit_pie_chart.png') -""" - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_e2b) - - -@tool_eval() -def e2b_eval_suite(): - suite = EvalSuite( - name="E2B Tools Evaluation", - system_message="You are an AI assistant with access to E2B tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Run code", - user_message=f"Can you please run my merge sort algo?\n\n{merge_sort_code}", - expected_tool_calls=[ - ExpectedToolCall( - func=run_code, - args={ - "code": merge_sort_code, - "language": E2BSupportedLanguage.PYTHON, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="code", weight=0.8), - BinaryCritic(critic_field="language", weight=0.2), - ], - ) - - suite.add_case( - name="Create static matplotlib chart", - user_message=f"Run this code:\n\n{matplotlib_chart_code}", - expected_tool_calls=[ - ExpectedToolCall( - func=create_static_matplotlib_chart, - args={ - "code": matplotlib_chart_code, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="code", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/e2b/pyproject.toml b/toolkits/e2b/pyproject.toml deleted file mode 100644 index e672d343..00000000 --- a/toolkits/e2b/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_e2b" -version = "2.0.0" -description = "Arcade.dev LLM tools for running code in a sandbox using E2B" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "e2b-code-interpreter>=1.0.1,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_e2b/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_e2b",] diff --git a/toolkits/e2b/tests/__init__.py b/toolkits/e2b/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/e2b/tests/test_e2b.py b/toolkits/e2b/tests/test_e2b.py deleted file mode 100644 index 36724a08..00000000 --- a/toolkits/e2b/tests/test_e2b.py +++ /dev/null @@ -1,82 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem -from arcade_tdk.errors import ToolExecutionError - -import arcade_e2b.tools.create_chart -import arcade_e2b.tools.run_code -from arcade_e2b.enums import E2BSupportedLanguage - - -@pytest.fixture -def mock_run_code_sandbox(): - with patch("arcade_e2b.tools.run_code.Sandbox") as mock: - yield mock.return_value.__enter__.return_value - - -@pytest.fixture -def mock_create_chart_sandbox(): - with patch("arcade_e2b.tools.create_chart.Sandbox") as mock: - yield mock.return_value.__enter__.return_value - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="e2b_api_key", value="fake_api_key")]) - - -def test_run_code_success(mock_run_code_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.to_json.return_value = '{"result": "success"}' - mock_run_code_sandbox.run_code.return_value = mock_execution - - result = arcade_e2b.tools.run_code( - mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON - ) - assert result == '{"result": "success"}' - - -def test_run_code_error(mock_run_code_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.to_json.side_effect = ToolExecutionError("Execution failed") - mock_run_code_sandbox.run_code.return_value = mock_execution - - with pytest.raises(ToolExecutionError, match="Execution failed"): - arcade_e2b.tools.run_code( - mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON - ) - - -def test_create_static_matplotlib_chart_success(mock_create_chart_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.results = [MagicMock(png="base64encodedimage")] - mock_execution.logs.to_json.return_value = '{"logs": "log data"}' - mock_execution.error = None - mock_create_chart_sandbox.run_code.return_value = mock_execution - - result = arcade_e2b.tools.create_chart.create_static_matplotlib_chart( - mock_context, "import matplotlib.pyplot as plt" - ) - assert result == { - "base64_image": "base64encodedimage", - "logs": '{"logs": "log data"}', - "error": None, - } - - -def test_create_static_matplotlib_chart_error(mock_create_chart_sandbox, mock_context): - mock_execution = MagicMock() - mock_execution.results = [] - mock_execution.logs.to_json.return_value = '{"logs": "log data"}' - mock_execution.error.to_json.return_value = '{"error": "some error"}' - mock_create_chart_sandbox.run_code.return_value = mock_execution - - result = arcade_e2b.tools.create_chart.create_static_matplotlib_chart( - mock_context, "import matplotlib.pyplot as plt" - ) - assert result == { - "base64_image": None, - "logs": '{"logs": "log data"}', - "error": '{"error": "some error"}', - } diff --git a/toolkits/firecrawl/.pre-commit-config.yaml b/toolkits/firecrawl/.pre-commit-config.yaml deleted file mode 100644 index 031d52b9..00000000 --- a/toolkits/firecrawl/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/firecrawl/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/firecrawl/.ruff.toml b/toolkits/firecrawl/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/firecrawl/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/firecrawl/LICENSE b/toolkits/firecrawl/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/firecrawl/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/firecrawl/Makefile b/toolkits/firecrawl/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/firecrawl/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/firecrawl/arcade_firecrawl/__init__.py b/toolkits/firecrawl/arcade_firecrawl/__init__.py deleted file mode 100644 index f0c5a745..00000000 --- a/toolkits/firecrawl/arcade_firecrawl/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from arcade_firecrawl.tools import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, - map_website, - scrape_url, -) - -__all__ = [ - "cancel_crawl", - "crawl_website", - "get_crawl_data", - "get_crawl_status", - "map_website", - "scrape_url", -] diff --git a/toolkits/firecrawl/arcade_firecrawl/enums.py b/toolkits/firecrawl/arcade_firecrawl/enums.py deleted file mode 100644 index 2e823940..00000000 --- a/toolkits/firecrawl/arcade_firecrawl/enums.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -# Models and enums for firecrawl web tools -class Formats(str, Enum): - MARKDOWN = "markdown" - HTML = "html" - RAW_HTML = "rawHtml" - LINKS = "links" - SCREENSHOT = "screenshot" - SCREENSHOT_AT_FULL_PAGE = "screenshot@fullPage" diff --git a/toolkits/firecrawl/arcade_firecrawl/tools/__init__.py b/toolkits/firecrawl/arcade_firecrawl/tools/__init__.py deleted file mode 100644 index dd3c5cbe..00000000 --- a/toolkits/firecrawl/arcade_firecrawl/tools/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from arcade_firecrawl.tools.crawl import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, -) -from arcade_firecrawl.tools.map import map_website -from arcade_firecrawl.tools.scrape import scrape_url - -__all__ = [ - "cancel_crawl", - "crawl_website", - "get_crawl_data", - "get_crawl_status", - "map_website", - "scrape_url", -] diff --git a/toolkits/firecrawl/arcade_firecrawl/tools/crawl.py b/toolkits/firecrawl/arcade_firecrawl/tools/crawl.py deleted file mode 100644 index e7812e87..00000000 --- a/toolkits/firecrawl/arcade_firecrawl/tools/crawl.py +++ /dev/null @@ -1,121 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from firecrawl import FirecrawlApp - - -# TODO: Support scrapeOptions. -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def crawl_website( - context: ToolContext, - url: Annotated[str, "URL to crawl"], - exclude_paths: Annotated[list[str] | None, "URL patterns to exclude from the crawl"] = None, - include_paths: Annotated[list[str] | None, "URL patterns to include in the crawl"] = None, - max_depth: Annotated[int, "Maximum depth to crawl relative to the entered URL"] = 2, - ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True, - limit: Annotated[int, "Limit the number of pages to crawl"] = 10, - allow_backward_links: Annotated[ - bool, - "Enable navigation to previously linked pages and enable crawling " - "sublinks that are not children of the 'url' input parameter.", - ] = False, - allow_external_links: Annotated[bool, "Allow following links to external websites"] = False, - webhook: Annotated[ - str | None, - "The URL to send a POST request to when the crawl is started, updated and completed.", - ] = None, - async_crawl: Annotated[bool, "Run the crawl asynchronously"] = True, -) -> Annotated[dict[str, Any], "Crawl status and data"]: - """ - Crawl a website using Firecrawl. If the crawl is asynchronous, then returns the crawl ID. - If the crawl is synchronous, then returns the crawl data. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - params = { - "limit": limit, - "excludePaths": exclude_paths or [], - "includePaths": include_paths or [], - "maxDepth": max_depth, - "ignoreSitemap": ignore_sitemap, - "allowBackwardLinks": allow_backward_links, - "allowExternalLinks": allow_external_links, - } - if webhook: - params["webhook"] = webhook - - if async_crawl: - response = app.async_crawl_url(url, params=params) - response.pop("url", None) # Remove 'url' as it's an API endpoint - - if response["success"]: - response["status"] = await get_crawl_status(context, response["id"]) - response["llm_instructions"] = ( - "You have the ability to get crawl status, cancel a crawl, " - "and get a crawl's data. Inform the user that you have these capabilities. " - "Inform the user that they should let you know if they want you to perform any " - "of these actions." - ) - - else: - response = app.crawl_url(url, params=params) - - return dict(response) - - -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def get_crawl_status( - context: ToolContext, - crawl_id: Annotated[str, "The ID of the crawl job"], -) -> Annotated[dict[str, Any], "Crawl status information"]: - """ - Get the status of a Firecrawl 'crawl' that is either in progress or recently completed. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - crawl_status = app.check_crawl_status(crawl_id) - - crawl_status.pop("data", None) # Remove 'data' if it exists - crawl_status.pop("next", None) # Remove 'next' as it's an API endpoint - - return dict(crawl_status) - - -# TODO: Support responses greater than 10 MB. If the response is greater than 10 MB, -# then the Firecrawl API response will have a next_url field. -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def get_crawl_data( - context: ToolContext, - crawl_id: Annotated[str, "The ID of the crawl job"], -) -> Annotated[dict[str, Any], "Crawl data information"]: - """ - Get the data of a Firecrawl 'crawl' that is either in progress or recently completed. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - crawl_data = app.check_crawl_status(crawl_id) - - return dict(crawl_data) - - -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def cancel_crawl( - context: ToolContext, - crawl_id: Annotated[str, "The ID of the asynchronous crawl job to cancel"], -) -> Annotated[dict[str, Any], "Cancellation status information"]: - """ - Cancel an asynchronous crawl job that is in progress using the Firecrawl API. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - cancellation_status = app.cancel_crawl(crawl_id) - - return dict(cancellation_status) diff --git a/toolkits/firecrawl/arcade_firecrawl/tools/map.py b/toolkits/firecrawl/arcade_firecrawl/tools/map.py deleted file mode 100644 index 32d460af..00000000 --- a/toolkits/firecrawl/arcade_firecrawl/tools/map.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from firecrawl import FirecrawlApp - - -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def map_website( - context: ToolContext, - url: Annotated[str, "The base URL to start crawling from"], - search: Annotated[str | None, "Search query to use for mapping"] = None, - ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True, - include_subdomains: Annotated[bool, "Include subdomains of the website"] = False, - limit: Annotated[int, "Maximum number of links to return"] = 5000, -) -> Annotated[dict[str, Any], "Website map data"]: - """ - Map a website from a single URL to a map of the entire website. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - params: dict[str, Any] = { - "ignoreSitemap": ignore_sitemap, - "includeSubdomains": include_subdomains, - "limit": limit, - } - if search: - params["search"] = search - - map_result = app.map_url(url, params=params) - - return dict(map_result) diff --git a/toolkits/firecrawl/arcade_firecrawl/tools/scrape.py b/toolkits/firecrawl/arcade_firecrawl/tools/scrape.py deleted file mode 100644 index 148b98d3..00000000 --- a/toolkits/firecrawl/arcade_firecrawl/tools/scrape.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from firecrawl import FirecrawlApp - -from arcade_firecrawl.enums import Formats - - -# TODO: Support actions. This would enable clicking, scrolling, screenshotting, etc. -# TODO: Support extract. -# TODO: Support headers param? -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def scrape_url( - context: ToolContext, - url: Annotated[str, "URL to scrape"], - formats: Annotated[ - list[Formats] | None, "Formats to retrieve. Defaults to ['markdown']." - ] = None, - only_main_content: Annotated[ - bool | None, - "Only return the main content of the page excluding headers, navs, footers, etc.", - ] = True, - include_tags: Annotated[list[str] | None, "List of tags to include in the output"] = None, - exclude_tags: Annotated[list[str] | None, "List of tags to exclude from the output"] = None, - wait_for: Annotated[ - int | None, - "Specify a delay in milliseconds before fetching the content, allowing the page " - "sufficient time to load.", - ] = 10, - timeout: Annotated[int | None, "Timeout in milliseconds for the request"] = 30000, -) -> Annotated[dict[str, Any], "Scraped data in specified formats"]: - """Scrape a URL using Firecrawl and return the data in specified formats.""" - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - formats = formats or [Formats.MARKDOWN] - - app = FirecrawlApp(api_key=api_key) - params = { - "formats": formats, - "onlyMainContent": only_main_content, - "includeTags": include_tags or [], - "excludeTags": exclude_tags or [], - "waitFor": wait_for, - "timeout": timeout, - } - response = app.scrape_url(url, params=params) - - return dict(response) diff --git a/toolkits/firecrawl/evals/eval_firecrawl.py b/toolkits/firecrawl/evals/eval_firecrawl.py deleted file mode 100644 index cbaa7fd6..00000000 --- a/toolkits/firecrawl/evals/eval_firecrawl.py +++ /dev/null @@ -1,244 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NumericCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_firecrawl -from arcade_firecrawl.tools import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, - map_website, - scrape_url, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -# Register the Firecrawl tools -catalog.add_module(arcade_firecrawl) - - -@tool_eval() -def firecrawl_eval_suite() -> EvalSuite: - """Evaluation suite for Firecrawl tools.""" - suite = EvalSuite( - name="Firecrawl Tools Evaluation Suite", - system_message="You are an AI assistant that helps users interact with web scraping and crawling tools using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Scrape URL - suite.add_case( - name="Scrape a URL", - user_message="Scrape https://foobar.com/howto/tutorials/join-discord-server in markdown format please. Wait for 10 seconds before fetching the content.", - expected_tool_calls=[ - ExpectedToolCall( - func=scrape_url, - args={ - "url": "https://foobar.com/howto/tutorials/join-discord-server", - "formats": ["markdown"], - "wait_for": 10000, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="url", weight=0.4), - BinaryCritic(critic_field="formats", weight=0.4), - NumericCritic(critic_field="wait_for", weight=0.2, value_range=(9000, 11000)), - ], - ) - - # Crawl Website - suite.add_case( - name="Crawl a website", - user_message="Crawl the website at https://wikipedia.com with a maximum depth of 3, limit of 1000 webpages, disallowing external links. Updates should be sent to http://example.com/crawl-updates. Oh and do it in the background. THanks", - expected_tool_calls=[ - ExpectedToolCall( - func=crawl_website, - args={ - "url": "https://wikipedia.com", - "max_depth": 3, - "limit": 1000, - "allow_external_links": False, - "webhook": "http://example.com/crawl-updates", - "async_crawl": True, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="url", weight=0.2), - BinaryCritic(critic_field="max_depth", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="allow_external_links", weight=0.1), - BinaryCritic(critic_field="webhook", weight=0.2), - BinaryCritic(critic_field="async_crawl", weight=0.2), - ], - ) - - # Get Crawl Status - suite.add_case( - name="Get crawl status", - user_message="Check the status of my crawl", - expected_tool_calls=[ - ExpectedToolCall( - func=get_crawl_status, - args={ - "crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="crawl_id", weight=1.0), - ], - additional_messages=[ - {"role": "user", "content": "crawl asynchronously https://www.google.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "type": "function", - "function": { - "name": "Firecrawl_CrawlWebsite", - "arguments": '{"url":"https://www.google.com","async_crawl":true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}', - "tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "name": "Firecrawl_CrawlWebsite", - }, - { - "role": "assistant", - "content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).", - }, - ], - ) - - # # Get Crawl Data - suite.add_case( - name="Get crawl status", - user_message="Ok looks like the crawl is done, can I get the result please?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_crawl_data, - args={ - "crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="crawl_id", weight=1.0), - ], - additional_messages=[ - {"role": "user", "content": "crawl asynchronously https://www.google.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "type": "function", - "function": { - "name": "Firecrawl_CrawlWebsite", - "arguments": '{"url":"https://www.google.com","async_crawl":true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}', - "tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "name": "Firecrawl_CrawlWebsite", - }, - { - "role": "assistant", - "content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).", - }, - ], - ) - - # Cancel Crawl - suite.add_case( - name="Get crawl status", - user_message="Actually cancel it.", - expected_tool_calls=[ - ExpectedToolCall( - func=cancel_crawl, - args={ - "crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="crawl_id", weight=1.0), - ], - additional_messages=[ - {"role": "user", "content": "crawl asynchronously https://www.google.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "type": "function", - "function": { - "name": "Firecrawl_CrawlWebsite", - "arguments": '{"url":"https://www.google.com","async_crawl":true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}', - "tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "name": "Firecrawl_CrawlWebsite", - }, - { - "role": "assistant", - "content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).", - }, - ], - ) - - # Map Website - suite.add_case( - name="Map a website", - user_message="Map the website at https://wikipedia.com with a limit of 100000 links. Only the links that are about the topic of AI", - expected_tool_calls=[ - ExpectedToolCall( - func=map_website, - args={ - "url": "https://wikipedia.com", - "search": "AI", - "limit": 100000, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="url", weight=0.4), - SimilarityCritic(critic_field="search", weight=0.2), - NumericCritic(critic_field="limit", weight=0.4, value_range=(90000, 110000)), - ], - ) - - return suite diff --git a/toolkits/firecrawl/pyproject.toml b/toolkits/firecrawl/pyproject.toml deleted file mode 100644 index 79b3790d..00000000 --- a/toolkits/firecrawl/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_firecrawl" -version = "2.0.0" -description = "Arcade.dev LLM tools for web scraping related tasks via Firecrawl" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "firecrawl-py>=1.3.1,<2.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_firecrawl/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_firecrawl",] diff --git a/toolkits/firecrawl/tests/__init__.py b/toolkits/firecrawl/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/firecrawl/tests/test_firecrawl.py b/toolkits/firecrawl/tests/test_firecrawl.py deleted file mode 100644 index ca248855..00000000 --- a/toolkits/firecrawl/tests/test_firecrawl.py +++ /dev/null @@ -1,129 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem -from arcade_tdk.errors import ToolExecutionError - -from arcade_firecrawl.tools import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, - map_website, - scrape_url, -) - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="firecrawl_api_key", value="fake_api_key")]) - - -@pytest.fixture -def mock_firecrawl_app_for_scrape(): - with patch("arcade_firecrawl.tools.scrape.FirecrawlApp") as app: - yield app.return_value - - -@pytest.fixture -def mock_firecrawl_app_for_crawl(): - with patch("arcade_firecrawl.tools.crawl.FirecrawlApp") as app: - yield app.return_value - - -@pytest.fixture -def mock_firecrawl_app_for_map(): - with patch("arcade_firecrawl.tools.map.FirecrawlApp") as app: - yield app.return_value - - -@pytest.mark.asyncio -async def test_scrape_url_success(mock_firecrawl_app_for_scrape, mock_context): - expected_response = { - "success": True, - "data": {"scraped_content": "scraped content"}, - } - mock_firecrawl_app_for_scrape.scrape_url.return_value = expected_response - - result = await scrape_url(mock_context, "http://example.com") - assert result == expected_response - - -@pytest.mark.asyncio -async def test_crawl_website_success(mock_firecrawl_app_for_crawl, mock_context): - expected_response = { - "id": "12345", - "success": True, - } - mock_firecrawl_app_for_crawl.async_crawl_url.return_value = expected_response - mock_firecrawl_app_for_crawl.check_crawl_status.return_value = expected_response - - result = await crawl_website(mock_context, "http://example.com") - assert result == expected_response - - -@pytest.mark.asyncio -async def test_get_crawl_status_success(mock_firecrawl_app_for_crawl, mock_context): - expected_response = {"status": "completed"} - mock_firecrawl_app_for_crawl.check_crawl_status.return_value = expected_response - - result = await get_crawl_status(mock_context, "12345") - assert result == expected_response - - -@pytest.mark.asyncio -async def test_get_crawl_data_success(mock_firecrawl_app_for_crawl, mock_context): - expected_response = {"data": "crawl data"} - mock_firecrawl_app_for_crawl.check_crawl_status.return_value = expected_response - - result = await get_crawl_data(mock_context, "12345") - assert result == expected_response - - -@pytest.mark.asyncio -async def test_cancel_crawl_success(mock_firecrawl_app_for_crawl, mock_context): - expected_response = {"status": "cancelled"} - mock_firecrawl_app_for_crawl.cancel_crawl.return_value = expected_response - - result = await cancel_crawl(mock_context, "12345") - assert result == expected_response - - -@pytest.mark.asyncio -async def test_map_website_success(mock_firecrawl_app_for_map, mock_context): - expected_response = {"map": "website map"} - mock_firecrawl_app_for_map.map_url.return_value = expected_response - - result = await map_website(mock_context, "http://example.com") - assert result == expected_response - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "method,params,error_message", - [ - (scrape_url, ("http://example.com",), "Error scraping URL"), - (crawl_website, ("http://example.com",), "Error crawling website"), - (get_crawl_status, ("12345",), "Error getting crawl status"), - (get_crawl_data, ("12345",), "Error getting crawl data"), - (cancel_crawl, ("12345",), "Error cancelling crawl"), - (map_website, ("http://example.com",), "Error mapping website"), - ], -) -async def test_firecrawl_error( - mock_firecrawl_app_for_scrape, - mock_firecrawl_app_for_crawl, - mock_firecrawl_app_for_map, - mock_context, - method, - params, - error_message, -): - mock_firecrawl_app_for_scrape.scrape_url.side_effect = Exception(error_message) - mock_firecrawl_app_for_crawl.async_crawl_url.side_effect = Exception(error_message) - mock_firecrawl_app_for_crawl.check_crawl_status.side_effect = Exception(error_message) - mock_firecrawl_app_for_crawl.cancel_crawl.side_effect = Exception(error_message) - mock_firecrawl_app_for_map.map_url.side_effect = Exception(error_message) - - with pytest.raises(ToolExecutionError): - await method(mock_context, *params) diff --git a/toolkits/github/.pre-commit-config.yaml b/toolkits/github/.pre-commit-config.yaml deleted file mode 100644 index 748252a3..00000000 --- a/toolkits/github/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/github/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/github/.ruff.toml b/toolkits/github/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/github/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/github/LICENSE b/toolkits/github/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/github/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/github/Makefile b/toolkits/github/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/github/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/github/arcade_github/__init__.py b/toolkits/github/arcade_github/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/github/arcade_github/tools/__init__.py b/toolkits/github/arcade_github/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/github/arcade_github/tools/activity.py b/toolkits/github/arcade_github/tools/activity.py deleted file mode 100644 index b889dfba..00000000 --- a/toolkits/github/arcade_github/tools/activity.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import GitHub - -from arcade_github.tools.utils import get_github_json_headers, get_url, handle_github_response - - -# Implements https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#star-a-repository-for-the-authenticated-user and https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#unstar-a-repository-for-the-authenticated-user # noqa: E501 -# Example `arcade chat` usage: "star the vscode repo owned by microsoft" -@tool(requires_auth=GitHub()) -async def set_starred( - context: ToolContext, - owner: Annotated[str, "The owner of the repository"], - name: Annotated[str, "The name of the repository"], - starred: Annotated[bool, "Whether to star the repository or not"] = True, -) -> Annotated[ - str, "A message indicating whether the repository was successfully starred or unstarred" -]: - """ - Star or un-star a GitHub repository. - For example, to star microsoft/vscode, you would use: - ``` - set_starred(owner="microsoft", name="vscode", starred=True) - ``` - """ - url = get_url("user_starred", owner=owner, repo=name) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - if starred: - response = await client.put(url, headers=headers) - else: - response = await client.delete(url, headers=headers) - - handle_github_response(response, url) - - action = "starred" if starred else "unstarred" - return f"Successfully {action} the repository {owner}/{name}" - - -# Implements https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#list-stargazers -# Example `arcade chat` usage: "list the stargazers for the ArcadeAI/arcade-ai repo" -@tool(requires_auth=GitHub()) -async def list_stargazers( - context: ToolContext, - owner: Annotated[str, "The owner of the repository"], - repo: Annotated[str, "The name of the repository"], - limit: Annotated[ - int | None, - "The maximum number of stargazers to return. " - "If not provided, all stargazers will be returned.", - ] = None, -) -> Annotated[dict, "A dictionary containing the stargazers for the specified repository"]: - """List the stargazers for a GitHub repository.""" - url = get_url("repo_stargazers", owner=owner, repo=repo) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - if limit is None: - limit = 2**64 - 1 - - per_page = min(limit, 100) - page = 1 - stargazers: list[dict] = [] - - async with httpx.AsyncClient() as client: - while len(stargazers) < limit: - response = await client.get( - url, headers=headers, params={"per_page": per_page, "page": page} - ) - handle_github_response(response, url) - - data = response.json() - if not data: - break - - stargazers.extend([ - { - "login": stargazer.get("login"), - "id": stargazer.get("id"), - "node_id": stargazer.get("node_id"), - "html_url": stargazer.get("html_url"), - } - for stargazer in data - ]) - - if len(data) < per_page: - break - - page += 1 - - stargazers = stargazers[:limit] - return {"number_of_stargazers": len(stargazers), "stargazers": stargazers} diff --git a/toolkits/github/arcade_github/tools/constants.py b/toolkits/github/arcade_github/tools/constants.py deleted file mode 100644 index ee7e4eb6..00000000 --- a/toolkits/github/arcade_github/tools/constants.py +++ /dev/null @@ -1,19 +0,0 @@ -# Base URL for GitHub API -GITHUB_API_BASE_URL = "https://api.github.com" - -# Endpoint patterns -ENDPOINTS = { - "repo": "/repos/{owner}/{repo}", - "org_repos": "/orgs/{org}/repos", - "repo_activity": "/repos/{owner}/{repo}/activity", - "repo_pulls_comments": "/repos/{owner}/{repo}/pulls/comments", - "repo_issues": "/repos/{owner}/{repo}/issues", - "repo_issue_comments": "/repos/{owner}/{repo}/issues/{issue_number}/comments", - "repo_pulls": "/repos/{owner}/{repo}/pulls", - "repo_pull": "/repos/{owner}/{repo}/pulls/{pull_number}", - "repo_pull_commits": "/repos/{owner}/{repo}/pulls/{pull_number}/commits", - "repo_pull_comments": "/repos/{owner}/{repo}/pulls/{pull_number}/comments", - "repo_pull_comment_replies": "/repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies", # noqa: E501 - "user_starred": "/user/starred/{owner}/{repo}", - "repo_stargazers": "/repos/{owner}/{repo}/stargazers", -} diff --git a/toolkits/github/arcade_github/tools/issues.py b/toolkits/github/arcade_github/tools/issues.py deleted file mode 100644 index bfbdaed8..00000000 --- a/toolkits/github/arcade_github/tools/issues.py +++ /dev/null @@ -1,158 +0,0 @@ -import json -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import GitHub - -from arcade_github.tools.utils import ( - get_github_json_headers, - get_url, - handle_github_response, - remove_none_values, -) - - -# Implements https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue -# Example `arcade chat` usage: -# "create an issue in the repo owned by titled -# 'Found a bug' with the body 'I'm having a problem with this.' -# Assign it to and label it 'bug'" -@tool(requires_auth=GitHub()) -async def create_issue( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - title: Annotated[str, "The title of the issue."], - body: Annotated[str | None, "The contents of the issue."] = None, - assignees: Annotated[list[str] | None, "Logins for Users to assign to this issue."] = None, - milestone: Annotated[ - int | None, "The number of the milestone to associate this issue with." - ] = None, - labels: Annotated[list[str] | None, "Labels to associate with this issue."] = None, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the pull requests. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - str, - "A JSON string containing the created issue's details, including id, url, title, body, state, " - "html_url, creation and update timestamps, user, assignees, and labels. " - "If include_extra_data is True, returns all available data about the issue.", -]: - """ - Create an issue in a GitHub repository. - - Example: - ``` - create_issue( - owner="octocat", - repo="Hello-World", - title="Found a bug", - body="I'm having a problem with this.", - assignees=["octocat"], - milestone=1, - labels=["bug"], - ) - ``` - """ - url = get_url("repo_issues", owner=owner, repo=repo) - data = { - "title": title, - "body": body, - "labels": labels, - "milestone": milestone, - "assignees": assignees, - } - data = remove_none_values(data) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - - handle_github_response(response, url) - - issue_data = response.json() - if include_extra_data: - return json.dumps(issue_data) - - important_info = { - "id": issue_data.get("id"), - "url": issue_data.get("url"), - "title": issue_data.get("title"), - "body": issue_data.get("body"), - "state": issue_data.get("state"), - "html_url": issue_data.get("html_url"), - "created_at": issue_data.get("created_at"), - "updated_at": issue_data.get("updated_at"), - "user": issue_data.get("user", {}).get("login"), - "assignees": [assignee.get("login") for assignee in issue_data.get("assignees", [])], - "labels": [label.get("name") for label in issue_data.get("labels", [])], - } - return json.dumps(important_info) - - -# Implements https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment -# Example `arcade chat` usage: -# "create a comment in the vscode repo owned by microsoft for issue 1347 that says 'Me too'" -@tool(requires_auth=GitHub()) -async def create_issue_comment( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - issue_number: Annotated[int, "The number that identifies the issue."], - body: Annotated[str, "The contents of the comment."], - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the pull requests. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - str, - "A JSON string containing the created comment's details, including id, url, body, user, " - "and creation and update timestamps. If include_extra_data is True, returns all available " - "data about the comment.", -]: - """ - Create a comment on an issue in a GitHub repository. - - Example: - ``` - create_issue_comment(owner="octocat", repo="Hello-World", issue_number=1347, body="Me too") - ``` - """ - url = get_url("repo_issue_comments", owner=owner, repo=repo, issue_number=issue_number) - data = { - "body": body, - } - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - - handle_github_response(response, url) - - comment_data = response.json() - if include_extra_data: - return json.dumps(comment_data) - - important_info = { - "id": comment_data.get("id"), - "url": comment_data.get("url"), - "body": comment_data.get("body"), - "user": comment_data.get("user", {}).get("login"), - "created_at": comment_data.get("created_at"), - "updated_at": comment_data.get("updated_at"), - } - return json.dumps(important_info) diff --git a/toolkits/github/arcade_github/tools/models.py b/toolkits/github/arcade_github/tools/models.py deleted file mode 100644 index cc996d7b..00000000 --- a/toolkits/github/arcade_github/tools/models.py +++ /dev/null @@ -1,99 +0,0 @@ -from enum import Enum - - -# Pull Request specific -class PRSortProperty(str, Enum): - CREATED = "created" - UPDATED = "updated" - POPULARITY = "popularity" - LONG_RUNNING = "long-running" - - -class PRState(str, Enum): - OPEN = "open" - CLOSED = "closed" - ALL = "all" - - -class ReviewCommentSortProperty(str, Enum): - CREATED = "created" - UPDATED = "updated" - - -class ReviewCommentSubjectType(str, Enum): - FILE = "file" - LINE = "line" - - -class DiffSide(str, Enum): - """ - The side of the diff that the pull request's changes appear on. - Use LEFT for deletions that appear in red. - Use RIGHT for additions that appear in green or unchanged - lines that appear in white and are shown for context - """ - - LEFT = "LEFT" - RIGHT = "RIGHT" - - -# Repo specific -class RepoType(str, Enum): - """ - The types of repositories you want returned when listing organization repositories. - Default is all repositories. - """ - - ALL = "all" - PUBLIC = "public" - PRIVATE = "private" - FORKS = "forks" - SOURCES = "sources" - MEMBER = "member" - - -class RepoSortProperty(str, Enum): - """ - The property to sort the results by when listing organization repositories. - Default is created. - """ - - CREATED = "created" - UPDATED = "updated" - PUSHED = "pushed" - FULL_NAME = "full_name" - - -class RepoTimePeriod(str, Enum): - """ - The time period to filter by when listing repository activities. - """ - - DAY = "day" - WEEK = "week" - MONTH = "month" - QUARTER = "quarter" - YEAR = "year" - - -class ActivityType(str, Enum): - """ - The activity type to filter by when listing repository activities. - """ - - PUSH = "push" - FORCE_PUSH = "force_push" - BRANCH_CREATION = "branch_creation" - BRANCH_DELETION = "branch_deletion" - PR_MERGE = "pr_merge" - MERGE_QUEUE_MERGE = "merge_queue_merge" - - -class SortDirection(str, Enum): - """ - The order to sort by when listing organization repositories. - Default is asc. - """ - - ASC = "asc" - DESC = "desc" diff --git a/toolkits/github/arcade_github/tools/pull_requests.py b/toolkits/github/arcade_github/tools/pull_requests.py deleted file mode 100644 index 14e99e5e..00000000 --- a/toolkits/github/arcade_github/tools/pull_requests.py +++ /dev/null @@ -1,625 +0,0 @@ -import json -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import GitHub -from arcade_tdk.errors import RetryableToolError - -from arcade_github.tools.models import ( - DiffSide, - PRSortProperty, - PRState, - ReviewCommentSortProperty, - ReviewCommentSubjectType, - SortDirection, -) -from arcade_github.tools.utils import ( - get_github_diff_headers, - get_github_json_headers, - get_url, - handle_github_response, - remove_none_values, -) - - -# Implements https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests -# Example `arcade chat` usage: -# "get all open PRs that has that are in the / repo" -# TODO: Validate owner/repo combination is valid for the authenticated user. -# If not, return RetryableToolError with available repos. -# TODO: list repo's branches and validate base is in the list (or default to main). -# If not, return RetryableToolError with available branches. -@tool(requires_auth=GitHub()) -async def list_pull_requests( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - state: Annotated[PRState | None, "The state of the pull requests to return."] = PRState.OPEN, - head: Annotated[ - str | None, - "Filter pulls by head user or head organization and branch name in the format of " - "user:ref-name or organization:ref-name.", - ] = None, - base: Annotated[str | None, "Filter pulls by base branch name."] = "main", - sort: Annotated[ - PRSortProperty | None, "The property to sort the results by." - ] = PRSortProperty.CREATED, - direction: Annotated[SortDirection | None, "The direction of the sort."] = None, - per_page: Annotated[int, "The number of results per page (max 100)."] = 30, - page: Annotated[int, "The page number of the results to fetch."] = 1, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the pull requests. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[str, "JSON string containing a list of pull requests with their details"]: - """ - List pull requests in a GitHub repository. - - Example: - ``` - list_pull_requests(owner="octocat", repo="Hello-World", state=PRState.OPEN, sort=PRSort.UPDATED) - ``` - """ - url = get_url("repo_pulls", owner=owner, repo=repo) - params = { - "base": base, - "state": state.value if state else None, - "sort": sort.value if sort else None, - "per_page": min(max(1, per_page), 100), # clamp per_page to 1-100 - "page": page, - "head": head, - "direction": direction, # defaults to desc when sort is 'created'/'not specified', else asc - } - params = remove_none_values(params) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params) - - handle_github_response(response, url) - - pull_requests = response.json() - results = [] - for pr in pull_requests: - if include_extra_data: - results.append(pr) - continue - results.append({ - "number": pr.get("number"), - "title": pr.get("title"), - "body": pr.get("body"), - "state": pr.get("state"), - "html_url": pr.get("html_url"), - "diff_url": pr.get("diff_url"), - "created_at": pr.get("created_at"), - "updated_at": pr.get("updated_at"), - "user": pr.get("user", {}).get("login"), - "base": pr.get("base", {}).get("ref"), - "head": pr.get("head", {}).get("ref"), - }) - return json.dumps({"pull_requests": results}) - - -# Implements https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request -# Example `arcade chat` usage: -# "get the PR #72 in the / repo. Include diff content in your response." -@tool(requires_auth=GitHub()) -async def get_pull_request( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - pull_number: Annotated[int, "The number that identifies the pull request."], - include_diff_content: Annotated[ - bool | None, - "If true, return the diff content of the pull request.", - ] = False, - include_extra_data: Annotated[ - bool | None, - "If true, return all the data available about the pull requests. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - str, - "JSON string containing details of the specified pull request, " - "optionally including diff content", -]: - """ - Get details of a pull request in a GitHub repository. - - Example: - ``` - get_pull_request(owner="octocat", repo="Hello-World", pull_number=1347) - ``` - """ - url = get_url("repo_pull", owner=owner, repo=repo, pull_number=pull_number) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - diff_headers = get_github_diff_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - if include_diff_content: - diff_response = await client.get(url, headers=diff_headers) - - handle_github_response(response, url) - - if include_diff_content: - handle_github_response(diff_response, url) - - pr_data = response.json() - - if include_extra_data: - result = pr_data - if include_diff_content: - result["diff_content"] = diff_response.content.decode("utf-8") - return json.dumps(result) - - important_info = { - "number": pr_data.get("number"), - "title": pr_data.get("title"), - "body": pr_data.get("body"), - "state": pr_data.get("state"), - "html_url": pr_data.get("html_url"), - "diff_url": pr_data.get("diff_url"), - "created_at": pr_data.get("created_at"), - "updated_at": pr_data.get("updated_at"), - "user": pr_data.get("user", {}).get("login"), - "base": pr_data.get("base", {}).get("ref"), - "head": pr_data.get("head", {}).get("ref"), - } - - if include_diff_content: - important_info["diff_content"] = diff_response.content.decode("utf-8") - - return json.dumps(important_info) - - -# Implements https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#update-a-pull-request -# Example `arcade chat` usage: -# "update PR #72 in the / repo by changing the title to 'New Title' and -# setting the body to 'This PR description was added via arcade chat!'." -# TODO: Enable this tool to append to the PR contents instead of only replacing content. -@tool(requires_auth=GitHub()) -async def update_pull_request( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - pull_number: Annotated[int, "The number that identifies the pull request."], - title: Annotated[str | None, "The title of the pull request."] = None, - body: Annotated[str | None, "The contents of the pull request."] = None, - state: Annotated[PRState | None, "State of this Pull Request. Either open or closed."] = None, - base: Annotated[str | None, "The name of the branch you want your changes pulled into."] = None, - maintainer_can_modify: Annotated[ - bool | None, "Indicates whether maintainers can modify the pull request." - ] = None, -) -> Annotated[str, "JSON string containing updated information about the pull request"]: - """ - Update a pull request in a GitHub repository. - - Example: - ``` - update_pull_request( - owner="octocat", - repo="Hello-World", - pull_number=1347, - title="new title", - body="updated body", - ) - ``` - """ - url = get_url("repo_pull", owner=owner, repo=repo, pull_number=pull_number) - - data = { - "title": title, - "body": body, - "state": state.value if state else None, - "base": base, - "maintainer_can_modify": maintainer_can_modify, - } - data = remove_none_values(data) - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.patch(url, headers=headers, json=data) - - handle_github_response(response, url) - - pr_data = response.json() - important_info = { - "url": pr_data.get("url"), - "id": pr_data.get("id"), - "html_url": pr_data.get("html_url"), - "number": pr_data.get("number"), - "state": pr_data.get("state"), - "title": pr_data.get("title"), - "user": pr_data.get("user", {}).get("login"), - "body": pr_data.get("body"), - "created_at": pr_data.get("created_at"), - "updated_at": pr_data.get("updated_at"), - } - return json.dumps(important_info) - - -# Implements https://docs.github.com/en/rest/pulls/commits?apiVersion=2022-11-28#list-commits-on-a-pull-request -# Example `arcade chat` usage: "list all of the commits for the PR 72 in the / repo" -@tool(requires_auth=GitHub()) -async def list_pull_request_commits( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - pull_number: Annotated[int, "The number that identifies the pull request."], - per_page: Annotated[int, "The number of results per page (max 100)."] = 30, - page: Annotated[int, "The page number of the results to fetch."] = 1, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the pull requests. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[str, "JSON string containing a list of commits for the specified pull request"]: - """ - List commits (from oldest to newest) on a pull request in a GitHub repository. - - Example: - ``` - list_pull_request_commits(owner="octocat", repo="Hello-World", pull_number=1347) - ``` - """ - url = get_url("repo_pull_commits", owner=owner, repo=repo, pull_number=pull_number) - - params = { - "per_page": max(1, min(100, per_page)), # clamp per_page to 1-100 - "page": page, - } - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params) - - handle_github_response(response, url) - - commits = response.json() - if include_extra_data: - return json.dumps({"commits": commits}) - - filtered_commits = [] - for commit in commits: - filtered_commit = { - "sha": commit.get("sha"), - "html_url": commit.get("html_url"), - "diff_url": commit.get("html_url") + ".diff" if commit.get("html_url") else None, - "commit": { - "message": commit.get("commit", {}).get("message"), - "author": commit.get("commit", {}).get("author", {}).get("name"), - "committer": commit.get("commit", {}).get("committer", {}).get("name"), - "date": commit.get("commit", {}).get("committer", {}).get("date"), - }, - "author": commit.get("author", {}).get("login"), - "committer": commit.get("committer", {}).get("login"), - } - filtered_commits.append(filtered_commit) - - return json.dumps({"commits": filtered_commits}) - - -# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-reply-for-a-review-comment -# Example `arcade chat` usage: -# "create a reply to the review comment 1778019974 in arcadeai/arcade-ai for -# the PR 72 that says 'Thanks for the suggestion.'" -# Note: This tool requires the ID of the review comment to reply to. To obtain this ID, -# you should first call the `list_review_comments_on_pull_request` function. -# The returned JSON will contain the `id` field for each comment, which can be used as the -# `comment_id` parameter in this function. -@tool(requires_auth=GitHub()) -async def create_reply_for_review_comment( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - pull_number: Annotated[int, "The number that identifies the pull request."], - comment_id: Annotated[int, "The unique identifier of the comment."], - body: Annotated[str, "The text of the review comment."], -) -> Annotated[str, "JSON string containing details of the created reply comment"]: - """ - Create a reply to a review comment for a pull request. - - Example: - ``` - create_reply_for_review_comment( - owner="octocat", - repo="Hello-World", - pull_number=1347, - comment_id=42, - body="Looks good to me!", - ) - ``` - """ - url = get_url( - "repo_pull_comment_replies", - owner=owner, - repo=repo, - pull_number=pull_number, - comment_id=comment_id, - ) - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - data = {"body": body} - - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - - handle_github_response(response, url) - - return json.dumps(response.json()) - - -# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#list-review-comments-on-a-pull-request -# Example `arcade chat` usage: "list all of the review comments for PR 72 in /" -@tool(requires_auth=GitHub()) -async def list_review_comments_on_pull_request( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - pull_number: Annotated[int, "The number that identifies the pull request."], - sort: Annotated[ - ReviewCommentSortProperty | None, - "The property to sort the results by. Can be one of: created, updated.", - ] = ReviewCommentSortProperty.CREATED, - direction: Annotated[ - SortDirection | None, "The direction to sort results. Can be one of: asc, desc." - ] = SortDirection.DESC, - since: Annotated[ - str | None, - "Only show results that were last updated after the given time. " - "This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.", - ] = None, - per_page: Annotated[int, "The number of results per page (max 100)."] = 30, - page: Annotated[int, "The page number of the results to fetch."] = 1, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the review comments. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - str, "JSON string containing a list of review comments for the specified pull request" -]: - """ - List review comments on a pull request in a GitHub repository. - - Example: - ``` - list_review_comments_on_pull_request(owner="octocat", repo="Hello-World", pull_number=1347) - ``` - """ - url = get_url("repo_pull_comments", owner=owner, repo=repo, pull_number=pull_number) - - params = { - "sort": sort, - "direction": direction, - "per_page": max(1, min(100, per_page)), # clamp per_page to 1-100 - "page": page, - "since": since, - } - params = remove_none_values(params) - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params) - - handle_github_response(response, url) - - review_comments = response.json() - if include_extra_data: - return json.dumps(review_comments) - - filtered_comments = [] - for comment in review_comments: - filtered_comment = { - "id": comment.get("id"), - "url": comment.get("url"), - "diff_hunk": comment.get("diff_hunk"), - "path": comment.get("path"), - "position": comment.get("position"), - "original_position": comment.get("original_position"), - "commit_id": comment.get("commit_id"), - "original_commit_id": comment.get("original_commit_id"), - "in_reply_to_id": comment.get("in_reply_to_id"), - "user": comment.get("user", {}).get("login"), - "body": comment.get("body"), - "created_at": comment.get("created_at"), - "updated_at": comment.get("updated_at"), - "html_url": comment.get("html_url"), - "line": comment.get("line"), - "side": comment.get("side"), - "pull_request_url": comment.get("pull_request_url"), - } - filtered_comments.append(filtered_comment) - return json.dumps({"review_comments": filtered_comments}) - - -# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-review-comment-for-a-pull-request -# Example `arcade chat` usage: "create a review comment for PR 72 in / that says -# 'Great stuff! This looks good to merge. Add the comment to README.md file.'" -# TODO: Verify that path parameter exists in the PR's files that have changed -# (Or should we allow for any file in the repo?). -# If not, then throw RetryableToolError with all valid file paths. -@tool(requires_auth=GitHub()) -async def create_review_comment( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - pull_number: Annotated[int, "The number that identifies the pull request."], - body: Annotated[str, "The text of the review comment."], - path: Annotated[str, "The relative path to the file that necessitates a comment."], - commit_id: Annotated[ - str | None, - "The SHA of the commit needing a comment. If not provided, the latest commit SHA of the " - "PR's base branch will be used.", - ] = None, - start_line: Annotated[ - int | None, - "The start line of the range of lines in the pull request diff that the " - "comment applies to. Required unless 'subject_type' is 'file'.", - ] = None, - end_line: Annotated[ - int | None, - "The end line of the range of lines in the pull request diff that the " - "comment applies to. Required unless 'subject_type' is 'file'.", - ] = None, - side: Annotated[ - DiffSide | None, - "The side of the diff that the pull request's changes appear on. " - "Use LEFT for deletions that appear in red. Use RIGHT for additions that appear in green " - "or unchanged lines that appear in white and are shown for context", - ] = DiffSide.RIGHT, - start_side: Annotated[ - str | None, "The starting side of the diff that the comment applies to." - ] = None, - subject_type: Annotated[ - ReviewCommentSubjectType | None, - "The type of subject that the comment applies to. Can be one of: file, hunk, or line.", - ] = ReviewCommentSubjectType.FILE, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the review comment. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[str, "JSON string containing details of the created review comment"]: - """ - Create a review comment for a pull request in a GitHub repository. - - If the subject_type is not 'file', then the start_line and end_line parameters are required. - If the subject_type is 'file', then the start_line and end_line parameters are ignored. - If the commit_id is not provided, the latest commit SHA of the PR's base branch will be used. - - Example: - ``` - create_review_comment( - owner="octocat", - repo="Hello-World", - pull_number=1347, - body="Great stuff!", - commit_id="6dcb09b5b57875f334f61aebed695e2e4193db5e", - path="file1.txt", - line=2, - side="RIGHT" - ) - ``` - """ - # If the subject_type is 'file', then the line_range parameter is ignored - if subject_type == ReviewCommentSubjectType.FILE: - start_line, end_line = None, None - - if (start_line is None or end_line is None) and subject_type != ReviewCommentSubjectType.FILE: - message = ( - "'start_line' and 'end_line' parameters are required when 'subject_type' " - "parameter is not 'file'. Either provide both a start_line and end_line or set " - "subject_type to 'file'." - ) - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=message, - ) - - # Ensure the line range goes from lowest to highest - if start_line is not None and end_line is not None: - start_line, end_line = (min(start_line, end_line), max(start_line, end_line)) - - # Get the latest commit SHA of the PR's base branch and use that for the commit_id - if not commit_id: - commits_json = await list_pull_request_commits(context, owner, repo, pull_number) - commits_data = json.loads(commits_json) - commits = commits_data.get("commits", []) - latest_commit = commits[-1] if commits else {} - commit_id = latest_commit.get("sha") - - if not commit_id: - message = ( - f"Failed to get the latest commit SHA of PR {pull_number} in repo {repo} owned by " - "{owner}. Does the PR exist?" - ) - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=message, - ) - - url = get_url("repo_pull_comments", owner=owner, repo=repo, pull_number=pull_number) - data = { - "body": body, - "commit_id": commit_id, - "path": path, - "side": side, - "line": end_line if end_line else None, - "start_line": start_line - if start_line and start_line != end_line - else None, # Only send start_line when using multi-line comments - "start_side": start_side, - } - data = remove_none_values(data) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - - handle_github_response(response, url) - - comment_data = response.json() - if include_extra_data: - return json.dumps(comment_data) - - important_info = { - "id": comment_data.get("id"), - "url": comment_data.get("url"), - "body": comment_data.get("body"), - "path": comment_data.get("path"), - "line": comment_data.get("line"), - "side": comment_data.get("side"), - "commit_id": comment_data.get("commit_id"), - "user": comment_data.get("user", {}).get("login"), - "created_at": comment_data.get("created_at"), - "updated_at": comment_data.get("updated_at"), - "html_url": comment_data.get("html_url"), - } - return json.dumps(important_info) diff --git a/toolkits/github/arcade_github/tools/repositories.py b/toolkits/github/arcade_github/tools/repositories.py deleted file mode 100644 index d8057cd6..00000000 --- a/toolkits/github/arcade_github/tools/repositories.py +++ /dev/null @@ -1,373 +0,0 @@ -import json -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import GitHub - -from arcade_github.tools.models import ( - ActivityType, - RepoSortProperty, - RepoTimePeriod, - RepoType, - ReviewCommentSortProperty, - SortDirection, -) -from arcade_github.tools.utils import ( - get_github_json_headers, - get_url, - handle_github_response, - remove_none_values, -) - - -# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository -# and returns only the stargazers_count field. -# Example arcade chat usage: "How many stargazers does the / repo have?" -@tool(requires_auth=GitHub()) -async def count_stargazers( - context: ToolContext, - owner: Annotated[str, "The owner of the repository"], - name: Annotated[str, "The name of the repository"], -) -> Annotated[int, "The number of stargazers (stars) for the specified repository"]: - """Count the number of stargazers (stars) for a GitHub repository. - For example, to count the number of stars for microsoft/vscode, you would use: - ``` - count_stargazers(owner="microsoft", name="vscode") - ``` - """ - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - url = get_url("repo", owner=owner, repo=name) - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - - handle_github_response(response, url) - - data = response.json() - stargazers_count = data.get("stargazers_count", 0) - return int(stargazers_count) - - -# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-organization-repositories -# Example arcade chat usage: -# "List all repositories for the organization. Sort by creation date in descending order." -@tool(requires_auth=GitHub()) -async def list_org_repositories( - context: ToolContext, - org: Annotated[str, "The organization name. The name is not case sensitive"], - repo_type: Annotated[RepoType, "The types of repositories you want returned."] = RepoType.ALL, - sort: Annotated[ - RepoSortProperty, "The property to sort the results by" - ] = RepoSortProperty.CREATED, - sort_direction: Annotated[SortDirection, "The order to sort by"] = SortDirection.ASC, - per_page: Annotated[int, "The number of results per page"] = 30, - page: Annotated[int, "The page number of the results to fetch"] = 1, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the repositories. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - dict[str, list[dict]], - "A dictionary with key 'repositories' containing a list of repositories, each with details " - "such as name, full_name, html_url, description, clone_url, private status, " - "creation/update/push timestamps, and star/watcher/fork counts", -]: - """List repositories for the specified organization.""" - url = get_url("org_repos", org=org) - params = { - "type": repo_type.value, - "sort": sort.value, - "direction": sort_direction.value, - "per_page": per_page, - "page": page, - } - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params) - - handle_github_response(response, url) - - repos = response.json() - if include_extra_data: - return {"repositories": repos} - - results = [] - for repo in repos: - results.append({ - "name": repo["name"], - "full_name": repo["full_name"], - "html_url": repo["html_url"], - "description": repo["description"], - "clone_url": repo["clone_url"], - "private": repo["private"], - "created_at": repo["created_at"], - "updated_at": repo["updated_at"], - "pushed_at": repo["pushed_at"], - "stargazers_count": repo["stargazers_count"], - "watchers_count": repo["watchers_count"], - "forks_count": repo["forks_count"], - }) - - return {"repositories": results} - - -# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository -# Example arcade chat usage: "Tell me about the / repo." -@tool(requires_auth=GitHub()) -async def get_repository( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the repository. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - dict, - "A dictionary containing repository details such as name, full_name, html_url, description, " - "clone_url, private status, creation/update/push timestamps, and star/watcher/fork counts", -]: - """Get a repository. - - Retrieves detailed information about a repository using the GitHub API. - - Example: - ``` - get_repository(owner="octocat", repo="Hello-World") - ``` - """ - url = get_url("repo", owner=owner, repo=repo) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - - handle_github_response(response, url) - - repo_data = response.json() - if include_extra_data: - return dict(repo_data) - - return { - "name": repo_data["name"], - "full_name": repo_data["full_name"], - "html_url": repo_data["html_url"], - "description": repo_data["description"], - "clone_url": repo_data["clone_url"], - "private": repo_data["private"], - "created_at": repo_data["created_at"], - "updated_at": repo_data["updated_at"], - "pushed_at": repo_data["pushed_at"], - "stargazers_count": repo_data["stargazers_count"], - "watchers_count": repo_data["watchers_count"], - "forks_count": repo_data["forks_count"], - } - - -# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-activities -# Example arcade chat usage: -# "List all merges into main for the / repo in the last week by " -@tool(requires_auth=GitHub()) -async def list_repository_activities( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - direction: Annotated[ - SortDirection | None, "The direction to sort the results by." - ] = SortDirection.DESC, - per_page: Annotated[int, "The number of results per page (max 100)."] = 30, - before: Annotated[ - str | None, - "A cursor (unique ID, e.g., a SHA of a commit) to search for results before this cursor.", - ] = None, - after: Annotated[ - str | None, - "A cursor (unique ID, e.g., a SHA of a commit) to search for results after this cursor.", - ] = None, - ref: Annotated[ - str | None, - "The Git reference for the activities you want to list. The ref for a branch can be " - "formatted either as refs/heads/BRANCH_NAME or BRANCH_NAME, where BRANCH_NAME is the name " - "of your branch.", - ] = None, - actor: Annotated[ - str | None, "The GitHub username to filter by the actor who performed the activity." - ] = None, - time_period: Annotated[RepoTimePeriod | None, "The time period to filter by."] = None, - activity_type: Annotated[ActivityType | None, "The activity type to filter by."] = None, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the repository activities. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - str, - "A JSON string containing a dictionary with key 'activities', which is a list of repository " - "activities. Each activity includes id, node_id, before and after states, ref, timestamp, " - "activity_type, and actor information", -]: - """List repository activities. - - Retrieves a detailed history of changes to a repository, such as pushes, merges, - force pushes, and branch changes, and associates these changes with commits and users. - - Example: - ``` - list_repository_activities( - owner="octocat", - repo="Hello-World", - per_page=10, - activity_type="force_push" - ) - ``` - """ - url = get_url("repo_activity", owner=owner, repo=repo) - params = { - "direction": direction.value if direction else None, - "per_page": min(100, per_page), # The API only allows up to 100 per page - "before": before, - "after": after, - "ref": ref, - "actor": actor, - "time_period": time_period, - "activity_type": activity_type, - } - params = remove_none_values(params) - - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params) - - handle_github_response(response, url) - - activities = response.json() - if include_extra_data: - return json.dumps({"activities": activities}) - - results = [] - for activity in activities: - results.append({ - "id": activity["id"], - "node_id": activity["node_id"], - "before": activity.get("before"), - "after": activity.get("after"), - "ref": activity.get("ref"), - "timestamp": activity.get("timestamp"), - "activity_type": activity.get("activity_type"), - "actor": activity.get("actor", {}).get("login") if activity.get("actor") else None, - }) - return json.dumps({"activities": results}) - - -# Implements https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#list-review-comments-in-a-repository -# Example arcade chat usage: -# "List all review comments for the / repo. Sort by update date in descending order." -# TODO: Improve the 'since' input param such that LLM can more easily specify a valid date/time. -@tool(requires_auth=GitHub()) -async def list_review_comments_in_a_repository( - context: ToolContext, - owner: Annotated[str, "The account owner of the repository. The name is not case sensitive."], - repo: Annotated[ - str, - "The name of the repository without the .git extension. The name is not case sensitive.", - ], - sort: Annotated[ - ReviewCommentSortProperty | None, "Can be one of: created, updated." - ] = ReviewCommentSortProperty.CREATED, - direction: Annotated[ - SortDirection | None, - "The direction to sort results. Ignored without sort parameter. Can be one of: asc, desc.", - ] = SortDirection.DESC, - since: Annotated[ - str | None, - "Only show results that were last updated after the given time. " - "This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.", - ] = None, - per_page: Annotated[int, "The number of results per page (max 100)."] = 30, - page: Annotated[int, "The page number of the results to fetch."] = 1, - include_extra_data: Annotated[ - bool, - "If true, return all the data available about the review comments. " - "This is a large payload and may impact performance - use with caution.", - ] = False, -) -> Annotated[ - str, - "A JSON string containing a dictionary with key 'review_comments', which is a list of " - "review comments. Each comment includes id, url, diff_hunk, path, position details, commit " - "information, user, body, timestamps, and related URLs", -]: - """ - List review comments in a GitHub repository. - - Example: - ``` - list_review_comments(owner="octocat", repo="Hello-World", sort="created", direction="asc") - ``` - """ - url = get_url("repo_pulls_comments", owner=owner, repo=repo) - - params = { - "per_page": min(max(1, per_page), 100), # clamp per_page to 1-100 - "page": page, - "sort": sort, - "direction": direction, - "since": since, - } - params = remove_none_values(params) - headers = get_github_json_headers( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params) - - handle_github_response(response, url) - - review_comments = response.json() - if include_extra_data: - return json.dumps({"review_comments": review_comments}) - else: - important_info = [ - { - "id": comment["id"], - "url": comment["url"], - "diff_hunk": comment["diff_hunk"], - "path": comment["path"], - "position": comment["position"], - "original_position": comment["original_position"], - "commit_id": comment["commit_id"], - "original_commit_id": comment["original_commit_id"], - "in_reply_to_id": comment.get("in_reply_to_id"), - "user": comment["user"]["login"], - "body": comment["body"], - "created_at": comment["created_at"], - "updated_at": comment["updated_at"], - "html_url": comment["html_url"], - "line": comment["line"], - "side": comment["side"], - "pull_request_url": comment["pull_request_url"], - } - for comment in review_comments - ] - return json.dumps({"review_comments": important_info}) diff --git a/toolkits/github/arcade_github/tools/utils.py b/toolkits/github/arcade_github/tools/utils.py deleted file mode 100644 index c44930b0..00000000 --- a/toolkits/github/arcade_github/tools/utils.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Any - -import httpx -from arcade_tdk.errors import ToolExecutionError - -from arcade_github.tools.constants import ENDPOINTS, GITHUB_API_BASE_URL - - -def handle_github_response(response: httpx.Response, url: str) -> None: - """ - Handle GitHub API response and raise appropriate exceptions for non-200 status codes. - - :param response: The response object from the GitHub API - :param url: The URL of the API endpoint - :raises ToolExecutionError: If the response status code is not 200 - """ - if 200 <= response.status_code < 300: - return - - error_messages = { - 301: "Moved permanently. The repository has moved.", - 304: "Not modified. The requested resource hasn't been modified since the last request.", - 403: "Forbidden. You do not have access to this resource.", - 404: "Resource not found. The requested resource does not exist.", - 410: "Gone. The requested resource is no longer available.", - 422: "Validation failed or the endpoint has been spammed.", - 503: "Service unavailable. The server is temporarily unable to handle the request.", - } - - error_message = error_messages.get( - response.status_code, f"Failed to process request. Status code: {response.status_code}" - ) - - raise ToolExecutionError(f"Error accessing '{url}': {error_message}") - - -def get_github_json_headers(token: str | None) -> dict: - """ - Generate common headers for GitHub API requests. - - :param token: The authorization token - :return: A dictionary of headers - """ - token = token or "" - return { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - } - - -def get_github_diff_headers(token: str | None) -> dict: - """ - Generate headers for GitHub API requests for diff content. - - :param token: The authorization token - :return: A dictionary of headers - """ - token = token or "" - return { - "Accept": "application/vnd.github.diff", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - } - - -def remove_none_values(params: dict) -> dict: - """ - Remove None values from a dictionary. - - :param params: The dictionary to clean - :return: A new dictionary with None values removed - """ - return {k: v for k, v in params.items() if v is not None} - - -def get_url(endpoint: str, **kwargs: Any) -> str: - """ - Get the full URL for a given endpoint. - - :param endpoint: The endpoint key from ENDPOINTS - :param kwargs: The parameters to format the URL with - :return: The full URL - """ - return f"{GITHUB_API_BASE_URL}{ENDPOINTS[endpoint].format(**kwargs)}" diff --git a/toolkits/github/evals/eval_github_activity.py b/toolkits/github/evals/eval_github_activity.py deleted file mode 100644 index ba9f7e16..00000000 --- a/toolkits/github/evals/eval_github_activity.py +++ /dev/null @@ -1,115 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_github -from arcade_github.tools.activity import list_stargazers, set_starred - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -# Register the GitHub tools -catalog.add_module(arcade_github) - - -@tool_eval() -def github_activity_eval_suite() -> EvalSuite: - """Evaluation suite for GitHub Activity tools.""" - suite = EvalSuite( - name="GitHub Activity Tools Evaluation Suite", - system_message="You are an AI assistant that helps users interact with GitHub repositories using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Set Starred - suite.add_case( - name="Star a repository", - user_message="Star the test repository that is owned by ArcadeAI.", - expected_tool_calls=[ - ExpectedToolCall( - func=set_starred, - args={ - "owner": "ArcadeAI", - "name": "test", - "starred": True, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.3), - BinaryCritic(critic_field="name", weight=0.3), - BinaryCritic(critic_field="starred", weight=0.4), - ], - ) - - suite.add_case( - name="Unstar a repository", - user_message="Unstar the ArcadeAI/test repository.", - expected_tool_calls=[ - ExpectedToolCall( - func=set_starred, - args={ - "owner": "ArcadeAI", - "name": "test", - "starred": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.3), - BinaryCritic(critic_field="name", weight=0.3), - BinaryCritic(critic_field="starred", weight=0.4), - ], - ) - - suite.add_case( - name="List stargazers for a repository", - user_message="List 42 stargazers for the ArcadeAI/arcade-ai repository.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_stargazers, - args={ - "owner": "ArcadeAI", - "repo": "arcade-ai", - "limit": 42, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.3), - BinaryCritic(critic_field="repo", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.4), - ], - ) - - suite.add_case( - name="List stargazers for a repository", - user_message="List all of the stargazers for the ArcadeAI/arcade-ai repo", - expected_tool_calls=[ - ExpectedToolCall( - func=list_stargazers, - args={ - "owner": "ArcadeAI", - "repo": "arcade-ai", - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.3), - BinaryCritic(critic_field="repo", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.4), - ], - ) - - return suite diff --git a/toolkits/github/evals/eval_github_issues.py b/toolkits/github/evals/eval_github_issues.py deleted file mode 100644 index c5eaaee7..00000000 --- a/toolkits/github/evals/eval_github_issues.py +++ /dev/null @@ -1,92 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_github -from arcade_github.tools.issues import ( - create_issue, - create_issue_comment, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -# Register the GitHub tools -catalog.add_module(arcade_github) - - -@tool_eval() -def github_issues_eval_suite() -> EvalSuite: - """Evaluation suite for GitHub Issues tools.""" - suite = EvalSuite( - name="GitHub Issues Tools Evaluation Suite", - system_message="You are an AI assistant that helps users interact with GitHub issues using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Create Issue - suite.add_case( - name="Create a new issue", - user_message="Create a new issue in the 'ArcadeAI/arcade-ai' repository with the title 'Bug: Login not working' and description 'Users are unable to log in to the application.' Assign the issue to TestUser, add it to milestone 1, and add the labels 'bug', and 'critical'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_issue, - args={ - "owner": "ArcadeAI", - "repo": "arcade-ai", - "title": "Bug: Login not working", - "body": "Users are unable to log in to the application.", - "assignees": ["TestUser"], - "milestone": 1, - "labels": ["bug", "critical"], - "include_extra_data": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - SimilarityCritic(critic_field="title", weight=0.2), - SimilarityCritic(critic_field="body", weight=0.1), - BinaryCritic(critic_field="assignees", weight=0.1), - BinaryCritic(critic_field="milestone", weight=0.1), - BinaryCritic(critic_field="labels", weight=0.1), - ], - ) - - # Create Issue Comment - suite.add_case( - name="Add a comment to an existing issue", - user_message="Add a comment to issue #42 in the 'ArcadeAI/test' repository saying 'This issue is being investigated by the dev team.'", - expected_tool_calls=[ - ExpectedToolCall( - func=create_issue_comment, - args={ - "owner": "ArcadeAI", - "repo": "test", - "issue_number": 42, - "body": "This issue is being investigated by the dev team.", - "include_extra_data": False, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="owner", weight=0.2), - SimilarityCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="issue_number", weight=0.3), - SimilarityCritic(critic_field="body", weight=0.2), - ], - ) - - return suite diff --git a/toolkits/github/evals/eval_github_pull_requests.py b/toolkits/github/evals/eval_github_pull_requests.py deleted file mode 100644 index 67ce180b..00000000 --- a/toolkits/github/evals/eval_github_pull_requests.py +++ /dev/null @@ -1,250 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_github -from arcade_github.tools.models import ( - DiffSide, - ReviewCommentSubjectType, - SortDirection, -) -from arcade_github.tools.pull_requests import ( - create_reply_for_review_comment, - create_review_comment, - get_pull_request, - list_pull_request_commits, - list_pull_requests, - list_review_comments_on_pull_request, - update_pull_request, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -# Register the GitHub tools -catalog.add_module(arcade_github) - - -@tool_eval() -def github_pull_requests_eval_suite() -> EvalSuite: - """Evaluation suite for GitHub Pull Requests tools.""" - suite = EvalSuite( - name="GitHub Pull Requests Tools Evaluation Suite", - system_message="You are an AI assistant that helps users interact with GitHub pull requests using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # List Pull Requests - suite.add_case( - name="List all open pull requests", - user_message="List all open pull requests in the test repository under the ArcadeAI account that are proposing to merge into main.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_pull_requests, - args={ - "owner": "ArcadeAI", - "repo": "test", - "state": "open", - "base": "main", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="state", weight=0.2), - BinaryCritic(critic_field="base", weight=0.1), - ], - ) - - # Get Pull Request - suite.add_case( - name="Get details of a pull request", - user_message="Get diff of pull request #72 in the 'ArcadeAI/test' repository. Include all the data that is available in your response.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_pull_request, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - "include_diff_content": True, - "include_extra_data": True, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="pull_number", weight=0.3), - BinaryCritic(critic_field="include_extra_data", weight=0.1), - BinaryCritic(critic_field="include_diff_content", weight=0.2), - ], - ) - - # Update Pull Request - suite.add_case( - name="Update a pull request", - user_message="Update the title of pull request #72 in the 'ArcadeAI/test' repository to 'Updated Title'.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_pull_request, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - "title": "Updated Title", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="pull_number", weight=0.3), - BinaryCritic(critic_field="title", weight=0.3), - ], - ) - - # List Pull Request Commits - suite.add_case( - name="List commits on a pull request", - user_message="List all commits for PR 72 in the test repository under ArcadeAI.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_pull_request_commits, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="pull_number", weight=0.3), - ], - ) - - # Create Reply for Review Comment - suite.add_case( - name="Create a reply to a review comment", - user_message="Create a reply to the review comment 1778019974 in 'ArcadeAI/test' for pr 72 saying 'Thanks for the suggestion.'", - expected_tool_calls=[ - ExpectedToolCall( - func=create_reply_for_review_comment, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - "comment_id": 1778019974, - "body": "Thanks for the suggestion.", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="pull_number", weight=0.2), - BinaryCritic(critic_field="comment_id", weight=0.2), - SimilarityCritic(critic_field="body", weight=0.2), - ], - ) - - # List Review Comments on Pull Request - suite.add_case( - name="List all review comments on a pull request", - user_message="List review comments for pr 72 in the ArcadeAI/test repo. Sort by updated time in ascending order.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_review_comments_on_pull_request, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - "sort": "updated", - "direction": SortDirection.ASC, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="pull_number", weight=0.2), - BinaryCritic(critic_field="sort", weight=0.2), - BinaryCritic(critic_field="direction", weight=0.2), - ], - ) - - # Create Review Comment - suite.add_case( - name="Create a review comment on a pull request file", - user_message="Create a review comment on pr 72 in the 'ArcadeAI/test' repo. The comment should be on the file 'README.md' and says 'nit: you misspelled the word 'intelligence'", - expected_tool_calls=[ - ExpectedToolCall( - func=create_review_comment, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - "body": "nit: you misspelled the word 'intelligence'", - "path": "README.md", - "subject_type": ReviewCommentSubjectType.FILE, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.15), - BinaryCritic(critic_field="repo", weight=0.15), - BinaryCritic(critic_field="pull_number", weight=0.2), - SimilarityCritic(critic_field="body", weight=0.1), - BinaryCritic(critic_field="path", weight=0.2), - BinaryCritic(critic_field="subject_type", weight=0.2), - ], - ) - - # Create Review Comment with Line Numbers - suite.add_case( - name="Create a review comment on specific lines of a pull request", - user_message="Create a review comment on pull request #72 in the 'ArcadeAI/test' repository. The comment should be on the file 'src/main.py', lines 10-15, and say 'Move these to constants.py.'", - expected_tool_calls=[ - ExpectedToolCall( - func=create_review_comment, - args={ - "owner": "ArcadeAI", - "repo": "test", - "pull_number": 72, - "body": "Move these to constants.py.", - "path": "src/main.py", - "start_line": 10, - "end_line": 15, - "side": DiffSide.RIGHT, - "subject_type": ReviewCommentSubjectType.LINE, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.1), - BinaryCritic(critic_field="repo", weight=0.1), - BinaryCritic(critic_field="pull_number", weight=0.15), - SimilarityCritic(critic_field="body", weight=0.15), - BinaryCritic(critic_field="path", weight=0.1), - BinaryCritic(critic_field="start_line", weight=0.1), - BinaryCritic(critic_field="end_line", weight=0.1), - BinaryCritic(critic_field="side", weight=0.1), - BinaryCritic(critic_field="subject_type", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/github/evals/eval_github_repositories.py b/toolkits/github/evals/eval_github_repositories.py deleted file mode 100644 index b8c1e859..00000000 --- a/toolkits/github/evals/eval_github_repositories.py +++ /dev/null @@ -1,158 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_github -from arcade_github.tools.models import SortDirection -from arcade_github.tools.repositories import ( - count_stargazers, - get_repository, - list_org_repositories, - list_repository_activities, - list_review_comments_in_a_repository, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -# Register the GitHub tools -catalog.add_module(arcade_github) - - -@tool_eval() -def github_repositories_eval_suite() -> EvalSuite: - """Evaluation suite for GitHub Repositories tools.""" - suite = EvalSuite( - name="GitHub Repositories Tools Evaluation Suite", - system_message="You are an AI assistant that helps users interact with GitHub repositories using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Count Stargazers - suite.add_case( - name="Count stargazers of a repository", - user_message="How many stargazers does the ArcadeAI/test repo have?", - expected_tool_calls=[ - ExpectedToolCall( - func=count_stargazers, - args={ - "owner": "ArcadeAI", - "name": "test", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.5), - BinaryCritic(critic_field="name", weight=0.5), - ], - ) - - # List an Organization's Repositories - suite.add_case( - name="List repositories in an organization", - user_message="List all repos in the ArcadeAI org, sorted by creation date in descending order.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_org_repositories, - args={ - "org": "ArcadeAI", - "repo_type": "all", - "sort": "created", - "sort_direction": SortDirection.DESC, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="org", weight=0.1), - BinaryCritic(critic_field="repo_type", weight=0.1), - BinaryCritic(critic_field="sort", weight=0.1), - BinaryCritic(critic_field="sort_direction", weight=0.1), - ], - ) - - # Get Repository - suite.add_case( - name="Get details of a repository", - user_message="Tell me about the test repo owned by ArcadeAI.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_repository, - args={ - "owner": "ArcadeAI", - "repo": "test", - "include_extra_data": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.3), - BinaryCritic(critic_field="repo", weight=0.3), - ], - ) - - # List Repository Activities - suite.add_case( - name="List activities in a repository", - user_message="List all PR merges in the 'ArcadeAI/test' repository that were performed by TestUser in the last month", - expected_tool_calls=[ - ExpectedToolCall( - func=list_repository_activities, - args={ - "owner": "ArcadeAI", - "repo": "test", - "direction": SortDirection.DESC, - "per_page": 30, - "actor": "TestUser", - "time_period": "month", - "activity_type": "pr_merge", - "include_extra_data": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.1), - BinaryCritic(critic_field="repo", weight=0.1), - BinaryCritic(critic_field="direction", weight=0.1), - BinaryCritic(critic_field="actor", weight=0.1), - BinaryCritic(critic_field="time_period", weight=0.1), - BinaryCritic(critic_field="activity_type", weight=0.1), - ], - ) - - # List Review Comments in a Repository - suite.add_case( - name="List review comments in a repository", - user_message="List all review comments in the 'ArcadeAI/test' repository, sorted by creation date in descending order.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_review_comments_in_a_repository, - args={ - "owner": "ArcadeAI", - "repo": "test", - "sort": "created", - "direction": SortDirection.DESC, - "per_page": 30, - "page": 1, - "include_extra_data": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="owner", weight=0.2), - BinaryCritic(critic_field="repo", weight=0.2), - BinaryCritic(critic_field="sort", weight=0.1), - BinaryCritic(critic_field="direction", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/github/pyproject.toml b/toolkits/github/pyproject.toml deleted file mode 100644 index 85c276e2..00000000 --- a/toolkits/github/pyproject.toml +++ /dev/null @@ -1,58 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_github" -version = "0.1.13" -description = "Arcade.dev LLM tools for Github" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - - -[tool.mypy] -files = [ "arcade_github/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_github",] diff --git a/toolkits/github/tests/__init__.py b/toolkits/github/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/github/tests/test_activity.py b/toolkits/github/tests/test_activity.py deleted file mode 100644 index f245b63d..00000000 --- a/toolkits/github/tests/test_activity.py +++ /dev/null @@ -1,103 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from httpx import Response - -from arcade_github.tools.activity import list_stargazers, set_starred - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_client(): - with patch("arcade_github.tools.activity.httpx.AsyncClient") as client: - yield client.return_value.__aenter__.return_value - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "starred,expected_message", - [ - (True, "Successfully starred the repository owner/repo"), - (False, "Successfully unstarred the repository owner/repo"), - ], -) -async def test_set_starred_success(mock_context, mock_client, starred, expected_message): - mock_client.put.return_value = mock_client.delete.return_value = Response(204) - - result = await set_starred(mock_context, "owner", "repo", starred) - assert result == expected_message - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status_code,error_message,expected_error", - [ - (403, "Forbidden", "Error accessing.*: Forbidden"), - (404, "Not Found", "Error accessing.*: Resource not found"), - (500, "Internal Server Error", "Error accessing.*: Failed to process request"), - ], -) -async def test_set_starred_errors( - mock_context, mock_client, status_code, error_message, expected_error -): - mock_client.put.return_value = mock_client.delete.return_value = Response( - status_code, json={"message": error_message} - ) - - with pytest.raises(ToolExecutionError, match=expected_error): - await set_starred(mock_context, "owner", "repo", True) - - -@pytest.mark.asyncio -async def test_list_stargazers_success(mock_context, mock_client): - mock_response_data = [ - { - "login": "user1", - "id": 1, - "node_id": "MDQ6VXNlcjE=", - "html_url": "https://github.com/user1", - }, - { - "login": "user2", - "id": 2, - "node_id": "MDQ6VXNlcjI=", - "html_url": "https://github.com/user2", - }, - ] - mock_client.get.return_value = Response(200, json=mock_response_data) - - result = await list_stargazers(mock_context, "owner", "repo", limit=2) - assert result == {"number_of_stargazers": 2, "stargazers": mock_response_data} - - -@pytest.mark.asyncio -async def test_list_stargazers_empty(mock_context, mock_client): - mock_client.get.return_value = Response(200, json=[]) - - result = await list_stargazers(mock_context, "owner", "repo") - assert result == {"number_of_stargazers": 0, "stargazers": []} - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status_code,error_message,expected_error", - [ - (403, "Forbidden", "Error accessing.*: Forbidden"), - (404, "Not Found", "Error accessing.*: Resource not found"), - (500, "Internal Server Error", "Error accessing.*: Failed to process request"), - ], -) -async def test_list_stargazers_errors( - mock_context, mock_client, status_code, error_message, expected_error -): - mock_client.get.return_value = Response(status_code, json={"message": error_message}) - - with pytest.raises(ToolExecutionError, match=expected_error): - await list_stargazers(mock_context, "owner", "repo") diff --git a/toolkits/github/tests/test_issues.py b/toolkits/github/tests/test_issues.py deleted file mode 100644 index a22477c0..00000000 --- a/toolkits/github/tests/test_issues.py +++ /dev/null @@ -1,110 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from httpx import Response - -from arcade_github.tools.issues import create_issue, create_issue_comment - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_client(): - with patch("arcade_github.tools.issues.httpx.AsyncClient") as client: - yield client.return_value.__aenter__.return_value - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status_code,error_message,expected_error,func,args", - [ - ( - 422, - "Validation Failed", - "Error accessing.*: Validation failed", - create_issue, - ("owner", "repo", "title"), - ), - ( - 401, - "Unauthorized", - "Error accessing.*: Failed to process request", - create_issue_comment, - ("owner", "repo", 1, "body"), - ), - ( - 403, - "API rate limit exceeded", - "Error accessing.*: Forbidden", - create_issue_comment, - ("owner", "repo", 1, "body"), - ), - ( - 401, - "Bad credentials", - "Error accessing.*: Failed to process request", - create_issue, - ("owner", "repo", "title"), - ), - ], -) -async def test_issue_errors( - mock_context, mock_client, status_code, error_message, expected_error, func, args -): - mock_client.post.return_value = Response(status_code, json={"message": error_message}) - - with pytest.raises(ToolExecutionError, match=expected_error): - await func(mock_context, *args) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "func,args,response_json,expected_assertions", - [ - ( - create_issue, - ("owner", "repo", "Test Issue", "This is a test issue"), - { - "id": 1, - "url": "https://api.github.com/repos/owner/repo/issues/1", - "title": "Test Issue", - "body": "This is a test issue", - "state": "open", - "html_url": "https://github.com/owner/repo/issues/1", - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "user": {"login": "testuser"}, - "assignees": [], - "labels": [], - }, - ["Test Issue", "https://github.com/owner/repo/issues/1"], - ), - ( - create_issue_comment, - ("owner", "repo", 1, "This is a test comment"), - { - "id": 1, - "url": "https://api.github.com/repos/owner/repo/issues/comments/1", - "body": "This is a test comment", - "user": {"login": "testuser"}, - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - }, - ["This is a test comment", "https://api.github.com/repos/owner/repo/issues/comments/1"], - ), - ], -) -async def test_issue_success( - mock_context, mock_client, func, args, response_json, expected_assertions -): - mock_client.post.return_value = Response(201, json=response_json) - - result = await func(mock_context, *args) - for assertion in expected_assertions: - assert assertion in result diff --git a/toolkits/github/tests/test_pull_requests.py b/toolkits/github/tests/test_pull_requests.py deleted file mode 100644 index d5abad68..00000000 --- a/toolkits/github/tests/test_pull_requests.py +++ /dev/null @@ -1,359 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from httpx import Response - -from arcade_github.tools.models import ( - DiffSide, - ReviewCommentSubjectType, -) -from arcade_github.tools.pull_requests import ( - create_reply_for_review_comment, - create_review_comment, - get_pull_request, - list_pull_request_commits, - list_pull_requests, - list_review_comments_on_pull_request, - update_pull_request, -) - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_client(): - with patch("arcade_github.tools.pull_requests.httpx.AsyncClient") as client: - yield client.return_value.__aenter__.return_value - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "func,args,status_code,json_response,expected_result,error_message", - [ - (list_pull_requests, ("owner", "repo"), 200, [], '{"pull_requests": []}', None), - ( - get_pull_request, - ("owner", "repo", 1), - 404, - {"message": "Not Found"}, - None, - "Error accessing.*: Resource not found", - ), - ( - update_pull_request, - ("owner", "repo", 1, "New Title"), - 409, - {"message": "Conflict"}, - None, - "Error accessing.*: Failed to process request", - ), - ( - list_pull_request_commits, - ("owner", "repo", 1), - 500, - {"message": "Internal Server Error"}, - None, - "Error accessing.*: Failed to process request", - ), - ( - list_review_comments_on_pull_request, - ("owner", "repo", 1), - 403, - {"message": "API rate limit exceeded"}, - None, - "Error accessing.*: Forbidden", - ), - ], -) -async def test_pull_request_functions( - mock_context, - mock_client, - func, - args, - status_code, - json_response, - expected_result, - error_message, -): - mock_client.get.return_value = mock_client.post.return_value = ( - mock_client.patch.return_value - ) = Response(status_code, json=json_response) - - if error_message: - with pytest.raises(ToolExecutionError, match=error_message): - await func(mock_context, *args) - else: - result = await func(mock_context, *args) - assert result == expected_result - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "func,args,json_response,expected_assertions", - [ - ( - list_pull_requests, - ("owner", "repo"), - [ - { - "number": 1, - "title": "Test PR", - "body": "This is a test PR", - "state": "open", - "html_url": "https://github.com/owner/repo/pull/1", - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "user": {"login": "testuser"}, - "base": {"ref": "main"}, - "head": {"ref": "feature-branch"}, - } - ], - ["Test PR", "https://github.com/owner/repo/pull/1"], - ), - ( - update_pull_request, - ("owner", "repo", 1, "Updated PR Title", "Updated PR body"), - { - "number": 1, - "title": "Updated PR Title", - "body": "Updated PR body", - "state": "open", - "html_url": "https://github.com/owner/repo/pull/1", - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-02T12:00:00Z", - "user": {"login": "testuser"}, - }, - ["Updated PR Title", "Updated PR body"], - ), - ( - list_pull_request_commits, - ("owner", "repo", 1), - [ - { - "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", - "commit": { - "author": { - "name": "Test Author", - "email": "author@example.com", - "date": "2023-05-01T12:00:00Z", - }, - "message": "Test commit message", - }, - } - ], - ["6dcb09b5b57875f334f61aebed695e2e4193db5e", "Test commit message"], - ), - ( - create_reply_for_review_comment, - ("owner", "repo", 1, 42, "Thanks for the suggestion."), - { - "id": 123, - "body": "Thanks for the suggestion.", - "user": {"login": "testuser"}, - "created_at": "2023-05-02T12:00:00Z", - "updated_at": "2023-05-02T12:00:00Z", - }, - ["Thanks for the suggestion.", "testuser"], - ), - ( - list_review_comments_on_pull_request, - ("owner", "repo", 1), - [ - { - "id": 1, - "body": "Great changes!", - "user": {"login": "reviewer1"}, - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "path": "file1.txt", - "line": 5, - } - ], - ["Great changes!", "reviewer1", "file1.txt"], - ), - ( - get_pull_request, - ("owner", "repo", 1, False, False), - { - "number": 1, - "title": "Test PR", - "body": "This is a test PR", - "state": "open", - "html_url": "https://github.com/owner/repo/pull/1", - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "user": {"login": "testuser"}, - "base": {"ref": "main"}, - "head": {"ref": "feature-branch"}, - }, - ["Test PR", "https://github.com/owner/repo/pull/1"], - ), - ( - get_pull_request, - ("owner", "repo", 1, True, False), - { - "number": 1, - "title": "Test PR", - "body": "This is a test PR", - "state": "open", - "html_url": "https://github.com/owner/repo/pull/1", - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "user": {"login": "testuser"}, - "base": {"ref": "main"}, - "head": {"ref": "feature-branch"}, - "diff_content": "Sample diff content", - }, - ["Test PR", "https://github.com/owner/repo/pull/1", "diff_content"], - ), - ( - create_review_comment, - ( - "owner", - "repo", - 1, - "Great changes!", - "file1.txt", - "6dcb09b5b57875f334f61aebed695e2e4193db5e", - 1, - 2, - DiffSide.RIGHT, - None, - ReviewCommentSubjectType.LINE, - ), - { - "id": 1, - "body": "Great changes!", - "path": "file1.txt", - "line": 2, - "side": "RIGHT", - "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e", - "user": {"login": "testuser"}, - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "html_url": "https://github.com/owner/repo/pull/1#discussion_r1", - }, - ["Great changes!", "file1.txt", "6dcb09b5b57875f334f61aebed695e2e4193db5e"], - ), - ], -) -async def test_pull_request_functions_success( - mock_context, mock_client, func, args, json_response, expected_assertions -): - mock_client.get.return_value = mock_client.post.return_value = ( - mock_client.patch.return_value - ) = Response(200, json=json_response) - - result = await func(mock_context, *args) - for assertion in expected_assertions: - assert assertion in result - - -@pytest.mark.asyncio -async def test_create_review_comment_file_subject_type(mock_context, mock_client): - mock_client.post.return_value = Response( - 200, - json={ - "id": 1, - "body": "File comment", - "path": "file1.txt", - "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e", - "user": {"login": "testuser"}, - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "html_url": "https://github.com/owner/repo/pull/1#discussion_r1", - }, - ) - - result = await create_review_comment( - mock_context, - "owner", - "repo", - 1, - "File comment", - "file1.txt", - "6dcb09b5b57875f334f61aebed695e2e4193db5e", - subject_type=ReviewCommentSubjectType.FILE, - ) - - assert "File comment" in result - assert "file1.txt" in result - assert "6dcb09b5b57875f334f61aebed695e2e4193db5e" in result - assert "start_line" not in mock_client.post.call_args[1]["json"] - assert "end_line" not in mock_client.post.call_args[1]["json"] - - -@pytest.mark.asyncio -async def test_create_review_comment_missing_commit_id(mock_context, mock_client): - mock_client.get.return_value = Response( - 200, - json=[{"sha": "latest_commit_sha"}], - ) - mock_client.post.return_value = Response( - 200, - json={ - "id": 1, - "body": "Comment with auto-fetched commit ID", - "path": "file1.txt", - "commit_id": "latest_commit_sha", - "user": {"login": "testuser"}, - "created_at": "2023-05-01T12:00:00Z", - "updated_at": "2023-05-01T12:00:00Z", - "html_url": "https://github.com/owner/repo/pull/1#discussion_r1", - }, - ) - - result = await create_review_comment( - mock_context, - "owner", - "repo", - 1, - "Comment with auto-fetched commit ID", - "file1.txt", - start_line=1, - end_line=2, - ) - - assert "Comment with auto-fetched commit ID" in result - assert "latest_commit_sha" in result - assert mock_client.get.called - assert mock_client.post.called - - -@pytest.mark.asyncio -async def test_create_review_comment_invalid_input(mock_context, mock_client): - with pytest.raises( - RetryableToolError, match="'start_line' and 'end_line' parameters are required" - ): - await create_review_comment( - mock_context, - "owner", - "repo", - 1, - "Invalid comment", - "file1.txt", - subject_type=ReviewCommentSubjectType.LINE, - ) - - -@pytest.mark.asyncio -async def test_create_review_comment_no_commits(mock_context, mock_client): - mock_client.get.return_value = Response(200, json=[]) - - with pytest.raises(RetryableToolError, match="Failed to get the latest commit SHA"): - await create_review_comment( - mock_context, - "owner", - "repo", - 1, - "Comment with no commits", - "file1.txt", - start_line=1, - end_line=2, - ) diff --git a/toolkits/github/tests/test_repositories.py b/toolkits/github/tests/test_repositories.py deleted file mode 100644 index 466b2cf4..00000000 --- a/toolkits/github/tests/test_repositories.py +++ /dev/null @@ -1,73 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from httpx import Response - -from arcade_github.tools.models import RepoType -from arcade_github.tools.repositories import ( - count_stargazers, - get_repository, - list_org_repositories, - list_repository_activities, - list_review_comments_in_a_repository, -) - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_client(): - with patch("arcade_github.tools.repositories.httpx.AsyncClient") as client: - yield client.return_value.__aenter__.return_value - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status_code,error_message,expected_error", - [ - (422, "Validation Failed", "Error accessing.*: Validation failed"), - (301, "Moved Permanently", "Error accessing.*: Moved permanently"), - (404, "Not Found", "Error accessing.*: Resource not found"), - (503, "Service Unavailable", "Error accessing.*: Service unavailable"), - (410, "Gone", "Error accessing.*: Gone"), - ], -) -async def test_error_responses( - mock_context, mock_client, status_code, error_message, expected_error -): - mock_client.get.return_value = Response(status_code, json={"message": error_message}) - mock_client.post.return_value = Response(status_code, json={"message": error_message}) - - with pytest.raises(ToolExecutionError, match=expected_error): - if status_code == 422: - await list_org_repositories(mock_context, "org", repo_type=RepoType.ALL) - elif status_code == 301: - await count_stargazers(mock_context, "owner", "repo") - elif status_code == 404: - await list_org_repositories(mock_context, "non_existent_org") - elif status_code == 503: - await get_repository(mock_context, "owner", "repo") - elif status_code == 410: - await list_review_comments_in_a_repository(mock_context, "owner", "repo") - - -@pytest.mark.asyncio -async def test_list_repository_activities_invalid_cursor(mock_context, mock_client): - mock_client.get.return_value = Response(422, json={"message": "Validation Failed"}) - - with pytest.raises(ToolExecutionError, match="Error accessing.*: Validation failed"): - await list_repository_activities(mock_context, "owner", "repo", before="invalid_cursor") - - -@pytest.mark.asyncio -async def test_count_stargazers_success(mock_context, mock_client): - mock_client.get.return_value = Response(200, json={"stargazers_count": 42}) - - result = await count_stargazers(mock_context, "owner", "repo") - assert result == 42 diff --git a/toolkits/gmail/.pre-commit-config.yaml b/toolkits/gmail/.pre-commit-config.yaml deleted file mode 100644 index d46cb6e2..00000000 --- a/toolkits/gmail/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/gmail/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/gmail/.ruff.toml b/toolkits/gmail/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/gmail/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/gmail/Makefile b/toolkits/gmail/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/gmail/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/gmail/arcade_gmail/__init__.py b/toolkits/gmail/arcade_gmail/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/gmail/arcade_gmail/constants.py b/toolkits/gmail/arcade_gmail/constants.py deleted file mode 100644 index 4755797e..00000000 --- a/toolkits/gmail/arcade_gmail/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -import os - -from arcade_gmail.enums import GmailReplyToWhom - -# The default reply in Gmail is to only the sender. Since Gmail also offers the possibility of -# changing the default to 'reply to all', we support both options through an env variable. -# https://support.google.com/mail/answer/6585?hl=en&sjid=15399867888091633568-SA#null -try: - GMAIL_DEFAULT_REPLY_TO = GmailReplyToWhom( - # Values accepted are defined in the arcade_google.tools.models.GmailReplyToWhom Enum - os.getenv("ARCADE_GMAIL_DEFAULT_REPLY_TO", GmailReplyToWhom.ONLY_THE_SENDER.value).lower() - ) -except ValueError as e: - raise ValueError( - "Invalid value for ARCADE_GMAIL_DEFAULT_REPLY_TO: " - f"'{os.getenv('ARCADE_GMAIL_DEFAULT_REPLY_TO')}'. Expected one of " - f"{list(GmailReplyToWhom.__members__.keys())}" - ) from e diff --git a/toolkits/gmail/arcade_gmail/enums.py b/toolkits/gmail/arcade_gmail/enums.py deleted file mode 100644 index 16b186d7..00000000 --- a/toolkits/gmail/arcade_gmail/enums.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -class GmailReplyToWhom(str, Enum): - EVERY_RECIPIENT = "every_recipient" - ONLY_THE_SENDER = "only_the_sender" - - -class GmailAction(str, Enum): - SEND = "send" - DRAFT = "draft" diff --git a/toolkits/gmail/arcade_gmail/exceptions.py b/toolkits/gmail/arcade_gmail/exceptions.py deleted file mode 100644 index 12c9aa65..00000000 --- a/toolkits/gmail/arcade_gmail/exceptions.py +++ /dev/null @@ -1,19 +0,0 @@ -class GmailToolError(Exception): - """Base exception for Google tool errors.""" - - def __init__(self, message: str, developer_message: str | None = None): - self.message = message - self.developer_message = developer_message - super().__init__(self.message) - - def __str__(self) -> str: - base_message = self.message - if self.developer_message: - return f"{base_message} (Developer: {self.developer_message})" - return base_message - - -class GmailServiceError(GmailToolError): - """Raised when there's an error building or using the Google service.""" - - pass diff --git a/toolkits/gmail/arcade_gmail/tools/__init__.py b/toolkits/gmail/arcade_gmail/tools/__init__.py deleted file mode 100644 index 6ed7ba8e..00000000 --- a/toolkits/gmail/arcade_gmail/tools/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -from arcade_gmail.tools.gmail import ( - change_email_labels, - create_label, - delete_draft_email, - get_thread, - list_draft_emails, - list_emails, - list_emails_by_header, - list_labels, - list_threads, - reply_to_email, - search_threads, - send_draft_email, - send_email, - trash_email, - update_draft_email, - write_draft_email, - write_draft_reply_email, -) - -__all__ = [ - "change_email_labels", - "create_label", - "delete_draft_email", - "get_thread", - "list_draft_emails", - "list_emails", - "list_emails_by_header", - "list_labels", - "list_threads", - "reply_to_email", - "search_threads", - "send_draft_email", - "send_email", - "trash_email", - "update_draft_email", - "write_draft_email", - "write_draft_reply_email", -] diff --git a/toolkits/gmail/arcade_gmail/tools/gmail.py b/toolkits/gmail/arcade_gmail/tools/gmail.py deleted file mode 100644 index 23a87572..00000000 --- a/toolkits/gmail/arcade_gmail/tools/gmail.py +++ /dev/null @@ -1,664 +0,0 @@ -import base64 -from email.mime.text import MIMEText -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import RetryableToolError -from googleapiclient.errors import HttpError - -from arcade_gmail.constants import GMAIL_DEFAULT_REPLY_TO -from arcade_gmail.enums import GmailAction, GmailReplyToWhom -from arcade_gmail.exceptions import GmailToolError -from arcade_gmail.utils import ( - DateRange, - _build_gmail_service, - build_email_message, - build_gmail_query_string, - build_reply_recipients, - fetch_messages, - get_draft_url, - get_email_details, - get_email_in_trash_url, - get_label_ids, - get_sent_email_url, - parse_draft_email, - parse_multipart_email, - parse_plain_text_email, - remove_none_values, -) - - -# Email sending tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.send"], - ) -) -async def send_email( - context: ToolContext, - subject: Annotated[str, "The subject of the email"], - body: Annotated[str, "The body of the email"], - recipient: Annotated[str, "The recipient of the email"], - cc: Annotated[list[str] | None, "CC recipients of the email"] = None, - bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None, -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """ - Send an email using the Gmail API. - """ - service = _build_gmail_service(context) - email = build_email_message(recipient, subject, body, cc, bcc) - - sent_message = service.users().messages().send(userId="me", body=email).execute() - - email = parse_plain_text_email(sent_message) - email["url"] = get_sent_email_url(sent_message["id"]) - return email - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.send"], - ) -) -async def send_draft_email( - context: ToolContext, email_id: Annotated[str, "The ID of the draft to send"] -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """ - Send a draft email using the Gmail API. - """ - - service = _build_gmail_service(context) - - # Send the draft email - sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute() - - email = parse_plain_text_email(sent_message) - email["url"] = get_sent_email_url(sent_message["id"]) - return email - - -# Note: in the Gmail UI, a user can customize the recipient and cc fields before replying. -# We decided not to support this feature, since we'd need a way for LLMs to tell apart between -# adding or removing recipients/cc, or replacing with an entirely new list of addresses, -# which would make the tool more complex to call. -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.send"], - ) -) -async def reply_to_email( - context: ToolContext, - body: Annotated[str, "The body of the email"], - reply_to_message_id: Annotated[str, "The ID of the message to reply to"], - reply_to_whom: Annotated[ - GmailReplyToWhom, - "Whether to reply to every recipient (including cc) or only to the original sender. " - f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.", - ] = GMAIL_DEFAULT_REPLY_TO, - bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None, -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """ - Send a reply to an email message. - """ - if isinstance(reply_to_whom, str): - reply_to_whom = GmailReplyToWhom(reply_to_whom) - - service = _build_gmail_service(context) - - current_user = service.users().getProfile(userId="me").execute() - - try: - replying_to_email = ( - service.users().messages().get(userId="me", id=reply_to_message_id).execute() - ) - except HttpError as e: - raise RetryableToolError( - message=f"Could not retrieve the message with id {reply_to_message_id}.", - developer_message=( - f"Could not retrieve the message with id {reply_to_message_id}. " - f"Reason: '{e.reason}'. Error details: '{e.error_details}'" - ), - ) from e - - replying_to_email = parse_multipart_email(replying_to_email) - - recipients = build_reply_recipients( - replying_to_email, current_user["emailAddress"], reply_to_whom - ) - - email = build_email_message( - recipient=recipients, - subject=f"Re: {replying_to_email['subject']}", - body=body, - cc=None - if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER - else replying_to_email["cc"].split(","), - bcc=bcc, - replying_to=replying_to_email, - ) - - sent_message = service.users().messages().send(userId="me", body=email).execute() - - email = parse_plain_text_email(sent_message) - email["url"] = get_sent_email_url(sent_message["id"]) - return email - - -# Draft Management Tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def write_draft_email( - context: ToolContext, - subject: Annotated[str, "The subject of the draft email"], - body: Annotated[str, "The body of the draft email"], - recipient: Annotated[str, "The recipient of the draft email"], - cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None, - bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None, -) -> Annotated[dict, "A dictionary containing the created draft email details"]: - """ - Compose a new email draft using the Gmail API. - """ - # Set up the Gmail API client - service = _build_gmail_service(context) - - draft = { - "message": build_email_message(recipient, subject, body, cc, bcc, action=GmailAction.DRAFT) - } - - draft_message = service.users().drafts().create(userId="me", body=draft).execute() - email = parse_draft_email(draft_message) - email["url"] = get_draft_url(draft_message["id"]) - return email - - -# Note: in the Gmail UI, a user can customize the recipient and cc fields before replying. -# We decided not to support this feature, since we'd need a way for LLMs to tell apart between -# adding or removing recipients/cc, or replacing with an entirely new list of addresses, -# which would make the tool more complex to call. -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def write_draft_reply_email( - context: ToolContext, - body: Annotated[str, "The body of the draft reply email"], - reply_to_message_id: Annotated[str, "The Gmail message ID of the message to draft a reply to"], - reply_to_whom: Annotated[ - GmailReplyToWhom, - "Whether to reply to every recipient (including cc) or only to the original sender. " - f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.", - ] = GMAIL_DEFAULT_REPLY_TO, - bcc: Annotated[list[str] | None, "BCC recipients of the draft reply email"] = None, -) -> Annotated[dict, "A dictionary containing the created draft reply email details"]: - """ - Compose a draft reply to an email message. - """ - if isinstance(reply_to_whom, str): - reply_to_whom = GmailReplyToWhom(reply_to_whom) - - service = _build_gmail_service(context) - - current_user = service.users().getProfile(userId="me").execute() - - try: - replying_to_email = ( - service.users().messages().get(userId="me", id=reply_to_message_id).execute() - ) - except HttpError as e: - raise RetryableToolError( - message="Could not retrieve the message to respond to.", - developer_message=( - "Could not retrieve the message to respond to. " - f"Reason: '{e.reason}'. Error details: '{e.error_details}'" - ), - ) - - replying_to_email = parse_multipart_email(replying_to_email) - - recipients = build_reply_recipients( - replying_to_email, current_user["emailAddress"], reply_to_whom - ) - - draft_message = { - "message": build_email_message( - recipient=recipients, - subject=f"Re: {replying_to_email['subject']}", - body=body, - cc=None - if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER - else replying_to_email["cc"].split(","), - bcc=bcc, - replying_to=replying_to_email, - action=GmailAction.DRAFT, - ), - } - - draft = service.users().drafts().create(userId="me", body=draft_message).execute() - - email = parse_draft_email(draft) - email["url"] = get_draft_url(draft["id"]) - return email - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def update_draft_email( - context: ToolContext, - draft_email_id: Annotated[str, "The ID of the draft email to update."], - subject: Annotated[str, "The subject of the draft email"], - body: Annotated[str, "The body of the draft email"], - recipient: Annotated[str, "The recipient of the draft email"], - cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None, - bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None, -) -> Annotated[dict, "A dictionary containing the updated draft email details"]: - """ - Update an existing email draft using the Gmail API. - """ - service = _build_gmail_service(context) - - message = MIMEText(body) - message["to"] = recipient - message["subject"] = subject - if cc: - message["Cc"] = ", ".join(cc) - if bcc: - message["Bcc"] = ", ".join(bcc) - - # Encode the message in base64 - raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode() - - # Update the draft - draft = {"id": draft_email_id, "message": {"raw": raw_message}} - - updated_draft_message = ( - service.users().drafts().update(userId="me", id=draft_email_id, body=draft).execute() - ) - - email = parse_draft_email(updated_draft_message) - email["url"] = get_draft_url(updated_draft_message["id"]) - - return email - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def delete_draft_email( - context: ToolContext, - draft_email_id: Annotated[str, "The ID of the draft email to delete"], -) -> Annotated[str, "A confirmation message indicating successful deletion"]: - """ - Delete a draft email using the Gmail API. - """ - service = _build_gmail_service(context) - - # Delete the draft - service.users().drafts().delete(userId="me", id=draft_email_id).execute() - return f"Draft email with ID {draft_email_id} deleted successfully." - - -# Email Management Tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.modify"], - ) -) -async def trash_email( - context: ToolContext, email_id: Annotated[str, "The ID of the email to trash"] -) -> Annotated[dict, "A dictionary containing the trashed email details"]: - """ - Move an email to the trash folder using the Gmail API. - """ - - service = _build_gmail_service(context) - - # Trash the email - trashed_email = service.users().messages().trash(userId="me", id=email_id).execute() - - email = parse_plain_text_email(trashed_email) - email["url"] = get_email_in_trash_url(trashed_email["id"]) - return email - - -# Draft Search Tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_draft_emails( - context: ToolContext, - n_drafts: Annotated[int, "Number of draft emails to read"] = 5, -) -> Annotated[dict, "A dictionary containing a list of draft email details"]: - """ - Lists draft emails in the user's draft mailbox using the Gmail API. - """ - service = _build_gmail_service(context) - - listed_drafts = service.users().drafts().list(userId="me").execute() - - if not listed_drafts: - return {"emails": []} - - draft_ids = [draft["id"] for draft in listed_drafts.get("drafts", [])][:n_drafts] - - emails = [] - for draft_id in draft_ids: - try: - draft_data = service.users().drafts().get(userId="me", id=draft_id).execute() - draft_details = parse_draft_email(draft_data) - if draft_details: - emails.append(draft_details) - except Exception as e: - raise GmailToolError( - message=f"Error reading draft email {draft_id}.", developer_message=str(e) - ) - - return {"emails": emails} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_emails_by_header( - context: ToolContext, - sender: Annotated[str | None, "The name or email address of the sender of the email"] = None, - recipient: Annotated[str | None, "The name or email address of the recipient"] = None, - subject: Annotated[str | None, "Words to find in the subject of the email"] = None, - body: Annotated[str | None, "Words to find in the body of the email"] = None, - date_range: Annotated[DateRange | None, "The date range of the email"] = None, - label: Annotated[str | None, "The label name to filter by"] = None, - max_results: Annotated[int, "The maximum number of emails to return"] = 25, -) -> Annotated[ - dict, "A dictionary containing a list of email details matching the search criteria" -]: - """ - Search for emails by header using the Gmail API. - - At least one of the following parameters MUST be provided: sender, recipient, - subject, date_range, label, or body. - """ - service = _build_gmail_service(context) - # Ensure at least one search parameter is provided - if not any([sender, recipient, subject, body, label, date_range]): - raise RetryableToolError( - message=( - "At least one of sender, recipient, subject, body, label, query, " - "or date_range must be provided." - ), - developer_message=( - "At least one of sender, recipient, subject, body, label, query, " - "or date_range must be provided." - ), - ) - - # Check if label is valid - if label: - label_ids = get_label_ids(service, [label]) - - if not label_ids: - labels = service.users().labels().list(userId="me").execute().get("labels", []) - label_names = [label["name"] for label in labels] - raise RetryableToolError( - message=f"Invalid label: {label}", - developer_message=f"Invalid label: {label}", - additional_prompt_content=f"List of valid labels: {label_names}", - ) - - # Build a Gmail-style query string based on the filters - query = build_gmail_query_string(sender, recipient, subject, body, date_range, label) - - # Fetch matching messages. This fetches message metadata from Gmail - messages = fetch_messages(service, query, max_results) - - # If no messages found, return an empty list - if not messages: - return {"emails": []} - - # Process each message into a structured email object - emails = get_email_details(service, messages) - - # Return the list of emails in a dictionary with key "emails" - return {"emails": emails} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_emails( - context: ToolContext, - n_emails: Annotated[int, "Number of emails to read"] = 5, -) -> Annotated[dict, "A dictionary containing a list of email details"]: - """ - Read emails from a Gmail account and extract plain text content. - """ - service = _build_gmail_service(context) - - messages = service.users().messages().list(userId="me").execute().get("messages", []) - - if not messages: - return {"emails": []} - - emails = [] - for msg in messages[:n_emails]: - try: - email_data = service.users().messages().get(userId="me", id=msg["id"]).execute() - email_details = parse_plain_text_email(email_data) - if email_details: - emails.append(email_details) - except Exception as e: - raise GmailToolError( - message=f"Error reading email {msg['id']}.", developer_message=str(e) - ) - return {"emails": emails} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def search_threads( - context: ToolContext, - page_token: Annotated[ - str | None, "Page token to retrieve a specific page of results in the list" - ] = None, - max_results: Annotated[int, "The maximum number of threads to return"] = 10, - include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False, - label_ids: Annotated[list[str] | None, "The IDs of labels to filter by"] = None, - sender: Annotated[str | None, "The name or email address of the sender of the email"] = None, - recipient: Annotated[str | None, "The name or email address of the recipient"] = None, - subject: Annotated[str | None, "Words to find in the subject of the email"] = None, - body: Annotated[str | None, "Words to find in the body of the email"] = None, - date_range: Annotated[DateRange | None, "The date range of the email"] = None, -) -> Annotated[dict, "A dictionary containing a list of thread details"]: - """Search for threads in the user's mailbox""" - service = _build_gmail_service(context) - - query = ( - build_gmail_query_string(sender, recipient, subject, body, date_range) - if any([sender, recipient, subject, body, date_range]) - else None - ) - - params = { - "userId": "me", - "maxResults": min(max_results, 500), - "pageToken": page_token, - "includeSpamTrash": include_spam_trash, - "labelIds": label_ids, - "q": query, - } - params = remove_none_values(params) - - threads: list[dict[str, Any]] = [] - next_page_token = None - # Paginate through thread pages until we have the desired number of threads - while len(threads) < max_results: - response = service.users().threads().list(**params).execute() - - threads.extend(response.get("threads", [])) - next_page_token = response.get("nextPageToken") - - if not next_page_token: - break - - params["pageToken"] = next_page_token - params["maxResults"] = min(max_results - len(threads), 500) - - return { - "threads": threads, - "num_threads": len(threads), - "next_page_token": next_page_token, - } - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_threads( - context: ToolContext, - page_token: Annotated[ - str | None, "Page token to retrieve a specific page of results in the list" - ] = None, - max_results: Annotated[int, "The maximum number of threads to return"] = 10, - include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False, -) -> Annotated[dict, "A dictionary containing a list of thread details"]: - """List threads in the user's mailbox.""" - threads: dict[str, Any] = await search_threads( - context, page_token, max_results, include_spam_trash - ) - return threads - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def get_thread( - context: ToolContext, - thread_id: Annotated[str, "The ID of the thread to retrieve"], -) -> Annotated[dict, "A dictionary containing the thread details"]: - """Get the specified thread by ID.""" - params = { - "userId": "me", - "id": thread_id, - "format": "full", - } - params = remove_none_values(params) - - service = _build_gmail_service(context) - - thread = service.users().threads().get(**params).execute() - thread["messages"] = [parse_plain_text_email(message) for message in thread.get("messages", [])] - - return dict(thread) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.modify"], - ) -) -async def change_email_labels( - context: ToolContext, - email_id: Annotated[str, "The ID of the email to modify labels for"], - labels_to_add: Annotated[list[str], "List of label names to add"], - labels_to_remove: Annotated[list[str], "List of label names to remove"], -) -> Annotated[dict, "List of labels that were added, removed, and not found"]: - """ - Add and remove labels from an email using the Gmail API. - """ - service = _build_gmail_service(context) - - add_labels = get_label_ids(service, labels_to_add) - remove_labels = get_label_ids(service, labels_to_remove) - - invalid_labels = ( - set(labels_to_add + labels_to_remove) - set(add_labels.keys()) - set(remove_labels.keys()) - ) - - if invalid_labels: - # prepare the list of valid labels - labels = service.users().labels().list(userId="me").execute().get("labels", []) - label_names = [label["name"] for label in labels] - - # raise a retryable error with the list of valid labels - raise RetryableToolError( - message=f"Invalid labels: {invalid_labels}", - developer_message=f"Invalid labels: {invalid_labels}", - additional_prompt_content=f"List of valid labels: {label_names}", - ) - - # Prepare the modification body with label IDs. - body = { - "addLabelIds": list(add_labels.values()), - "removeLabelIds": list(remove_labels.values()), - } - - try: # Modify the email labels. - service.users().messages().modify(userId="me", id=email_id, body=body).execute() - - except Exception as e: - raise GmailToolError( - message=f"Error modifying labels for email {email_id}", developer_message=str(e) - ) - - # Confirmation JSON with lists for added and removed labels. - confirmation = { - "addedLabels": list(add_labels.keys()), - "removedLabels": list(remove_labels.keys()), - } - - return {"confirmation": dict(confirmation)} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_labels( - context: ToolContext, -) -> Annotated[dict, "A dictionary containing a list of label details"]: - """List all the labels in the user's mailbox.""" - - service = _build_gmail_service(context) - - labels = service.users().labels().list(userId="me").execute().get("labels", []) - - return {"labels": labels} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.labels"], - ) -) -async def create_label( - context: ToolContext, - label_name: Annotated[str, "The name of the label to create"], -) -> Annotated[dict, "The details of the created label"]: - """Create a new label in the user's mailbox.""" - - service = _build_gmail_service(context) - label = service.users().labels().create(userId="me", body={"name": label_name}).execute() - - return {"label": label} diff --git a/toolkits/gmail/arcade_gmail/utils.py b/toolkits/gmail/arcade_gmail/utils.py deleted file mode 100644 index 48aa5ca2..00000000 --- a/toolkits/gmail/arcade_gmail/utils.py +++ /dev/null @@ -1,509 +0,0 @@ -import logging -import re -from base64 import urlsafe_b64decode, urlsafe_b64encode -from datetime import datetime, timedelta -from email.message import EmailMessage -from email.mime.text import MIMEText -from enum import Enum -from typing import Any - -from arcade_tdk import ToolContext -from bs4 import BeautifulSoup -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build - -from arcade_gmail.enums import ( - GmailAction, - GmailReplyToWhom, -) -from arcade_gmail.exceptions import GmailServiceError, GmailToolError - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -class DateRange(Enum): - TODAY = "today" - YESTERDAY = "yesterday" - LAST_7_DAYS = "last_7_days" - LAST_30_DAYS = "last_30_days" - THIS_MONTH = "this_month" - LAST_MONTH = "last_month" - THIS_YEAR = "this_year" - - def to_date_query(self) -> str: - today = datetime.now() - result = "after:" - comparison_date = today - - if self == DateRange.YESTERDAY: - comparison_date = today - timedelta(days=1) - elif self == DateRange.LAST_7_DAYS: - comparison_date = today - timedelta(days=7) - elif self == DateRange.LAST_30_DAYS: - comparison_date = today - timedelta(days=30) - elif self == DateRange.THIS_MONTH: - comparison_date = today.replace(day=1) - elif self == DateRange.LAST_MONTH: - comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1) - elif self == DateRange.THIS_YEAR: - comparison_date = today.replace(month=1, day=1) - elif self == DateRange.LAST_MONTH: - comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace( - month=1, day=1 - ) - - return result + comparison_date.strftime("%Y/%m/%d") - - -def build_email_message( - recipient: str, - subject: str, - body: str, - cc: list[str] | None = None, - bcc: list[str] | None = None, - replying_to: dict[str, Any] | None = None, - action: GmailAction = GmailAction.SEND, -) -> dict[str, Any]: - if replying_to: - body = build_reply_body(body, replying_to) - - message: EmailMessage | MIMEText - - if action == GmailAction.SEND: - message = EmailMessage() - message.set_content(body) - elif action == GmailAction.DRAFT: - message = MIMEText(body) - - message["To"] = recipient - message["Subject"] = subject - - if cc: - message["Cc"] = ",".join(cc) - if bcc: - message["Bcc"] = ",".join(bcc) - if replying_to: - message["In-Reply-To"] = replying_to["header_message_id"] - message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}" - - encoded_message = urlsafe_b64encode(message.as_bytes()).decode() - - data = {"raw": encoded_message} - - if replying_to: - data["threadId"] = replying_to["thread_id"] - - return data - - -def _build_gmail_service(context: ToolContext) -> Any: - """ - Private helper function to build and return the Gmail service client. - - Args: - context (ToolContext): The context containing authorization details. - - Returns: - googleapiclient.discovery.Resource: An authorized Gmail API service instance. - """ - try: - credentials = Credentials( - context.authorization.token - if context.authorization and context.authorization.token - else "" - ) - except Exception as e: - raise GmailServiceError(message="Failed to build Gmail service.", developer_message=str(e)) - - return build("gmail", "v1", credentials=credentials) - - -def build_gmail_query_string( - sender: str | None = None, - recipient: str | None = None, - subject: str | None = None, - body: str | None = None, - date_range: DateRange | None = None, - label: str | None = None, -) -> str: - """Helper function to build a query string - for Gmail list_emails_by_header and search_threads tools. - """ - query = [] - if sender: - query.append(f"from:{sender}") - if recipient: - query.append(f"to:{recipient}") - if subject: - query.append(f"subject:{subject}") - if body: - query.append(body) - if date_range: - query.append(date_range.to_date_query()) - if label: - query.append(f"label:{label}") - return " ".join(query) - - -def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]: - """ - Retrieve label IDs for given label names. - Returns a dictionary mapping label names to their IDs. - - Args: - service: Authenticated Gmail API service instance. - label_names: List of label names to retrieve IDs for. - - Returns: - A dictionary mapping found label names to their corresponding IDs. - """ - try: - # Fetch all existing labels from Gmail - labels = service.users().labels().list(userId="me").execute().get("labels", []) - except Exception as e: - raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e - - # Create a mapping from label names to their IDs - label_id_map = {label["name"]: label["id"] for label in labels} - - found_labels = {} - for name in label_names: - label_id = label_id_map.get(name) - if label_id: - found_labels[name] = label_id - else: - logger.warning(f"Label '{name}' does not exist") - - return found_labels - - -def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]: - """ - Helper function to fetch messages from Gmail API for the list_emails_by_header tool. - """ - response = ( - service.users() - .messages() - .list(userId="me", q=query_string, maxResults=limit or 100) - .execute() - ) - return response.get("messages", []) # type: ignore[no-any-return] - - -def remove_none_values(params: dict) -> dict: - """ - Remove None values from a dictionary. - :param params: The dictionary to clean - :return: A new dictionary with None values removed - """ - return {k: v for k, v in params.items() if v is not None} - - -def build_reply_recipients( - replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom -) -> str: - if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER: - recipients = [replying_to["from"]] - elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT: - recipients = [replying_to["from"], *replying_to["to"].split(",")] - else: - raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}") - - recipients = [ - email_address.strip() - for email_address in recipients - if email_address.strip().lower() != current_user_email_address.lower().strip() - ] - - return ", ".join(recipients) - - -def get_draft_url(draft_id: str) -> str: - return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}" - - -def get_sent_email_url(sent_email_id: str) -> str: - return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}" - - -def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Retrieves full message data for each message ID in the given list and extracts email details. - - :param service: Authenticated Gmail API service instance. - :param messages: A list of dictionaries, each representing a message with an 'id' key. - :return: A list of dictionaries, each containing parsed email details. - """ - - emails = [] - for msg in messages: - try: - # Fetch the full message data from Gmail using the message ID - email_data = service.users().messages().get(userId="me", id=msg["id"]).execute() - # Parse the raw email data into a structured form - email_details = parse_plain_text_email(email_data) - # Only add the details if parsing was successful - if email_details: - emails.append(email_details) - except Exception as e: - # Log any errors encountered while trying to fetch or parse a message - raise GmailToolError( - message=f"Error reading email {msg['id']}.", developer_message=str(e) - ) - return emails - - -def get_email_in_trash_url(email_id: str) -> str: - return f"https://mail.google.com/mail/u/0/#trash/{email_id}" - - -def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]: - """ - Parse draft email data and extract relevant information. - - Args: - draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API. - - Returns: - dict[str, str]: Parsed draft email details - """ - message = draft_email_data.get("message", {}) - payload = message.get("payload", {}) - headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])} - - body_data = _get_email_plain_text_body(payload) - - return { - "id": draft_email_data.get("id", ""), - "thread_id": draft_email_data.get("threadId", ""), - "from": headers.get("from", ""), - "date": headers.get("internaldate", ""), - "subject": headers.get("subject", ""), - "body": _clean_email_body(body_data) if body_data else "", - } - - -def _clean_email_body(body: str | None) -> str: - """ - Remove HTML tags and clean up email body text while preserving most content. - - Args: - body (str): The raw email body text. - - Returns: - str: Cleaned email body text. - """ - if not body: - return "" - - try: - # Remove HTML tags using BeautifulSoup - soup = BeautifulSoup(body, "html.parser") - text = soup.get_text(separator=" ") - - # Clean up the text - cleaned_text = _clean_text(text) - - return cleaned_text.strip() - except Exception: - logger.exception("Error cleaning email body") - return body - - -def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None: - """ - Extract email body from payload, handling 'multipart/alternative' parts. - - Args: - payload (Dict[str, Any]): Email payload data. - - Returns: - str | None: Decoded email body or None if not found. - """ - # Direct body extraction - if "body" in payload and payload["body"].get("data"): - return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode()) - - # Handle multipart and alternative parts - return _clean_email_body(_extract_plain_body(payload.get("parts", []))) - - -def _extract_plain_body(parts: list) -> str | None: - """ - Recursively extract the email body from parts, handling both plain text and HTML. - - Args: - parts (List[Dict[str, Any]]): List of email parts. - - Returns: - str | None: Decoded and cleaned email body or None if not found. - """ - for part in parts: - mime_type = part.get("mimeType") - - if mime_type == "text/plain" and "data" in part.get("body", {}): - return urlsafe_b64decode(part["body"]["data"]).decode() - - elif mime_type.startswith("multipart/"): - subparts = part.get("parts", []) - body = _extract_plain_body(subparts) - if body: - return body - - return _extract_html_body(parts) - - -def _extract_html_body(parts: list) -> str | None: - """ - Recursively extract the email body from parts, handling only HTML. - - Args: - parts (List[Dict[str, Any]]): List of email parts. - - Returns: - str | None: Decoded and cleaned email body or None if not found. - """ - for part in parts: - mime_type = part.get("mimeType") - - if mime_type == "text/html" and "data" in part.get("body", {}): - html_content = urlsafe_b64decode(part["body"]["data"]).decode() - return html_content - - elif mime_type.startswith("multipart/"): - subparts = part.get("parts", []) - body = _extract_html_body(subparts) - if body: - return body - - return None - - -def _clean_text(text: str) -> str: - """ - Clean up the text while preserving most content. - - Args: - text (str): The input text. - - Returns: - str: Cleaned text. - """ - # Replace multiple newlines with a single newline - text = re.sub(r"\n+", "\n", text) - - # Replace multiple spaces with a single space - text = re.sub(r"\s+", " ", text) - - # Remove leading/trailing whitespace from each line - text = "\n".join(line.strip() for line in text.split("\n")) - - return text - - -def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]: - """ - Parse email data and extract relevant information. - Only returns the plain text body. - - Args: - email_data (dict[str, Any]): Raw email data from Gmail API. - - Returns: - dict[str, str]: Parsed email details - """ - payload = email_data.get("payload", {}) - headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])} - - body_data = _get_email_plain_text_body(payload) - - email_details = { - "id": email_data.get("id", ""), - "thread_id": email_data.get("threadId", ""), - "label_ids": email_data.get("labelIds", []), - "history_id": email_data.get("historyId", ""), - "snippet": email_data.get("snippet", ""), - "to": headers.get("to", ""), - "cc": headers.get("cc", ""), - "from": headers.get("from", ""), - "reply_to": headers.get("reply-to", ""), - "in_reply_to": headers.get("in-reply-to", ""), - "references": headers.get("references", ""), - "header_message_id": headers.get("message-id", ""), - "date": headers.get("date", ""), - "subject": headers.get("subject", ""), - "body": body_data or "", - } - - return email_details - - -def build_reply_body(body: str, replying_to: dict[str, Any]) -> str: - attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:" - lines = replying_to["plain_text_body"].split("\n") - quoted_plain = "\n".join([f"> {line}" for line in lines]) - return f"{body}\n\n{attribution}\n\n{quoted_plain}" - - -def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]: - """ - Parse email data and extract relevant information. - Returns the plain text and HTML body along with the images. - - Args: - email_data (Dict[str, Any]): Raw email data from Gmail API. - - Returns: - dict[str, Any]: Parsed email details - """ - - payload = email_data.get("payload", {}) - headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])} - - # Extract different parts of the email - plain_text_body = _get_email_plain_text_body(payload) - html_body = _get_email_html_body(payload) - - email_details = { - "id": email_data.get("id", ""), - "thread_id": email_data.get("threadId", ""), - "label_ids": email_data.get("labelIds", []), - "history_id": email_data.get("historyId", ""), - "snippet": email_data.get("snippet", ""), - "to": headers.get("to", ""), - "cc": headers.get("cc", ""), - "from": headers.get("from", ""), - "reply_to": headers.get("reply-to", ""), - "in_reply_to": headers.get("in-reply-to", ""), - "references": headers.get("references", ""), - "header_message_id": headers.get("message-id", ""), - "date": headers.get("date", ""), - "subject": headers.get("subject", ""), - "plain_text_body": plain_text_body or _clean_email_body(html_body), - "html_body": html_body or "", - } - - return email_details - - -def _get_email_html_body(payload: dict[str, Any]) -> str | None: - """ - Extract email html body from payload, handling 'multipart/alternative' parts. - - Args: - payload (Dict[str, Any]): Email payload data. - - Returns: - str | None: Decoded email body or None if not found. - """ - # Direct body extraction - if "body" in payload and payload["body"].get("data"): - return urlsafe_b64decode(payload["body"]["data"]).decode() - - # Handle multipart and alternative parts - return _extract_html_body(payload.get("parts", [])) diff --git a/toolkits/gmail/evals/eval_google_gmail.py b/toolkits/gmail/evals/eval_google_gmail.py deleted file mode 100644 index 7271ce78..00000000 --- a/toolkits/gmail/evals/eval_google_gmail.py +++ /dev/null @@ -1,431 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_gmail -from arcade_gmail.enums import GmailReplyToWhom -from arcade_gmail.tools import ( - get_thread, - list_emails_by_header, - list_threads, - reply_to_email, - search_threads, - send_email, - write_draft_reply_email, -) -from arcade_gmail.utils import DateRange - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_gmail) - - -@tool_eval() -def gmail_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail tools.""" - suite = EvalSuite( - name="Gmail Tools Evaluation", - system_message="You are an AI assistant that can send and manage emails using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Send email to user with clear username", - user_message="Send a email to johndoe@example.com saying 'Hello, can we meet at 3 PM?'. CC his boss janedoe@example.com", - expected_tool_calls=[ - ExpectedToolCall( - func=send_email, - args={ - "subject": "Meeting Request", - "body": "Hello, can we meet at 3 PM?", - "recipient": "johndoe@example.com", - "cc": ["janedoe@example.com"], - "bcc": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=0.125), - SimilarityCritic(critic_field="body", weight=0.25), - BinaryCritic(critic_field="recipient", weight=0.25), - BinaryCritic(critic_field="cc", weight=0.25), - BinaryCritic(critic_field="bcc", weight=0.125), - ], - ) - - suite.add_case( - name="Simple list threads", - user_message="Get 42 threads like right now i even wanna see the ones in my trash", - expected_tool_calls=[ - ExpectedToolCall( - func=list_threads, - args={"max_results": 42, "include_spam_trash": True}, - ) - ], - critics=[ - BinaryCritic(critic_field="max_results", weight=0.5), - BinaryCritic(critic_field="include_spam_trash", weight=0.5), - ], - ) - - history = [ - {"role": "user", "content": "list 1 thread"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_X8V5Hw9iJ3wfB8WMZf8omAMi", - "type": "function", - "function": {"name": "Google_ListThreads", "arguments": '{"max_results":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"next_page_token":"10321400718999360131","num_threads":1,"threads":[{"historyId":"61691","id":"1934a8f8deccb749","snippet":"Hi Joe, I hope this email finds you well. Thank you for being a part of our community."}]}', - "tool_call_id": "call_X8V5Hw9iJ3wfB8WMZf8omAMi", - "name": "Google_ListThreads", - }, - { - "role": "assistant", - "content": "Here is one email thread:\n\n- **Snippet:** Hi Joe, I hope this email finds you well. Thank you for being a part of our community.\n- **Thread ID:** 1934a8f8deccb749\n- **History ID:** 61691", - }, - ] - suite.add_case( - name="List threads with history", - user_message="Get the next 5 threads", - additional_messages=history, - expected_tool_calls=[ - ExpectedToolCall( - func=list_threads, - args={ - "max_results": 5, - "page_token": "10321400718999360131", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="max_results", weight=0.2), - BinaryCritic(critic_field="page_token", weight=0.8), - ], - ) - - suite.add_case( - name="Search threads", - user_message="Search for threads from johndoe@example.com to janedoe@example.com about that talk about 'Arcade AI' from yesterday", - expected_tool_calls=[ - ExpectedToolCall( - func=search_threads, - args={ - "sender": "johndoe@example.com", - "recipient": "janedoe@example.com", - "body": "Arcade AI", - "date_range": DateRange.YESTERDAY, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="sender", weight=0.25), - BinaryCritic(critic_field="recipient", weight=0.25), - SimilarityCritic(critic_field="body", weight=0.25), - BinaryCritic(critic_field="date_range", weight=0.25), - ], - ) - - suite.add_case( - name="Get a thread by ID", - user_message="Get the thread r-124325435467568867667878874565464564563523424323524235242412", - expected_tool_calls=[ - ExpectedToolCall( - func=get_thread, - args={ - "thread_id": "r-124325435467568867667878874565464564563523424323524235242412", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="thread_id", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def gmail_reply_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail reply tools.""" - suite = EvalSuite( - name="Gmail Reply Tools Evaluation", - system_message="You are an AI assistant that can send and manage emails using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - email_history = [ - {"role": "user", "content": "get the latest emails I received from johndoe@gmail.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_jowMD7aB9sVPClOfvNof7Llu", - "type": "function", - "function": { - "name": "Google_ListEmailsByHeader", - "arguments": json.dumps({ - "sender": "johndoe@gmail.com", - "max_results": 5, - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "emails": [ - { - "body": "test 1", - "cc": "", - "date": "Tue, 11 Feb 2025 11:33:08 -0300", - "from": "John Doe ", - "header_message_id": "", - "history_id": "123456", - "id": "q34759q435nv", - "in_reply_to": "", - "label_ids": ["INBOX"], - "references": "", - "reply_to": "", - "snippet": "test 1", - "subject": "test 1", - "thread_id": "345y6v3596", - "to": "myself@gmail.com", - }, - { - "body": "test 2", - "cc": "", - "date": "Mon, 20 Jan 2025 13:04:42 -0800", - "from": "John Doe ", - "header_message_id": "<28745ytvw8745ct4@mail.gmail.com>", - "history_id": "3456758", - "id": "9475tvy24578yx", - "in_reply_to": "", - "label_ids": [], - "references": "", - "reply_to": "", - "snippet": "test 2", - "subject": "test 2", - "thread_id": "249576v3496", - "to": "myself@gmail.com", - }, - ] - }), - "tool_call_id": "call_jowMD7aB9sVPClOfvNof7Llu", - "name": "Google_ListEmailsByHeader", - }, - { - "role": "assistant", - "content": "Here are the latest emails you received from johndoe@gmail.com:\n\n1. **Subject**: test 1\n - **Date**: Tue, 11 Feb 2025 11:33:08 -0300\n - **Snippet**: test 1\n\n2. **Subject**: test 2\n - **Date**: Mon, 20 Jan 2025 13:04:42 -0800\n - **Snippet**: test 2\n\nIf you need further details from any specific email, let me know!", - }, - ] - - suite.add_case( - name="Reply to an email", - user_message="Reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Reply to an email with every recipient", - user_message="Reply to every recipient in the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.EVERY_RECIPIENT.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Reply to an email with bcc", - user_message="Reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well' and send it to janedoe@example.com as bcc as well", - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "bcc": ["janedoe@example.com"], - "reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Write draft reply", - user_message="Write a draft reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=write_draft_reply_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Write draft reply to every recipient", - user_message="Write a draft reply to every recipient in the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=write_draft_reply_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.EVERY_RECIPIENT.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=0.125), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - return suite - - -@tool_eval() -def gmail_list_emails_by_header_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail tools.""" - suite = EvalSuite( - name="Gmail list_emails_by_header tool evaluation", - system_message="You are an AI assistant that can send and manage emails using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List emails by header using date-range", - user_message="List all emails from johndoe@example.com to janedoe@example.com about 'Arcade AI' from yesterday", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_header, - args={ - "sender": "johndoe@example.com", - "recipient": "janedoe@example.com", - "subject": "Arcade AI", - "date_range": DateRange.YESTERDAY.value, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="sender", weight=1 / 4), - BinaryCritic(critic_field="recipient", weight=1 / 4), - SimilarityCritic(critic_field="subject", weight=1 / 4), - BinaryCritic(critic_field="date_range", weight=1 / 4), - ], - ) - - suite.add_case( - name="List emails by header using date-range", - user_message="List all emails from johndoe@example.com to janedoe@example.com about 'Arcade AI' from the last month", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_header, - args={ - "sender": "johndoe@example.com", - "recipient": "janedoe@example.com", - "subject": "Arcade AI", - "date_range": DateRange.LAST_MONTH.value, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="sender", weight=1 / 4), - BinaryCritic(critic_field="recipient", weight=1 / 4), - SimilarityCritic(critic_field="subject", weight=1 / 4), - BinaryCritic(critic_field="date_range", weight=1 / 4), - ], - ) - - return suite diff --git a/toolkits/gmail/pyproject.toml b/toolkits/gmail/pyproject.toml deleted file mode 100644 index 710149ca..00000000 --- a/toolkits/gmail/pyproject.toml +++ /dev/null @@ -1,64 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_gmail" -version = "2.0.0" -description = "Arcade.dev LLM tools for Gmail" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "beautifulsoup4>=4.10.0,<5.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_gmail/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_gmail",] diff --git a/toolkits/gmail/tests/__init__.py b/toolkits/gmail/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/gmail/tests/test_gmail.py b/toolkits/gmail/tests/test_gmail.py deleted file mode 100644 index a1873c2b..00000000 --- a/toolkits/gmail/tests/test_gmail.py +++ /dev/null @@ -1,951 +0,0 @@ -from base64 import urlsafe_b64encode -from email.message import EmailMessage -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_gmail.enums import GmailReplyToWhom -from arcade_gmail.tools import ( - delete_draft_email, - get_thread, - list_draft_emails, - list_emails, - list_emails_by_header, - list_threads, - reply_to_email, - search_threads, - send_draft_email, - send_email, - trash_email, - update_draft_email, - write_draft_email, -) -from arcade_gmail.utils import ( - build_reply_body, - parse_draft_email, - parse_multipart_email, - parse_plain_text_email, -) - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_send_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await send_email( - context=mock_context, - subject="Test Subject", - body="Test Body", - recipient="test@example.com", - ) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().messages().send().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid recipient"}}', - ) - - with pytest.raises(ToolExecutionError): - await send_email( - context=mock_context, - subject="Test Subject", - body="Test Body", - recipient="invalid@example.com", - ) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_write_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await write_draft_email( - context=mock_context, - subject="Test Draft Subject", - body="Test Draft Body", - recipient="draft@example.com", - ) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().drafts().create().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await write_draft_email( - context=mock_context, - subject="Test Draft Subject", - body="Test Draft Body", - recipient="draft@example.com", - ) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_update_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await update_draft_email( - context=mock_context, - draft_email_id="draft123", - subject="Updated Subject", - body="Updated Body", - recipient="updated@example.com", - ) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().drafts().update().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Draft not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await update_draft_email( - context=mock_context, - draft_email_id="nonexistent_draft", - subject="Updated Subject", - body="Updated Body", - recipient="updated@example.com", - ) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_send_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await send_draft_email(context=mock_context, email_id="draft456") - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().drafts().send().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Draft not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await send_draft_email(context=mock_context, email_id="nonexistent_draft") - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_delete_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await delete_draft_email(context=mock_context, draft_email_id="draft789") - - assert "Draft email with ID" in result - assert "deleted successfully" in result - - # Test http error - mock_service.users().drafts().delete().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Draft not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await delete_draft_email(context=mock_context, draft_email_id="nonexistent_draft") - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -@patch("arcade_gmail.tools.gmail.parse_draft_email") -async def test_get_draft_emails(mock_parse_draft_email, mock_build, mock_context): - # Setup test data - mock_drafts_list_response = { - "drafts": [ - { - "id": "r9999999999999999999", - "message": {"id": "0000000000000000", "threadId": "0000000000000000"}, - } - ], - "resultSizeEstimate": 1, - } - mock_drafts_get_response = { - "id": "r9999999999999999999", - "message": { - "id": "0000000000000000", - "threadId": "0000000000000000", - "labelIds": ["DRAFT"], - "snippet": "Hello! This is a test. Best regards, John", - "payload": { - "partId": "", - "mimeType": "text/plain", - "filename": "", - "headers": [ - {"name": "to", "value": "test@arcade-ai.com"}, - {"name": "subject", "value": "New Draft"}, - {"name": "Date", "value": "Mon, 16 Sep 2024 13:02:10 -0400"}, - {"name": "From", "value": "john-doe@arcade-ai.com"}, - ], - "body": { - "size": 41, - "data": "SGVsbG8hIFRoaXMgaXMgYSB0ZXN0LgoKQmVzdCByZWdhcmRzLApCb2I=", - }, - }, - "sizeEstimate": 453, - "historyId": "7061", - "internalDate": "1726506130000", - }, - } - - # Setup mocking - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the response from the Gmail list drafts API - mock_service.users().drafts().list().execute.return_value = mock_drafts_list_response - - # Mock the response from the Gmail get drafts API - mock_service.users().drafts().get().execute.return_value = mock_drafts_get_response - - # Mock the parse_draft_email function since parse_draft_email doesn't accept object of type MagicMock - mock_parse_draft_email.return_value = parse_draft_email(mock_drafts_get_response) - - # Test happy path - result = await list_draft_emails(context=mock_context, n_drafts=2) - - assert isinstance(result, dict) - assert "emails" in result - assert len(result["emails"]) == 1 - assert all("id" in draft and "subject" in draft for draft in result["emails"]) - - # Test http error - mock_service.users().drafts().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_draft_emails(context=mock_context, n_drafts=2) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -@patch("arcade_gmail.tools.gmail.parse_plain_text_email") -async def test_search_emails_by_header(mock_parse_plain_text_email, mock_build, mock_context): - # Setup test data - mock_messages_list_response = { - "messages": [ - {"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"}, - {"id": "191fbc0ea11efa90", "threadId": "191fbc0ea11efa90"}, - ], - "nextPageToken": "00755945214480102915", - "resultSizeEstimate": 201, - } - mock_messages_get_response = { - "id": "191f2cf4d24bf23d", - "threadId": "191f2cf4d24bf23d", - "labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"], - "snippet": "Hey User, Your personal access token (classic) "ArcadeAI" with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,", - "payload": { - "partId": "", - "mimeType": "text/plain", - "filename": "", - "headers": [ - {"name": "Delivered-To", "value": "example@arcade-ai.com"}, - {"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"}, - {"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"}, - {"name": "To", "value": "example@arcade-ai.com"}, - { - "name": "Subject", - "value": "[GitHub] Your personal access token (classic) has expired", - }, - ], - "body": { - "size": 605, - "data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=", - }, - }, - "sizeEstimate": 4512, - "historyId": "5508", - "internalDate": "1726355557000", - } - - # Setup mocking - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the response from the Gmail list messages API - mock_service.users().messages().list().execute.return_value = mock_messages_list_response - - # Mock the response from the Gmail get messages API - mock_service.users().messages().get().execute.return_value = mock_messages_get_response - - # Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock - mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response) - - # Test happy path - result = await list_emails_by_header( - context=mock_context, sender="noreply@github.com", max_results=2 - ) - - assert isinstance(result, dict) - assert "emails" in result - assert len(result["emails"]) == 2 - assert all("id" in email and "subject" in email for email in result["emails"]) - - # Test http error - mock_service.users().messages().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_emails_by_header( - context=mock_context, sender="noreply@github.com", max_results=2 - ) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -@patch("arcade_gmail.tools.gmail.parse_plain_text_email") -async def test_get_emails(mock_parse_plain_text_email, mock_build, mock_context): - # Setup test data - mock_messages_list_response = { - "messages": [ - {"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"}, - ], - "nextPageToken": "00755945214480102915", - "resultSizeEstimate": 1, - } - mock_messages_get_response = { - "id": "191f2cf4d24bf23d", - "threadId": "191f2cf4d24bf23d", - "labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"], - "snippet": "Hey User, Your personal access token (classic) "ArcadeAI" with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,", - "payload": { - "partId": "", - "mimeType": "text/plain", - "filename": "", - "headers": [ - {"name": "Delivered-To", "value": "example@arcade-ai.com"}, - {"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"}, - {"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"}, - {"name": "To", "value": "example@arcade-ai.com"}, - { - "name": "Subject", - "value": "[GitHub] Your personal access token (classic) has expired", - }, - ], - "body": { - "size": 605, - "data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=", - }, - }, - "sizeEstimate": 4512, - "historyId": "5508", - "internalDate": "1726355557000", - } - - # Setup mocking - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the response from the Gmail list messages API - mock_service.users().messages().list().execute.return_value = mock_messages_list_response - - # Mock the Gmail get messages API - mock_service.users().messages().get().execute.return_value = mock_messages_get_response - - # Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock - mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response) - - # Test happy path - result = await list_emails(context=mock_context, n_emails=1) - - assert isinstance(result, dict) - assert "emails" in result - assert len(result["emails"]) == 1 - assert "id" in result["emails"][0] - assert "subject" in result["emails"][0] - assert "date" in result["emails"][0] - assert "body" in result["emails"][0] - - # Test http error - mock_service.users().messages().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_emails(context=mock_context, n_emails=1) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_trash_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - email_id = "123456" - result = await trash_email(context=mock_context, email_id=email_id) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().messages().trash().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Email not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await trash_email(context=mock_context, email_id="nonexistent_email") - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_search_threads(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Setup mock response data - mock_threads_list_response = { - "threads": [ - { - "id": "thread1", - "snippet": "Thread snippet 1", - }, - { - "id": "thread2", - "snippet": "Thread snippet 2", - }, - ], - "nextPageToken": "next_token_123", - "resultSizeEstimate": 2, - } - - # Mock the Gmail API threads().list() method - mock_service.users().threads().list().execute.return_value = mock_threads_list_response - - # Test happy path - result = await search_threads( - context=mock_context, - sender="test@example.com", - max_results=2, - ) - - assert isinstance(result, dict) - assert "threads" in result - assert len(result["threads"]) == 2 - assert result["threads"][0]["id"] == "thread1" - assert "next_page_token" in result - - # Test error handling - mock_service.users().threads().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await search_threads( - context=mock_context, - sender="test@example.com", - max_results=2, - ) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_list_threads(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Setup mock response data - mock_threads_list_response = { - "threads": [ - { - "id": "thread1", - "snippet": "Thread snippet 1", - }, - { - "id": "thread2", - "snippet": "Thread snippet 2", - }, - ], - "nextPageToken": "next_token_123", - "resultSizeEstimate": 2, - } - - # Mock the Gmail API threads().list() method - mock_service.users().threads().list().execute.return_value = mock_threads_list_response - - # Test happy path - result = await list_threads( - context=mock_context, - max_results=2, - ) - - assert isinstance(result, dict) - assert "threads" in result - assert len(result["threads"]) == 2 - assert result["threads"][0]["id"] == "thread1" - assert "next_page_token" in result - - # Test error handling - mock_service.users().threads().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_threads( - context=mock_context, - max_results=2, - ) - - -@pytest.mark.asyncio -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_get_thread(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Setup mock response data - mock_thread_get_response = { - "id": "thread1", - "messages": [ - { - "id": "message1", - "snippet": "Message snippet 1", - }, - { - "id": "message2", - "snippet": "Message snippet 2", - }, - ], - } - - # Mock the Gmail API threads().get() method - mock_service.users().threads().get().execute.return_value = mock_thread_get_response - - # Test happy path - result = await get_thread( - context=mock_context, - thread_id="thread1", - ) - - assert isinstance(result, dict) - assert "id" in result - assert result["id"] == "thread1" - assert "messages" in result - assert len(result["messages"]) == 2 - assert result["messages"][0]["id"] == "message1" - - # Test error handling - mock_service.users().threads().get().execute.side_effect = HttpError( - resp=MagicMock(status=404), - content=b'{"error": {"message": "Thread not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await get_thread( - context=mock_context, - thread_id="invalid_thread", - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "reply_to_whom, expected_to, expected_cc", - [ - ( - GmailReplyToWhom.EVERY_RECIPIENT, - "sender@example.com, to1@example.com, to2@example.com", - "cc1@example.com, cc2@example.com", - ), - ( - GmailReplyToWhom.ONLY_THE_SENDER, - "sender@example.com", - "", - ), - ], -) -@patch("arcade_gmail.tools.gmail._build_gmail_service") -async def test_reply_to_email(mock_build, reply_to_whom, expected_to, expected_cc, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - original_message = { - "id": "id123456", - "threadId": "thread123456", - "payload": { - "headers": [ - {"name": "Message-ID", "value": "id123456"}, - {"name": "Subject", "value": "test"}, - {"name": "From", "value": "sender@example.com"}, - {"name": "To", "value": "to1@example.com, to2@example.com, test@example.com"}, - {"name": "Cc", "value": "cc1@example.com, cc2@example.com"}, - {"name": "References", "value": "thread123456"}, - ], - }, - } - - mock_service.users().getProfile().execute.return_value = {"emailAddress": "test@example.com"} - mock_service.users().messages().get().execute.return_value = original_message - - result = await reply_to_email( - context=mock_context, - body="test", - reply_to_message_id="id123456", - reply_to_whom=reply_to_whom, - ) - - assert isinstance(result, dict) - assert "url" in result - - replying_to = parse_multipart_email(original_message) - expected_body = build_reply_body("test", replying_to) - - expected_message = EmailMessage() - expected_message.set_content(expected_body) - expected_message["To"] = expected_to - expected_message["Subject"] = "Re: test" - if expected_cc: - expected_message["Cc"] = expected_cc - expected_message["In-Reply-To"] = "id123456" - expected_message["References"] = "id123456, thread123456" - - mock_service.users().messages().send.assert_called_once_with( - userId="me", - body={ - "raw": urlsafe_b64encode(expected_message.as_bytes()).decode(), - "threadId": "thread123456", - }, - ) - - -def test_parse_multipart_email_full(): - """ - Test parsing a multipart email with both plain text and HTML bodies. - """ - email_data = { - "id": "email123", - "threadId": "thread123", - "labelIds": ["INBOX", "UNREAD"], - "historyId": "history123", - "snippet": "This is a test email.", - "payload": { - "headers": [ - {"name": "To", "value": "recipient@example.com"}, - {"name": "From", "value": "sender@example.com"}, - {"name": "Subject", "value": "Test Email"}, - {"name": "Date", "value": "Mon, 1 Jan 2024 10:00:00 -0000"}, - ], - "body": {"size": 100, "data": "VGhpcyBpcyBhIHRlc3QgZW1haWwu"}, - }, - } - - with ( - patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_gmail.utils._get_email_html_body") as mock_html, - patch("arcade_gmail.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "This is a test email." - mock_html.return_value = "

This is a test email.

" - mock_clean.return_value = "This is a test email." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email123" - assert result["thread_id"] == "thread123" - assert result["label_ids"] == ["INBOX", "UNREAD"] - assert result["snippet"] == "This is a test email." - assert result["to"] == "recipient@example.com" - assert result["from"] == "sender@example.com" - assert result["subject"] == "Test Email" - assert result["date"] == "Mon, 1 Jan 2024 10:00:00 -0000" - assert result["plain_text_body"] == "This is a test email." - assert result["html_body"] == "

This is a test email.

" - - -def test_parse_multipart_email_plain_only(): - """ - Test parsing an email with only a plain text body. - """ - email_data = { - "id": "email456", - "threadId": "thread456", - "labelIds": ["INBOX"], - "historyId": "history456", - "snippet": "Plain text only email.", - "payload": { - "headers": [ - {"name": "To", "value": "recipient2@example.com"}, - {"name": "From", "value": "sender2@example.com"}, - {"name": "Subject", "value": "Plain Text Email"}, - {"name": "Date", "value": "Tue, 2 Feb 2024 11:00:00 -0000"}, - ], - "body": {"size": 150, "data": "UGxhaW4gdGV4dCBvbmx5IGVtYWlsLg=="}, - }, - } - - with ( - patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_gmail.utils._get_email_html_body") as mock_html, - patch("arcade_gmail.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "Plain text only email." - mock_html.return_value = None - mock_clean.return_value = "Plain text only email." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email456" - assert result["thread_id"] == "thread456" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Plain text only email." - assert result["to"] == "recipient2@example.com" - assert result["from"] == "sender2@example.com" - assert result["subject"] == "Plain Text Email" - assert result["date"] == "Tue, 2 Feb 2024 11:00:00 -0000" - assert result["plain_text_body"] == "Plain text only email." - assert result["html_body"] == "" - - -def test_parse_multipart_email_html_only(): - """ - Test parsing an email with only an HTML body. - """ - email_data = { - "id": "email789", - "threadId": "thread789", - "labelIds": ["SENT"], - "historyId": "history789", - "snippet": "HTML only email.", - "payload": { - "headers": [ - {"name": "To", "value": "recipient3@example.com"}, - {"name": "From", "value": "sender3@example.com"}, - {"name": "Subject", "value": "HTML Email"}, - {"name": "Date", "value": "Wed, 3 Mar 2024 12:00:00 -0000"}, - ], - "body": {"size": 200, "data": "PGh0bWw+VGhpcyBpcyBIVE1MIGVtYWlsLjwvaHRtbD4="}, - }, - } - - with ( - patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_gmail.utils._get_email_html_body") as mock_html, - patch("arcade_gmail.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = None - mock_html.return_value = "This is HTML email." - mock_clean.return_value = "This is HTML email." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email789" - assert result["thread_id"] == "thread789" - assert result["label_ids"] == ["SENT"] - assert result["snippet"] == "HTML only email." - assert result["to"] == "recipient3@example.com" - assert result["from"] == "sender3@example.com" - assert result["subject"] == "HTML Email" - assert result["date"] == "Wed, 3 Mar 2024 12:00:00 -0000" - assert result["plain_text_body"] == "This is HTML email." - assert result["html_body"] == "This is HTML email." - - -def test_parse_multipart_email_missing_payload(): - """ - Test parsing an email with missing payload. - """ - email_data = { - "id": "email000", - "threadId": "thread000", - "labelIds": ["INBOX"], - "historyId": "history000", - "snippet": "Missing payload email.", - # 'payload' key is missing - } - - result = parse_multipart_email(email_data) - - # Since payload is missing, headers and bodies should be default or empty - assert result["id"] == "email000" - assert result["thread_id"] == "thread000" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Missing payload email." - assert result["to"] == "" - assert result["from"] == "" - assert result["subject"] == "" - assert result["date"] == "" - assert result["plain_text_body"] == "" - assert result["html_body"] == "" - - -def test_parse_multipart_email_missing_headers(): - """ - Test parsing an email with missing headers in the payload. - """ - email_data = { - "id": "email111", - "threadId": "thread111", - "labelIds": ["INBOX"], - "historyId": "history111", - "snippet": "Missing headers email.", - "payload": { - # 'headers' key is missing - "body": {"size": 100, "data": "VGltZWw="} - }, - } - - with ( - patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_gmail.utils._get_email_html_body") as mock_html, - patch("arcade_gmail.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "Timeel" - mock_html.return_value = "

Timeel

" - mock_clean.return_value = "Timeel" - - result = parse_multipart_email(email_data) - - assert result["id"] == "email111" - assert result["thread_id"] == "thread111" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Missing headers email." - assert result["to"] == "" - assert result["from"] == "" - assert result["subject"] == "" - assert result["date"] == "" - assert result["plain_text_body"] == "Timeel" - assert result["html_body"] == "

Timeel

" - - -def test_parse_multipart_email_missing_fields(): - """ - Test parsing an email with some missing fields in headers. - """ - email_data = { - "id": "email222", - "threadId": "thread222", - "labelIds": ["INBOX"], - "historyId": "history222", - "snippet": "Missing some headers.", - "payload": { - "headers": [ - {"name": "From", "value": "sender4@example.com"}, - {"name": "Subject", "value": "Partial Headers"}, - # 'To' and 'Date' headers are missing - ], - "body": {"size": 100, "data": "TWlzc2luZyBzb21lIGhlYWRlcnMu"}, - }, - } - - with ( - patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_gmail.utils._get_email_html_body") as mock_html, - patch("arcade_gmail.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "Missing some headers." - mock_html.return_value = None - mock_clean.return_value = "Missing some headers." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email222" - assert result["thread_id"] == "thread222" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Missing some headers." - assert result["to"] == "" - assert result["from"] == "sender4@example.com" - assert result["subject"] == "Partial Headers" - assert result["date"] == "" - assert result["plain_text_body"] == "Missing some headers." - assert result["html_body"] == "" - - -def test_parse_multipart_email_empty(): - """ - Test parsing an empty email data. - """ - email_data = {} - - result = parse_multipart_email(email_data) - - assert result["id"] == "" - assert result["thread_id"] == "" - assert result["label_ids"] == [] - assert result["snippet"] == "" - assert result["to"] == "" - assert result["from"] == "" - assert result["subject"] == "" - assert result["date"] == "" - assert result["plain_text_body"] == "" - assert result["html_body"] == "" - - -def test_parse_multipart_email_invalid_payload_structure(): - """ - Test parsing an email with an invalid payload structure. - """ - email_data = { - "id": "email333", - "threadId": "thread333", - "labelIds": ["INBOX"], - "historyId": "history333", - "snippet": "Invalid payload structure.", - "payload": { - "headers": "This should be a list, not a string", - "body": {"size": 100, "data": "SW52YWxpZCBwYXlsb2Fk"}, - }, - } - - with pytest.raises(TypeError): - parse_multipart_email(email_data) diff --git a/toolkits/google/.pre-commit-config.yaml b/toolkits/google/.pre-commit-config.yaml deleted file mode 100644 index 310d8911..00000000 --- a/toolkits/google/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google/.ruff.toml b/toolkits/google/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google/LICENSE b/toolkits/google/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/google/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google/Makefile b/toolkits/google/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google/arcade_google/__init__.py b/toolkits/google/arcade_google/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google/arcade_google/constants.py b/toolkits/google/arcade_google/constants.py deleted file mode 100644 index c2bff210..00000000 --- a/toolkits/google/arcade_google/constants.py +++ /dev/null @@ -1,24 +0,0 @@ -import os - -from arcade_google.models import GmailReplyToWhom - -# The default reply in Gmail is to only the sender. Since Gmail also offers the possibility of -# changing the default to 'reply to all', we support both options through an env variable. -# https://support.google.com/mail/answer/6585?hl=en&sjid=15399867888091633568-SA#null -try: - GMAIL_DEFAULT_REPLY_TO = GmailReplyToWhom( - # Values accepted are defined in the arcade_google.tools.models.GmailReplyToWhom Enum - os.getenv("ARCADE_GMAIL_DEFAULT_REPLY_TO", GmailReplyToWhom.ONLY_THE_SENDER.value).lower() - ) -except ValueError as e: - raise ValueError( - "Invalid value for ARCADE_GMAIL_DEFAULT_REPLY_TO: " - f"'{os.getenv('ARCADE_GMAIL_DEFAULT_REPLY_TO')}'. Expected one of " - f"{list(GmailReplyToWhom.__members__.keys())}" - ) from e - - -DEFAULT_SEARCH_CONTACTS_LIMIT = 30 - -DEFAULT_SHEET_ROW_COUNT = 1000 -DEFAULT_SHEET_COLUMN_COUNT = 26 diff --git a/toolkits/google/arcade_google/critics.py b/toolkits/google/arcade_google/critics.py deleted file mode 100644 index 502a946a..00000000 --- a/toolkits/google/arcade_google/critics.py +++ /dev/null @@ -1,41 +0,0 @@ -from collections.abc import Collection -from typing import Any - -from arcade_evals import DatetimeCritic - - -class DatetimeOrNoneCritic(DatetimeCritic): - """ - A critic that evaluates the closeness of datetime values within a specified tolerance or whether - it's a None value. - - Attributes: - tolerance: Acceptable timedelta between expected and actual datetimes. - max_difference: Maximum timedelta for a partial score. - """ - - def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]: - if actual is None: - return {"match": True, "score": self.weight} - return super().evaluate(expected, actual) - - -class AnyDatetimeCritic(DatetimeCritic): - """ - A critic that evaluates the closeness of datetime values within a list of expected values. - """ - - def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]: - if not isinstance(expected, Collection): - expected = [expected] - for expected_value in expected: - critic = DatetimeCritic( - critic_field=self.critic_field, - weight=self.weight, - tolerance=self.tolerance, - max_difference=self.max_difference, - ) - result = critic.evaluate(expected_value, actual) - if result["match"]: - return result - return {"match": False, "score": 0} diff --git a/toolkits/google/arcade_google/doc_to_html.py b/toolkits/google/arcade_google/doc_to_html.py deleted file mode 100644 index d54fcef0..00000000 --- a/toolkits/google/arcade_google/doc_to_html.py +++ /dev/null @@ -1,99 +0,0 @@ -def convert_document_to_html(document: dict) -> str: - html = ( - "" - f"{document['title']}" - f'' - "" - ) - for element in document["body"]["content"]: - html += convert_structural_element(element) - html += "" - return html - - -def convert_structural_element(element: dict, wrap_paragraphs: bool = True) -> str: - if "sectionBreak" in element or "tableOfContents" in element: - return "" - - elif "paragraph" in element: - paragraph_content = "" - - prepend, append = get_paragraph_style_tags( - style=element["paragraph"]["paragraphStyle"], - wrap_paragraphs=wrap_paragraphs, - ) - - for item in element["paragraph"]["elements"]: - if "textRun" not in item: - continue - paragraph_content += extract_paragraph_content(item["textRun"]) - - if not paragraph_content: - return "" - - return f"{prepend}{paragraph_content.strip()}{append}" - - elif "table" in element: - table = [ - [ - "".join([ - convert_structural_element(element=cell_element, wrap_paragraphs=False) - for cell_element in cell["content"] - ]) - for cell in row["tableCells"] - ] - for row in element["table"]["tableRows"] - ] - return table_list_to_html(table) - - else: - raise ValueError(f"Unknown document body element type: {element}") - - -def extract_paragraph_content(text_run: dict) -> str: - content = text_run["content"] - style = text_run["textStyle"] - return apply_text_style(content, style) - - -def apply_text_style(content: str, style: dict) -> str: - content = content.rstrip("\n") - content = content.replace("\n", "
") - italic = style.get("italic", False) - bold = style.get("bold", False) - if italic: - content = f"{content}" - if bold: - content = f"{content}" - return content - - -def get_paragraph_style_tags(style: dict, wrap_paragraphs: bool = True) -> tuple[str, str]: - named_style = style["namedStyleType"] - if named_style == "NORMAL_TEXT": - return ("

", "

") if wrap_paragraphs else ("", "") - elif named_style == "TITLE": - return "

", "

" - elif named_style == "SUBTITLE": - return "

", "

" - elif named_style.startswith("HEADING_"): - try: - heading_level = int(named_style.split("_")[1]) - except ValueError: - return ("

", "

") if wrap_paragraphs else ("", "") - else: - return f"", f"" - return ("

", "

") if wrap_paragraphs else ("", "") - - -def table_list_to_html(table: list[list[str]]) -> str: - html = "" - for row in table: - html += "" - for cell in row: - if cell.endswith("
"): - cell = cell[:-4] - html += f"" - html += "" - html += "
{cell}
" - return html diff --git a/toolkits/google/arcade_google/doc_to_markdown.py b/toolkits/google/arcade_google/doc_to_markdown.py deleted file mode 100644 index 9595de56..00000000 --- a/toolkits/google/arcade_google/doc_to_markdown.py +++ /dev/null @@ -1,64 +0,0 @@ -import arcade_google.doc_to_html as doc_to_html - - -def convert_document_to_markdown(document: dict) -> str: - md = f"---\ntitle: {document['title']}\ndocumentId: {document['documentId']}\n---\n" - for element in document["body"]["content"]: - md += convert_structural_element(element) - return md - - -def convert_structural_element(element: dict) -> str: - if "sectionBreak" in element or "tableOfContents" in element: - return "" - - elif "paragraph" in element: - md = "" - prepend = get_paragraph_style_prepend_str(element["paragraph"]["paragraphStyle"]) - for item in element["paragraph"]["elements"]: - if "textRun" not in item: - continue - content = extract_paragraph_content(item["textRun"]) - md += f"{prepend}{content}" - return md - - elif "table" in element: - return doc_to_html.convert_structural_element(element) - - else: - raise ValueError(f"Unknown document body element type: {element}") - - -def extract_paragraph_content(text_run: dict) -> str: - content = text_run["content"] - style = text_run["textStyle"] - return apply_text_style(content, style) - - -def apply_text_style(content: str, style: dict) -> str: - append = "\n" if content.endswith("\n") else "" - content = content.rstrip("\n") - italic = style.get("italic", False) - bold = style.get("bold", False) - if italic: - content = f"_{content}_" - if bold: - content = f"**{content}**" - return f"{content}{append}" - - -def get_paragraph_style_prepend_str(style: dict) -> str: - named_style = style["namedStyleType"] - if named_style == "NORMAL_TEXT": - return "" - elif named_style == "TITLE": - return "# " - elif named_style == "SUBTITLE": - return "## " - elif named_style.startswith("HEADING_"): - try: - heading_level = int(named_style.split("_")[1]) - return f"{'#' * heading_level} " - except ValueError: - return "" - return "" diff --git a/toolkits/google/arcade_google/enums.py b/toolkits/google/arcade_google/enums.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google/arcade_google/exceptions.py b/toolkits/google/arcade_google/exceptions.py deleted file mode 100644 index 0598dbee..00000000 --- a/toolkits/google/arcade_google/exceptions.py +++ /dev/null @@ -1,70 +0,0 @@ -from zoneinfo import available_timezones - -from arcade_tdk.errors import RetryableToolError - - -class GoogleToolError(Exception): - """Base exception for Google tool errors.""" - - def __init__(self, message: str, developer_message: str | None = None): - self.message = message - self.developer_message = developer_message - super().__init__(self.message) - - def __str__(self) -> str: - base_message = self.message - if self.developer_message: - return f"{base_message} (Developer: {self.developer_message})" - return base_message - - -class RetryableGoogleToolError(RetryableToolError): - """Raised when there's an error in a Google tool that can be retried.""" - - pass - - -class GoogleServiceError(GoogleToolError): - """Raised when there's an error building or using the Google service.""" - - pass - - -class GmailToolError(GoogleToolError): - """Raised when there's an error in the Gmail tools.""" - - pass - - -class GoogleCalendarToolError(GoogleToolError): - """Raised when there's an error in the Google Calendar tools.""" - - pass - - -class InvalidTimezoneError(RetryableGoogleToolError): - """Raised when a timezone is provided that is not supported by Python's zoneinfo.""" - - def __init__(self, timezone_str: str): - self.timezone_str = timezone_str - available_timezones_msg = ( - "Here is a list of valid timezones (from Python's zoneinfo.available_timezones()): " - f"{available_timezones()}" - ) - super().__init__( - f"Invalid timezone: '{timezone_str}'", - developer_message=available_timezones_msg, - additional_prompt_content=available_timezones_msg, - ) - - -class GoogleDriveToolError(GoogleToolError): - """Raised when there's an error in the Google Drive tools.""" - - pass - - -class GoogleDocsToolError(GoogleToolError): - """Raised when there's an error in the Google Docs tools.""" - - pass diff --git a/toolkits/google/arcade_google/models.py b/toolkits/google/arcade_google/models.py deleted file mode 100644 index 76131cf6..00000000 --- a/toolkits/google/arcade_google/models.py +++ /dev/null @@ -1,654 +0,0 @@ -import json -from datetime import date, datetime, time, timedelta -from enum import Enum -from typing import Optional -from zoneinfo import ZoneInfo - -from pydantic import BaseModel, field_validator, model_validator - - -# ---------------------------------------------------------------------------- # -# Google Calendar Models and Enums -# ---------------------------------------------------------------------------- # -class DateRange(Enum): - TODAY = "today" - TOMORROW = "tomorrow" - THIS_WEEK = "this_week" - NEXT_WEEK = "next_week" - THIS_MONTH = "this_month" - NEXT_MONTH = "next_month" - - def to_datetime_range( - self, - start_time: time | None = None, - end_time: time | None = None, - time_zone: ZoneInfo | None = None, - today: date | None = None, - ) -> tuple[datetime, datetime]: - """ - Convert a DateRange enum value to a tuple with two datetime objects representing the start - and end of the date range. - - :param start_time: The start time of the date range. Defaults to the current time. - :param end_time: The end time of the date range. Defaults to 23:59:59. - :param time_zone: The time zone to use for the date range. Defaults to UTC. - :param today: Today's date. Defaults to the current date provided by `datetime.now().date()` - """ - start_time = start_time or datetime.now().time() - end_time = end_time or time(23, 59, 59) - today = today or datetime.now().date() - - if self == DateRange.TODAY: - start_date, end_date = today, today - elif self == DateRange.TOMORROW: - start_date, end_date = today + timedelta(days=1), today + timedelta(days=1) - elif self == DateRange.THIS_WEEK: - start_date = today - timedelta(days=today.weekday()) - end_date = start_date + timedelta(days=6) - elif self == DateRange.NEXT_WEEK: - start_date = today + timedelta(days=7 - today.weekday()) - end_date = start_date + timedelta(days=6) - elif self == DateRange.THIS_MONTH: - start_date = today.replace(day=1) - next_month = start_date + timedelta(days=31) - end_date = next_month.replace(day=1) - timedelta(days=1) - elif self == DateRange.NEXT_MONTH: - start_date = (today.replace(day=1) + timedelta(days=31)).replace(day=1) - next_month = start_date + timedelta(days=31) - end_date = next_month.replace(day=1) - timedelta(days=1) - else: - raise ValueError( - f"DateRange enum value: {self} is not supported for date range conversion" - ) - - start_time = start_time or time(0, 0, 0) - end_time = end_time or time(23, 59, 59) - - start_datetime = datetime.combine(start_date, start_time) - end_datetime = datetime.combine(end_date, end_time) - - if time_zone: - start_datetime = start_datetime.replace(tzinfo=time_zone) - end_datetime = end_datetime.replace(tzinfo=time_zone) - - return start_datetime, end_datetime - - -class Day(Enum): - # TODO: THere are obvious limitations here. We should do better and support any date. - YESTERDAY = "yesterday" - TODAY = "today" - TOMORROW = "tomorrow" - THIS_SUNDAY = "this_sunday" - THIS_MONDAY = "this_monday" - THIS_TUESDAY = "this_tuesday" - THIS_WEDNESDAY = "this_wednesday" - THIS_THURSDAY = "this_thursday" - THIS_FRIDAY = "this_friday" - THIS_SATURDAY = "this_saturday" - NEXT_SUNDAY = "next_sunday" - NEXT_MONDAY = "next_monday" - NEXT_TUESDAY = "next_tuesday" - NEXT_WEDNESDAY = "next_wednesday" - NEXT_THURSDAY = "next_thursday" - NEXT_FRIDAY = "next_friday" - NEXT_SATURDAY = "next_saturday" - - def to_date(self, time_zone_name: str) -> date: - time_zone = ZoneInfo(time_zone_name) - today = datetime.now(time_zone).date() - weekday = today.weekday() - - if self == Day.YESTERDAY: - return today - timedelta(days=1) - elif self == Day.TODAY: - return today - elif self == Day.TOMORROW: - return today + timedelta(days=1) - - day_offsets = { - Day.THIS_SUNDAY: 6, - Day.THIS_MONDAY: 0, - Day.THIS_TUESDAY: 1, - Day.THIS_WEDNESDAY: 2, - Day.THIS_THURSDAY: 3, - Day.THIS_FRIDAY: 4, - Day.THIS_SATURDAY: 5, - } - - if self in day_offsets: - return today + timedelta(days=(day_offsets[self] - weekday) % 7) - - next_week_offsets = { - Day.NEXT_SUNDAY: 6, - Day.NEXT_MONDAY: 0, - Day.NEXT_TUESDAY: 1, - Day.NEXT_WEDNESDAY: 2, - Day.NEXT_THURSDAY: 3, - Day.NEXT_FRIDAY: 4, - Day.NEXT_SATURDAY: 5, - } - - if self in next_week_offsets: - return today + timedelta(days=(next_week_offsets[self] - weekday + 7) % 7) - - raise ValueError(f"Invalid Day enum value: {self}") - - -class TimeSlot(Enum): - _0000 = "00:00" - _0015 = "00:15" - _0030 = "00:30" - _0045 = "00:45" - _0100 = "01:00" - _0115 = "01:15" - _0130 = "01:30" - _0145 = "01:45" - _0200 = "02:00" - _0215 = "02:15" - _0230 = "02:30" - _0245 = "02:45" - _0300 = "03:00" - _0315 = "03:15" - _0330 = "03:30" - _0345 = "03:45" - _0400 = "04:00" - _0415 = "04:15" - _0430 = "04:30" - _0445 = "04:45" - _0500 = "05:00" - _0515 = "05:15" - _0530 = "05:30" - _0545 = "05:45" - _0600 = "06:00" - _0615 = "06:15" - _0630 = "06:30" - _0645 = "06:45" - _0700 = "07:00" - _0715 = "07:15" - _0730 = "07:30" - _0745 = "07:45" - _0800 = "08:00" - _0815 = "08:15" - _0830 = "08:30" - _0845 = "08:45" - _0900 = "09:00" - _0915 = "09:15" - _0930 = "09:30" - _0945 = "09:45" - _1000 = "10:00" - _1015 = "10:15" - _1030 = "10:30" - _1045 = "10:45" - _1100 = "11:00" - _1115 = "11:15" - _1130 = "11:30" - _1145 = "11:45" - _1200 = "12:00" - _1215 = "12:15" - _1230 = "12:30" - _1245 = "12:45" - _1300 = "13:00" - _1315 = "13:15" - _1330 = "13:30" - _1345 = "13:45" - _1400 = "14:00" - _1415 = "14:15" - _1430 = "14:30" - _1445 = "14:45" - _1500 = "15:00" - _1515 = "15:15" - _1530 = "15:30" - _1545 = "15:45" - _1600 = "16:00" - _1615 = "16:15" - _1630 = "16:30" - _1645 = "16:45" - _1700 = "17:00" - _1715 = "17:15" - _1730 = "17:30" - _1745 = "17:45" - _1800 = "18:00" - _1815 = "18:15" - _1830 = "18:30" - _1845 = "18:45" - _1900 = "19:00" - _1915 = "19:15" - _1930 = "19:30" - _1945 = "19:45" - _2000 = "20:00" - _2015 = "20:15" - _2030 = "20:30" - _2045 = "20:45" - _2100 = "21:00" - _2115 = "21:15" - _2130 = "21:30" - _2145 = "21:45" - _2200 = "22:00" - _2215 = "22:15" - _2230 = "22:30" - _2245 = "22:45" - _2300 = "23:00" - _2315 = "23:15" - _2330 = "23:30" - _2345 = "23:45" - - def to_time(self) -> time: - return datetime.strptime(self.value, "%H:%M").time() - - -class EventVisibility(Enum): - DEFAULT = "default" - PUBLIC = "public" - PRIVATE = "private" - CONFIDENTIAL = "confidential" - - -class EventType(Enum): - BIRTHDAY = "birthday" # Special all-day events with an annual recurrence. - DEFAULT = "default" # Regular events - FOCUS_TIME = "focusTime" # Focus time events - FROM_GMAIL = "fromGmail" # Events from Gmail - OUT_OF_OFFICE = "outOfOffice" # Out of office events - WORKING_LOCATION = "workingLocation" # Working location events - - -class SendUpdatesOptions(Enum): - NONE = "none" # No notifications are sent - ALL = "all" # Notifications are sent to all guests - EXTERNAL_ONLY = "externalOnly" # Notifications are sent to non-Google Calendar guests only. - - -# ---------------------------------------------------------------------------- # -# Google Drive Models and Enums -# ---------------------------------------------------------------------------- # -class Corpora(str, Enum): - """ - Bodies of items (files/documents) to which the query applies. - Prefer 'user' or 'drive' to 'allDrives' for efficiency. - By default, corpora is set to 'user'. - """ - - USER = "user" - DOMAIN = "domain" - DRIVE = "drive" - ALL_DRIVES = "allDrives" - - -class OrderBy(str, Enum): - """ - Sort keys for ordering files in Google Drive. - Each key has both ascending and descending options. - """ - - CREATED_TIME = ( - # When the file was created (ascending) - "createdTime" - ) - CREATED_TIME_DESC = ( - # When the file was created (descending) - "createdTime desc" - ) - FOLDER = ( - # The folder ID, sorted using alphabetical ordering (ascending) - "folder" - ) - FOLDER_DESC = ( - # The folder ID, sorted using alphabetical ordering (descending) - "folder desc" - ) - MODIFIED_BY_ME_TIME = ( - # The last time the file was modified by the user (ascending) - "modifiedByMeTime" - ) - MODIFIED_BY_ME_TIME_DESC = ( - # The last time the file was modified by the user (descending) - "modifiedByMeTime desc" - ) - MODIFIED_TIME = ( - # The last time the file was modified by anyone (ascending) - "modifiedTime" - ) - MODIFIED_TIME_DESC = ( - # The last time the file was modified by anyone (descending) - "modifiedTime desc" - ) - NAME = ( - # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending) - "name" - ) - NAME_DESC = ( - # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending) - "name desc" - ) - NAME_NATURAL = ( - # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending) - "name_natural" - ) - NAME_NATURAL_DESC = ( - # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending) - "name_natural desc" - ) - QUOTA_BYTES_USED = ( - # The number of storage quota bytes used by the file (ascending) - "quotaBytesUsed" - ) - QUOTA_BYTES_USED_DESC = ( - # The number of storage quota bytes used by the file (descending) - "quotaBytesUsed desc" - ) - RECENCY = ( - # The most recent timestamp from the file's date-time fields (ascending) - "recency" - ) - RECENCY_DESC = ( - # The most recent timestamp from the file's date-time fields (descending) - "recency desc" - ) - SHARED_WITH_ME_TIME = ( - # When the file was shared with the user, if applicable (ascending) - "sharedWithMeTime" - ) - SHARED_WITH_ME_TIME_DESC = ( - # When the file was shared with the user, if applicable (descending) - "sharedWithMeTime desc" - ) - STARRED = ( - # Whether the user has starred the file (ascending) - "starred" - ) - STARRED_DESC = ( - # Whether the user has starred the file (descending) - "starred desc" - ) - VIEWED_BY_ME_TIME = ( - # The last time the file was viewed by the user (ascending) - "viewedByMeTime" - ) - VIEWED_BY_ME_TIME_DESC = ( - # The last time the file was viewed by the user (descending) - "viewedByMeTime desc" - ) - - -class DocumentFormat(str, Enum): - MARKDOWN = "markdown" - HTML = "html" - GOOGLE_API_JSON = "google_api_json" - - -# ---------------------------------------------------------------------------- # -# Google Gmail Models and Enums -# ---------------------------------------------------------------------------- # -class GmailReplyToWhom(str, Enum): - EVERY_RECIPIENT = "every_recipient" - ONLY_THE_SENDER = "only_the_sender" - - -class GmailAction(str, Enum): - SEND = "send" - DRAFT = "draft" - - -# ---------------------------------------------------------------------------- # -# Google Sheets Models and Enums -# ---------------------------------------------------------------------------- # -class CellErrorType(str, Enum): - """The type of error in a cell - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType - """ - - ERROR_TYPE_UNSPECIFIED = "ERROR_TYPE_UNSPECIFIED" # The default error type, do not use this. - ERROR = "ERROR" # Corresponds to the #ERROR! error. - NULL_VALUE = "NULL_VALUE" # Corresponds to the #NULL! error. - DIVIDE_BY_ZERO = "DIVIDE_BY_ZERO" # Corresponds to the #DIV/0 error. - VALUE = "VALUE" # Corresponds to the #VALUE! error. - REF = "REF" # Corresponds to the #REF! error. - NAME = "NAME" # Corresponds to the #NAME? error. - NUM = "NUM" # Corresponds to the #NUM! error. - N_A = "N_A" # Corresponds to the #N/A error. - LOADING = "LOADING" # Corresponds to the Loading... state. - - -class CellErrorValue(BaseModel): - """An error in a cell - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorValue - """ - - type: CellErrorType - message: str - - -class CellExtendedValue(BaseModel): - """The kinds of value that a cell in a spreadsheet can have - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ExtendedValue - """ - - numberValue: float | None = None - stringValue: str | None = None - boolValue: bool | None = None - formulaValue: str | None = None - errorValue: Optional["CellErrorValue"] = None - - @model_validator(mode="after") - def check_exactly_one_value(cls, instance): # type: ignore[no-untyped-def] - provided = [v for v in instance.__dict__.values() if v is not None] - if len(provided) != 1: - raise ValueError( - "Exactly one of numberValue, stringValue, boolValue, " - "formulaValue, or errorValue must be set." - ) - return instance - - -class NumberFormatType(str, Enum): - NUMBER = "NUMBER" - PERCENT = "PERCENT" - CURRENCY = "CURRENCY" - - -class NumberFormat(BaseModel): - """The format of a number - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#NumberFormat - """ - - pattern: str - type: NumberFormatType - - -class CellFormat(BaseModel): - """The format of a cell - - Partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat - """ - - numberFormat: NumberFormat - - -class CellData(BaseModel): - """Data about a specific cell - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellData - """ - - userEnteredValue: CellExtendedValue - userEnteredFormat: CellFormat | None = None - - -class RowData(BaseModel): - """Data about each cellin a row - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData - """ - - values: list[CellData] - - -class GridData(BaseModel): - """Data in the grid - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData - """ - - startRow: int - startColumn: int - rowData: list[RowData] - - -class GridProperties(BaseModel): - """Properties of a grid - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties - """ - - rowCount: int - columnCount: int - - -class SheetProperties(BaseModel): - """Properties of a Sheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetProperties - """ - - sheetId: int - title: str - gridProperties: GridProperties | None = None - - -class Sheet(BaseModel): - """A Sheet in a spreadsheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet - """ - - properties: SheetProperties - data: list[GridData] | None = None - - -class SpreadsheetProperties(BaseModel): - """Properties of a spreadsheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties - """ - - title: str - - -class Spreadsheet(BaseModel): - """A spreadsheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets - """ - - properties: SpreadsheetProperties - sheets: list[Sheet] - - -CellValue = int | float | str | bool - - -class SheetDataInput(BaseModel): - """ - SheetDataInput models the cell data of a spreadsheet in a custom format. - - It is a dictionary mapping row numbers (as ints) to dictionaries that map - column letters (as uppercase strings) to cell values (int, float, str, or bool). - - This model enforces that: - - The outer keys are convertible to int. - - The inner keys are alphabetic strings (normalized to uppercase). - - All cell values are only of type int, float, str, or bool. - - The model automatically serializes (via `json_data()`) - and validates the inner types. - """ - - data: dict[int, dict[str, CellValue]] - - @classmethod - def _parse_json_if_string(cls, value): # type: ignore[no-untyped-def] - """Parses the value if it is a JSON string, otherwise returns it. - - Helper method for when validating the `data` field. - """ - if isinstance(value, str): - try: - return json.loads(value) - except json.JSONDecodeError as e: - raise TypeError(f"Invalid JSON: {e}") - return value - - @classmethod - def _validate_row_key(cls, row_key) -> int: # type: ignore[no-untyped-def] - """Converts the row key to an integer, raising an error if conversion fails. - - Helper method for when validating the `data` field. - """ - try: - return int(row_key) - except (ValueError, TypeError): - raise TypeError(f"Row key '{row_key}' is not convertible to int.") - - @classmethod - def _validate_inner_cells(cls, cells, row_int: int) -> dict: # type: ignore[no-untyped-def] - """Validates that 'cells' is a dict mapping column letters to valid cell values - and normalizes the keys. - - Helper method for when validating the `data` field. - """ - if not isinstance(cells, dict): - raise TypeError( - f"Value for row '{row_int}' must be a dict mapping column letters to cell values." - ) - new_inner = {} - for col_key, cell_value in cells.items(): - if not isinstance(col_key, str): - raise TypeError(f"Column key '{col_key}' must be a string.") - col_string = col_key.upper() - if not col_string.isalpha(): - raise TypeError(f"Column key '{col_key}' is invalid. Must be alphabetic.") - if not isinstance(cell_value, int | float | str | bool): - raise TypeError( - f"Cell value for {col_string}{row_int} must be an int, float, str, or bool." - ) - new_inner[col_string] = cell_value - return new_inner - - @field_validator("data", mode="before") - @classmethod - def validate_and_convert_keys(cls, value): # type: ignore[no-untyped-def] - """ - Validates data when SheetDataInput is instantiated and converts it to the correct format. - Uses private helper methods to parse JSON, validate row keys, and validate inner cell data. - """ - if value is None: - return {} - - value = cls._parse_json_if_string(value) - if isinstance(value, dict): - new_value = {} - for row_key, cells in value.items(): - row_int = cls._validate_row_key(row_key) - inner_cells = cls._validate_inner_cells(cells, row_int) - new_value[row_int] = inner_cells - return new_value - - raise TypeError("data must be a dict or a valid JSON string representing a dict") - - def json_data(self) -> str: - """ - Serialize the sheet data to a JSON string. - """ - return json.dumps(self.data) - - @classmethod - def from_json(cls, json_str: str) -> "SheetDataInput": - """ - Create a SheetData instance from a JSON string. - """ - return cls.model_validate_json(json_str) diff --git a/toolkits/google/arcade_google/tools/__init__.py b/toolkits/google/arcade_google/tools/__init__.py deleted file mode 100644 index f30b1d6e..00000000 --- a/toolkits/google/arcade_google/tools/__init__.py +++ /dev/null @@ -1,96 +0,0 @@ -from arcade_google.tools.calendar import ( - create_event, - delete_event, - find_time_slots_when_everyone_is_free, - list_calendars, - list_events, - update_event, -) -from arcade_google.tools.contacts import ( - create_contact, - search_contacts_by_email, - search_contacts_by_name, -) -from arcade_google.tools.docs import ( - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, -) -from arcade_google.tools.drive import ( - get_file_tree_structure, - search_and_retrieve_documents, - search_documents, -) -from arcade_google.tools.file_picker import generate_google_file_picker_url -from arcade_google.tools.gmail import ( - change_email_labels, - create_label, - delete_draft_email, - get_thread, - list_draft_emails, - list_emails, - list_emails_by_header, - list_labels, - list_threads, - reply_to_email, - search_threads, - send_draft_email, - send_email, - trash_email, - update_draft_email, - write_draft_email, - write_draft_reply_email, -) -from arcade_google.tools.sheets import ( - create_spreadsheet, - get_spreadsheet, - write_to_cell, -) - -__all__ = [ - # Google Calendar - create_event, - delete_event, - find_time_slots_when_everyone_is_free, - list_calendars, - list_events, - update_event, - # Google Contacts - create_contact, - search_contacts_by_email, - search_contacts_by_name, - # Google Docs - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, - # Google Drive - "get_file_tree_structure", - "search_and_retrieve_documents", - "search_documents", - # Google File Picker - generate_google_file_picker_url, - # Google Gmail - change_email_labels, - create_label, - delete_draft_email, - get_thread, - list_draft_emails, - list_emails, - list_emails_by_header, - list_labels, - list_threads, - reply_to_email, - search_threads, - send_draft_email, - send_email, - trash_email, - update_draft_email, - write_draft_email, - write_draft_reply_email, - # Google Sheets - create_spreadsheet, - get_spreadsheet, - write_to_cell, -] diff --git a/toolkits/google/arcade_google/tools/calendar.py b/toolkits/google/arcade_google/tools/calendar.py deleted file mode 100644 index a534acad..00000000 --- a/toolkits/google/arcade_google/tools/calendar.py +++ /dev/null @@ -1,510 +0,0 @@ -import json -from datetime import datetime, timedelta -from typing import Annotated, Any -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import RetryableToolError -from googleapiclient.errors import HttpError - -from arcade_google.models import EventVisibility, SendUpdatesOptions -from arcade_google.utils import ( - build_calendar_service, - build_oauth_service, - compute_free_time_intersection, - parse_datetime, -) - - -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - ] - ) -) -async def list_calendars( - context: ToolContext, - max_results: Annotated[ - int, "The maximum number of calendars to return. Up to 250 calendars, defaults to 10." - ] = 10, - show_deleted: Annotated[bool, "Whether to show deleted calendars. Defaults to False"] = False, - show_hidden: Annotated[bool, "Whether to show hidden calendars. Defaults to False"] = False, - next_page_token: Annotated[ - str | None, "The token to retrieve the next page of calendars. Optional." - ] = None, -) -> Annotated[dict, "A dictionary containing the calendars accessible by the end user"]: - """ - List all calendars accessible by the user. - """ - max_results = max(1, min(max_results, 250)) - service = build_calendar_service(context.get_auth_token_or_empty()) - calendars = ( - service.calendarList() - .list( - pageToken=next_page_token, - showDeleted=show_deleted, - showHidden=show_hidden, - maxResults=max_results, - ) - .execute() - ) - - items = calendars.get("items", []) - keys = ["description", "id", "summary", "timeZone"] - relevant_items = [{k: i.get(k) for k in keys if i.get(k)} for i in items] - return { - "next_page_token": calendars.get("nextPageToken"), - "num_calendars": len(relevant_items), - "calendars": relevant_items, - } - - -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - ], - ) -) -async def create_event( - context: ToolContext, - summary: Annotated[str, "The title of the event"], - start_datetime: Annotated[ - str, - "The datetime when the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.", - ], - end_datetime: Annotated[ - str, - "The datetime when the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.", - ], - calendar_id: Annotated[ - str, "The ID of the calendar to create the event in, usually 'primary'." - ] = "primary", - description: Annotated[str | None, "The description of the event"] = None, - location: Annotated[str | None, "The location of the event"] = None, - visibility: Annotated[EventVisibility, "The visibility of the event"] = EventVisibility.DEFAULT, - attendee_emails: Annotated[ - list[str] | None, - "The list of attendee emails. Must be valid email addresses e.g., username@domain.com.", - ] = None, -) -> Annotated[dict, "A dictionary containing the created event details"]: - """Create a new event/meeting/sync/meetup in the specified calendar.""" - - service = build_calendar_service(context.get_auth_token_or_empty()) - - # Get the calendar's time zone - calendar = service.calendars().get(calendarId=calendar_id).execute() - time_zone = calendar["timeZone"] - - # Parse datetime strings - start_dt = parse_datetime(start_datetime, time_zone) - end_dt = parse_datetime(end_datetime, time_zone) - - event: dict[str, Any] = { - "summary": summary, - "description": description, - "location": location, - "start": {"dateTime": start_dt.isoformat(), "timeZone": time_zone}, - "end": {"dateTime": end_dt.isoformat(), "timeZone": time_zone}, - "visibility": visibility.value, - } - - if attendee_emails: - event["attendees"] = [{"email": email} for email in attendee_emails] - - created_event = service.events().insert(calendarId=calendar_id, body=event).execute() - return {"event": created_event} - - -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - ], - ) -) -async def list_events( - context: ToolContext, - min_end_datetime: Annotated[ - str, - "Filter by events that end on or after this datetime in ISO 8601 format, " - "e.g., '2024-09-15T09:00:00'.", - ], - max_start_datetime: Annotated[ - str, - "Filter by events that start before this datetime in ISO 8601 format, " - "e.g., '2024-09-16T17:00:00'.", - ], - calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary", - max_results: Annotated[int, "The maximum number of events to return"] = 10, -) -> Annotated[dict, "A dictionary containing the list of events"]: - """ - List events from the specified calendar within the given datetime range. - - min_end_datetime serves as the lower bound (exclusive) for an event's end time. - max_start_datetime serves as the upper bound (exclusive) for an event's start time. - - For example: - If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime - is set to 2024-09-16T17:00:00, the function will return events that: - 1. End after 09:00 on September 15, 2024 (exclusive) - 2. Start before 17:00 on September 16, 2024 (exclusive) - This means an event starting at 08:00 on September 15 and - ending at 10:00 on September 15 would be included, but an - event starting at 17:00 on September 16 would not be included. - """ - service = build_calendar_service(context.get_auth_token_or_empty()) - - # Get the calendar's time zone - calendar = service.calendars().get(calendarId=calendar_id).execute() - time_zone = calendar["timeZone"] - - # Parse datetime strings - min_end_dt = parse_datetime(min_end_datetime, time_zone) - max_start_dt = parse_datetime(max_start_datetime, time_zone) - - if min_end_dt > max_start_dt: - min_end_dt, max_start_dt = max_start_dt, min_end_dt - - events_result = ( - service.events() - .list( - calendarId=calendar_id, - timeMin=min_end_dt.isoformat(), - timeMax=max_start_dt.isoformat(), - maxResults=max_results, - singleEvents=True, - orderBy="startTime", - ) - .execute() - ) - - items_keys = [ - "attachments", - "attendees", - "creator", - "description", - "end", - "eventType", - "htmlLink", - "id", - "location", - "organizer", - "start", - "summary", - "visibility", - ] - - events = [ - {key: event[key] for key in items_keys if key in event} - for event in events_result.get("items", []) - ] - - return {"events_count": len(events), "events": events} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/calendar.events"], - ) -) -async def update_event( - context: ToolContext, - event_id: Annotated[str, "The ID of the event to update"], - updated_start_datetime: Annotated[ - str | None, - "The updated datetime that the event starts in ISO 8601 format, " - "e.g., '2024-12-31T15:30:00'.", - ] = None, - updated_end_datetime: Annotated[ - str | None, - "The updated datetime that the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.", - ] = None, - updated_calendar_id: Annotated[ - str | None, "The updated ID of the calendar containing the event." - ] = None, - updated_summary: Annotated[str | None, "The updated title of the event"] = None, - updated_description: Annotated[str | None, "The updated description of the event"] = None, - updated_location: Annotated[str | None, "The updated location of the event"] = None, - updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None, - attendee_emails_to_add: Annotated[ - list[str] | None, - "The list of attendee emails to add. Must be valid email addresses " - "e.g., username@domain.com.", - ] = None, - attendee_emails_to_remove: Annotated[ - list[str] | None, - "The list of attendee emails to remove. Must be valid email addresses " - "e.g., username@domain.com.", - ] = None, - send_updates: Annotated[ - SendUpdatesOptions, - "Should attendees be notified of the update? (none, all, external_only)", - ] = SendUpdatesOptions.ALL, -) -> Annotated[ - str, - "A string containing the updated event details, including the event ID, update timestamp, " - "and a link to view the updated event.", -]: - """ - Update an existing event in the specified calendar with the provided details. - Only the provided fields will be updated; others will remain unchanged. - - `updated_start_datetime` and `updated_end_datetime` are - independent and can be provided separately. - """ - service = build_calendar_service(context.get_auth_token_or_empty()) - - calendar = service.calendars().get(calendarId="primary").execute() - time_zone = calendar["timeZone"] - - try: - event = service.events().get(calendarId="primary", eventId=event_id).execute() - except HttpError: - valid_events_with_id = ( - service.events() - .list( - calendarId="primary", - timeMin=(datetime.now() - timedelta(days=2)).isoformat(), - timeMax=(datetime.now() + timedelta(days=365)).isoformat(), - maxResults=50, - singleEvents=True, - orderBy="startTime", - ) - .execute() - ) - raise RetryableToolError( - f"Event with ID {event_id} not found.", - additional_prompt_content=( - f"Here is a list of valid events. The event_id parameter must match one of these: " - f"{valid_events_with_id}" - ), - retry_after_ms=1000, - developer_message=( - f"Event with ID {event_id} not found. Please try again with a valid event ID." - ), - ) - - update_fields = { - "start": {"dateTime": updated_start_datetime, "timeZone": time_zone} - if updated_start_datetime - else None, - "end": {"dateTime": updated_end_datetime, "timeZone": time_zone} - if updated_end_datetime - else None, - "calendarId": updated_calendar_id, - "sendUpdates": send_updates.value if send_updates else None, - "summary": updated_summary, - "description": updated_description, - "location": updated_location, - "visibility": updated_visibility.value if updated_visibility else None, - } - - event.update({k: v for k, v in update_fields.items() if v is not None}) - - if attendee_emails_to_remove: - event["attendees"] = [ - attendee - for attendee in event.get("attendees", []) - if attendee.get("email", "").lower() - not in [email.lower() for email in attendee_emails_to_remove] - ] - - if attendee_emails_to_add: - existing_emails = { - attendee.get("email", "").lower() for attendee in event.get("attendees", []) - } - new_attendees = [ - {"email": email} - for email in attendee_emails_to_add - if email.lower() not in existing_emails - ] - event["attendees"] = event.get("attendees", []) + new_attendees - - updated_event = ( - service.events() - .update( - calendarId="primary", - eventId=event_id, - sendUpdates=send_updates.value, - body=event, - ) - .execute() - ) - return ( - f"Event with ID {event_id} successfully updated at {updated_event['updated']}. " - f"View updated event at {updated_event['htmlLink']}" - ) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/calendar.events"], - ) -) -async def delete_event( - context: ToolContext, - event_id: Annotated[str, "The ID of the event to delete"], - calendar_id: Annotated[str, "The ID of the calendar containing the event"] = "primary", - send_updates: Annotated[ - SendUpdatesOptions, "Specifies which attendees to notify about the deletion" - ] = SendUpdatesOptions.ALL, -) -> Annotated[str, "A string containing the deletion confirmation message"]: - """Delete an event from Google Calendar.""" - service = build_calendar_service(context.get_auth_token_or_empty()) - - service.events().delete( - calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value - ).execute() - - notification_message = "" - if send_updates == SendUpdatesOptions.ALL: - notification_message = "Notifications were sent to all attendees." - elif send_updates == SendUpdatesOptions.EXTERNAL_ONLY: - notification_message = "Notifications were sent to external attendees only." - elif send_updates == SendUpdatesOptions.NONE: - notification_message = "No notifications were sent to attendees." - - return ( - f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. " - f"{notification_message}" - ) - - -# TODO: would be nice to have a "min_slot_duration" parameter -# TODO: find a way to have "include_weekends" parameter without confusing LLMs -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/calendar.readonly"], - ), -) -async def find_time_slots_when_everyone_is_free( - context: ToolContext, - email_addresses: Annotated[ - list[str] | None, - "The list of email addresses from people in the same organization domain (apart from the " - "currently logged in user) to search for free time slots. Defaults to None, which will " - "return free time slots for the current user only.", - ] = None, - start_date: Annotated[ - str | None, - "The start date to search for time slots in the format 'YYYY-MM-DD'. Defaults to today's " - "date. It will search starting from this date at the time 00:00:00.", - ] = None, - end_date: Annotated[ - str | None, - "The end date to search for time slots in the format 'YYYY-MM-DD'. Defaults to seven days " - "from the start date. It will search until this date at the time 23:59:59.", - ] = None, - start_time_boundary: Annotated[ - str, - "Will return free slots in any given day starting from this time in the format 'HH:MM'. " - "Defaults to '08:00', which is a usual business hour start time.", - ] = "08:00", - end_time_boundary: Annotated[ - str, - "Will return free slots in any given day until this time in the format 'HH:MM'. " - "Defaults to '18:00', which is a usual business hour end time.", - ] = "18:00", -) -> Annotated[ - dict, - "A dictionary with the free slots and the timezone in which time slots are represented.", -]: - """ - Provides time slots when everyone is free within a given date range and time boundaries. - """ - - # Build google api services - oauth_service = build_oauth_service(context.get_auth_token_or_empty()) - calendar_service = build_calendar_service(context.get_auth_token_or_empty()) - - email_addresses = email_addresses or [] - - if isinstance(email_addresses, str): - email_addresses = [email_addresses] - - # Add the currently logged in user to the list of email addresses - user_info = oauth_service.userinfo().get().execute() - if user_info["email"] not in email_addresses: - email_addresses.append(user_info["email"]) - - # Get the timezone of the currently logged in user - calendar = calendar_service.calendars().get(calendarId="primary").execute() - timezone_name = calendar.get("timeZone") - - try: - tz = ZoneInfo(timezone_name) - # If the calendar timezone name is not supported by Python's zoneinfo, use UTC - except ZoneInfoNotFoundError: - timezone_name = "UTC" - tz = ZoneInfo("UTC") - - # Set default start and end dates, if not provided by the caller - start_date = start_date or datetime.now(tz=tz).date().isoformat() - end_date = end_date or (datetime.now(tz=tz).date() + timedelta(days=7)).isoformat() - - # Parse start and end dates to datetime objects - start_datetime = datetime.strptime(start_date, "%Y-%m-%d").replace( - hour=0, minute=0, second=0, microsecond=0, tzinfo=tz - ) - end_datetime = datetime.strptime(end_date, "%Y-%m-%d").replace( - hour=23, minute=59, second=59, microsecond=0, tzinfo=tz - ) - - # Get the busy slots from the calendars of the users - freebusy_response = ( - calendar_service.freebusy() - .query( - body={ - "timeMin": start_datetime.isoformat(), - "timeMax": end_datetime.isoformat(), - "timeZone": timezone_name, - "items": [{"id": email_address} for email_address in email_addresses], - } - ) - .execute() - ) - busy_slots = freebusy_response["calendars"] - - response_errors = [] - - for email in email_addresses: - if "errors" not in busy_slots[email]: - continue - errors = busy_slots[email]["errors"] - for error in errors: - response_errors.append( - f"Error retrieving free slots from calendar of '{email}': " - f"{error.get('reason', 'not determined')}" - ) - - if response_errors: - raise RetryableToolError( - "Error retrieving free slots from calendars of one or more users.", - additional_prompt_content=json.dumps(response_errors), - retry_after_ms=1000, - developer_message="Error retrieving free slots from calendars of one or more users.", - ) - - # Compute the free slots - free_slots = compute_free_time_intersection( - busy_data=busy_slots, - global_start=start_datetime, - global_end=end_datetime, - start_time_boundary=datetime.strptime(start_time_boundary, "%H:%M") - .time() - .replace(tzinfo=tz), - end_time_boundary=datetime.strptime(end_time_boundary, "%H:%M").time().replace(tzinfo=tz), - include_weekends=True, - tz=tz, - ) - - return { - "free_slots": free_slots, - "timezone": timezone_name, - } diff --git a/toolkits/google/arcade_google/tools/contacts.py b/toolkits/google/arcade_google/tools/contacts.py deleted file mode 100644 index 64332847..00000000 --- a/toolkits/google/arcade_google/tools/contacts.py +++ /dev/null @@ -1,96 +0,0 @@ -import asyncio -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google - -from arcade_google.constants import DEFAULT_SEARCH_CONTACTS_LIMIT -from arcade_google.utils import build_people_service, search_contacts - - -async def _warmup_cache(service) -> None: # type: ignore[no-untyped-def] - """ - Warm-up the search cache for contacts by sending a request with an empty query. - This ensures that the lazy cache is updated for both primary contacts and other contacts. - This is unfortunately a real thing: https://developers.google.com/people/v1/contacts#search_the_users_contacts - """ - service.people().searchContacts(query="", pageSize=1, readMask="names,emailAddresses").execute() - await asyncio.sleep(3) # TODO experiment with this value - - -@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"])) -async def search_contacts_by_email( - context: ToolContext, - email: Annotated[str, "The email address to search for"], - limit: Annotated[ - int | None, - "The maximum number of contacts to return (30 is the max allowed by Google API)", - ] = DEFAULT_SEARCH_CONTACTS_LIMIT, -) -> Annotated[dict, "A dictionary containing the list of matching contacts"]: - """ - Search the user's contacts in Google Contacts by email address. - """ - service = build_people_service(context.get_auth_token_or_empty()) - # Warm-up the cache before performing search. - # TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently - await _warmup_cache(service) - - return {"contacts": search_contacts(service, email, limit)} - - -@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"])) -async def search_contacts_by_name( - context: ToolContext, - name: Annotated[str, "The full name to search for"], - limit: Annotated[ - int | None, - "The maximum number of contacts to return (30 is the max allowed by Google API)", - ] = DEFAULT_SEARCH_CONTACTS_LIMIT, -) -> Annotated[dict, "A dictionary containing the list of matching contacts"]: - """ - Search the user's contacts in Google Contacts by name. - """ - service = build_people_service(context.get_auth_token_or_empty()) - # Warm-up the cache before performing search. - # TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently - await _warmup_cache(service) - return {"contacts": search_contacts(service, name, limit)} - - -@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts"])) -async def create_contact( - context: ToolContext, - given_name: Annotated[str, "The given name of the contact"], - family_name: Annotated[str | None, "The optional family name of the contact"], - email: Annotated[str | None, "The optional email address of the contact"], -) -> Annotated[dict, "A dictionary containing the details of the created contact"]: - """ - Create a new contact record in Google Contacts. - - Examples: - ``` - create_contact(given_name="Alice") - create_contact(given_name="Alice", family_name="Smith") - create_contact(given_name="Alice", email="alice@example.com") - ``` - """ - # Build the People API service - service = build_people_service(context.get_auth_token_or_empty()) - - # Construct the person payload with the specified names - name_body = {"givenName": given_name} - if family_name: - name_body["familyName"] = family_name - contact_body = {"names": [name_body]} - if email: - contact_body["emailAddresses"] = [{"value": email, "type": "work"}] - - # Create the contact. The personFields parameter specifies what information - # should be returned. Here, we return names and emailAddresses. - created_contact = ( - service.people() - .createContact(body=contact_body, personFields="names,emailAddresses") - .execute() - ) - - return {"contact": created_contact} diff --git a/toolkits/google/arcade_google/tools/docs.py b/toolkits/google/arcade_google/tools/docs.py deleted file mode 100644 index b91da26a..00000000 --- a/toolkits/google/arcade_google/tools/docs.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google - -from arcade_google.utils import build_docs_service - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/get -# Example `arcade chat` query: `get document with ID 1234567890` -# Note: Document IDs are returned in the response of the Google Drive's `list_documents` tool -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ) -) -async def get_document_by_id( - context: ToolContext, - document_id: Annotated[str, "The ID of the document to retrieve."], -) -> Annotated[dict, "The document contents as a dictionary"]: - """ - Get the latest version of the specified Google Docs document. - """ - service = build_docs_service( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - # Execute the documents().get() method. Returns a Document object - # https://developers.google.com/docs/api/reference/rest/v1/documents#Document - request = service.documents().get(documentId=document_id) - response = request.execute() - return dict(response) - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate -# Example `arcade chat` query: `insert "The END" at the end of document with ID 1234567890` -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ) -) -async def insert_text_at_end_of_document( - context: ToolContext, - document_id: Annotated[str, "The ID of the document to update."], - text_content: Annotated[str, "The text content to insert into the document"], -) -> Annotated[dict, "The response from the batchUpdate API as a dict."]: - """ - Updates an existing Google Docs document using the batchUpdate API endpoint. - """ - document = await get_document_by_id(context, document_id) - - end_index = document["body"]["content"][-1]["endIndex"] - - service = build_docs_service( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - requests = [ - { - "insertText": { - "location": { - "index": int(end_index) - 1, - }, - "text": text_content, - } - } - ] - - # Execute the documents().batchUpdate() method - response = ( - service.documents() - .batchUpdate(documentId=document_id, body={"requests": requests}) - .execute() - ) - - return dict(response) - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create -# Example `arcade chat` query: `create blank document with title "My New Document"` -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ) -) -async def create_blank_document( - context: ToolContext, title: Annotated[str, "The title of the blank document to create"] -) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]: - """ - Create a blank Google Docs document with the specified title. - """ - service = build_docs_service( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - body = {"title": title} - - # Execute the documents().create() method. Returns a Document object https://developers.google.com/docs/api/reference/rest/v1/documents#Document - request = service.documents().create(body=body) - response = request.execute() - - return { - "title": response["title"], - "documentId": response["documentId"], - "documentUrl": f"https://docs.google.com/document/d/{response['documentId']}/edit", - } - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate -# Example `arcade chat` query: -# `create document with title "My New Document" and text content "Hello, World!"` -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ) -) -async def create_document_from_text( - context: ToolContext, - title: Annotated[str, "The title of the document to create"], - text_content: Annotated[str, "The text content to insert into the document"], -) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]: - """ - Create a Google Docs document with the specified title and text content. - """ - # First, create a blank document - document = await create_blank_document(context, title) - - service = build_docs_service( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - requests = [ - { - "insertText": { - "location": { - "index": 1, - }, - "text": text_content, - } - } - ] - - # Execute the batchUpdate method to insert text - service.documents().batchUpdate( - documentId=document["documentId"], body={"requests": requests} - ).execute() - - return { - "title": document["title"], - "documentId": document["documentId"], - "documentUrl": f"https://docs.google.com/document/d/{document['documentId']}/edit", - } diff --git a/toolkits/google/arcade_google/tools/drive.py b/toolkits/google/arcade_google/tools/drive.py deleted file mode 100644 index b8a7b4f8..00000000 --- a/toolkits/google/arcade_google/tools/drive.py +++ /dev/null @@ -1,287 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from googleapiclient.errors import HttpError - -from arcade_google.doc_to_html import convert_document_to_html -from arcade_google.doc_to_markdown import convert_document_to_markdown -from arcade_google.models import DocumentFormat, OrderBy -from arcade_google.tools import get_document_by_id -from arcade_google.utils import ( - build_drive_service, - build_file_tree, - build_file_tree_request_params, - build_files_list_params, -) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -async def get_file_tree_structure( - context: ToolContext, - include_shared_drives: Annotated[ - bool, "Whether to include shared drives in the file tree structure. Defaults to False." - ] = False, - restrict_to_shared_drive_id: Annotated[ - str | None, - "If provided, only include files from this shared drive in the file tree structure. " - "Defaults to None, which will include files and folders from all drives.", - ] = None, - include_organization_domain_documents: Annotated[ - bool, - "Whether to include documents from the organization's domain. This is applicable to admin " - "users who have permissions to view organization-wide documents in a Google Workspace " - "account. Defaults to False.", - ] = False, - order_by: Annotated[ - list[OrderBy] | None, - "Sort order. Defaults to listing the most recently modified documents first", - ] = None, - limit: Annotated[ - int | None, - "The number of files and folders to list. Defaults to None, " - "which will list all files and folders.", - ] = None, -) -> Annotated[ - dict, - "A dictionary containing the file/folder tree structure in the user's Google Drive", -]: - """ - Get the file/folder tree structure of the user's Google Drive. - """ - service = build_drive_service( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - keep_paginating = True - page_token = None - files = {} - file_tree: dict[str, list[dict]] = {"My Drive": []} - - params = build_file_tree_request_params( - order_by, - page_token, - limit, - include_shared_drives, - restrict_to_shared_drive_id, - include_organization_domain_documents, - ) - - while keep_paginating: - # Get a list of files - results = service.files().list(**params).execute() - - # Update page token - page_token = results.get("nextPageToken") - params["pageToken"] = page_token - keep_paginating = page_token is not None - - for file in results.get("files", []): - files[file["id"]] = file - - if not files: - return {"drives": []} - - file_tree = build_file_tree(files) - - drives = [] - - for drive_id, files in file_tree.items(): # type: ignore[assignment] - if drive_id == "My Drive": - drive = {"name": "My Drive", "children": files} - else: - try: - drive_details = service.drives().get(driveId=drive_id).execute() - drive_name = drive_details.get("name", "Shared Drive (name unavailable)") - except HttpError as e: - drive_name = ( - f"Shared Drive (name unavailable: 'HttpError {e.status_code}: {e.reason}')" - ) - - drive = {"name": drive_name, "id": drive_id, "children": files} - - drives.append(drive) - - return {"drives": drives} - - -# Implements: https://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.files.html#list -# Example `arcade chat` query: `list my 5 most recently modified documents` -# TODO: Support query with natural language. Currently, the tool expects a fully formed query -# string as input with the syntax defined here: https://developers.google.com/drive/api/guides/search-files -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -async def search_documents( - context: ToolContext, - document_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - document_not_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must NOT be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - search_only_in_shared_drive_id: Annotated[ - str | None, - "The ID of the shared drive to restrict the search to. If provided, the search will only " - "return documents from this drive. Defaults to None, which searches across all drives.", - ] = None, - include_shared_drives: Annotated[ - bool, - "Whether to include documents from shared drives. Defaults to False (searches only in " - "the user's 'My Drive').", - ] = False, - include_organization_domain_documents: Annotated[ - bool, - "Whether to include documents from the organization's domain. This is applicable to admin " - "users who have permissions to view organization-wide documents in a Google Workspace " - "account. Defaults to False.", - ] = False, - order_by: Annotated[ - list[OrderBy] | None, - "Sort order. Defaults to listing the most recently modified documents first", - ] = None, - limit: Annotated[int, "The number of documents to list"] = 50, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[ - dict, - "A dictionary containing 'documents_count' (number of documents returned) and 'documents' " - "(a list of document details including 'kind', 'mimeType', 'id', and 'name' for each document)", -]: - """ - Searches for documents in the user's Google Drive. Excludes documents that are in the trash. - """ - if order_by is None: - order_by = [OrderBy.MODIFIED_TIME_DESC] - elif isinstance(order_by, OrderBy): - order_by = [order_by] - - page_size = min(10, limit) - files: list[dict[str, Any]] = [] - - service = build_drive_service( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - - params = build_files_list_params( - mime_type="application/vnd.google-apps.document", - document_contains=document_contains, - document_not_contains=document_not_contains, - page_size=page_size, - order_by=order_by, - pagination_token=pagination_token, - include_shared_drives=include_shared_drives, - search_only_in_shared_drive_id=search_only_in_shared_drive_id, - include_organization_domain_documents=include_organization_domain_documents, - ) - - while len(files) < limit: - if pagination_token: - params["pageToken"] = pagination_token - else: - params.pop("pageToken", None) - - results = service.files().list(**params).execute() - batch = results.get("files", []) - files.extend(batch[: limit - len(files)]) - - pagination_token = results.get("nextPageToken") - if not pagination_token or len(batch) < page_size: - break - - return {"documents_count": len(files), "documents": files} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -async def search_and_retrieve_documents( - context: ToolContext, - return_format: Annotated[ - DocumentFormat, - "The format of the document to return. Defaults to Markdown.", - ] = DocumentFormat.MARKDOWN, - document_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - document_not_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must NOT be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - search_only_in_shared_drive_id: Annotated[ - str | None, - "The ID of the shared drive to restrict the search to. If provided, the search will only " - "return documents from this drive. Defaults to None, which searches across all drives.", - ] = None, - include_shared_drives: Annotated[ - bool, - "Whether to include documents from shared drives. Defaults to False (searches only in " - "the user's 'My Drive').", - ] = False, - include_organization_domain_documents: Annotated[ - bool, - "Whether to include documents from the organization's domain. This is applicable to admin " - "users who have permissions to view organization-wide documents in a Google Workspace " - "account. Defaults to False.", - ] = False, - order_by: Annotated[ - list[OrderBy] | None, - "Sort order. Defaults to listing the most recently modified documents first", - ] = None, - limit: Annotated[int, "The number of documents to list"] = 50, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[ - dict, - "A dictionary containing 'documents_count' (number of documents returned) and 'documents' " - "(a list of documents with their content).", -]: - """ - Searches for documents in the user's Google Drive and returns a list of documents (with text - content) matching the search criteria. Excludes documents that are in the trash. - - Note: use this tool only when the user prompt requires the documents' content. If the user only - needs a list of documents, use the `search_documents` tool instead. - """ - response = await search_documents( - context=context, - document_contains=document_contains, - document_not_contains=document_not_contains, - search_only_in_shared_drive_id=search_only_in_shared_drive_id, - include_shared_drives=include_shared_drives, - include_organization_domain_documents=include_organization_domain_documents, - order_by=order_by, - limit=limit, - pagination_token=pagination_token, - ) - - documents = [] - - for item in response["documents"]: - document = await get_document_by_id(context, document_id=item["id"]) - - if return_format == DocumentFormat.MARKDOWN: - document = convert_document_to_markdown(document) - elif return_format == DocumentFormat.HTML: - document = convert_document_to_html(document) - - documents.append(document) - - return {"documents_count": len(documents), "documents": documents} diff --git a/toolkits/google/arcade_google/tools/file_picker.py b/toolkits/google/arcade_google/tools/file_picker.py deleted file mode 100644 index 25a578b0..00000000 --- a/toolkits/google/arcade_google/tools/file_picker.py +++ /dev/null @@ -1,54 +0,0 @@ -import base64 -import json -from typing import Annotated - -from arcade_tdk import ToolContext, ToolMetadataKey, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import ToolExecutionError - - -@tool( - requires_auth=Google(), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -def generate_google_file_picker_url( - context: ToolContext, -) -> Annotated[dict, "Google File Picker URL for user file selection and permission granting"]: - """Generate a Google File Picker URL for user-driven file selection and authorization. - - This tool generates a URL that directs the end-user to a Google File Picker interface where - where they can select or upload Google Drive files. Users can grant permission to access their - Drive files, providing a secure and authorized way to interact with their files. - - This is particularly useful when prior tools (e.g., those accessing or modifying - Google Docs, Google Sheets, etc.) encountered failures due to file non-existence - (Requested entity was not found) or permission errors. Once the user completes the file - picker flow, the prior tool can be retried. - """ - client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID) - client_id_parts = client_id.split("-") - if not client_id_parts: - raise ToolExecutionError( - message="Invalid Google Client ID", - developer_message=f"Google Client ID '{client_id}' is not valid", - ) - app_id = client_id_parts[0] - cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/") - - config = { - "auth": { - "client_id": client_id, - "app_id": app_id, - }, - } - config_json = json.dumps(config) - config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8") - url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}" - - return { - "url": url, - "llm_instructions": ( - "Instruct the user to click the following link to open the Google Drive File Picker. " - f"This will allow them to select files and grant access permissions: {url}" - ), - } diff --git a/toolkits/google/arcade_google/tools/gmail.py b/toolkits/google/arcade_google/tools/gmail.py deleted file mode 100644 index 7b89ee54..00000000 --- a/toolkits/google/arcade_google/tools/gmail.py +++ /dev/null @@ -1,664 +0,0 @@ -import base64 -from email.mime.text import MIMEText -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import RetryableToolError -from googleapiclient.errors import HttpError - -from arcade_google.constants import GMAIL_DEFAULT_REPLY_TO -from arcade_google.exceptions import GmailToolError -from arcade_google.models import GmailAction, GmailReplyToWhom -from arcade_google.utils import ( - DateRange, - _build_gmail_service, - build_email_message, - build_gmail_query_string, - build_reply_recipients, - fetch_messages, - get_draft_url, - get_email_details, - get_email_in_trash_url, - get_label_ids, - get_sent_email_url, - parse_draft_email, - parse_multipart_email, - parse_plain_text_email, - remove_none_values, -) - - -# Email sending tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.send"], - ) -) -async def send_email( - context: ToolContext, - subject: Annotated[str, "The subject of the email"], - body: Annotated[str, "The body of the email"], - recipient: Annotated[str, "The recipient of the email"], - cc: Annotated[list[str] | None, "CC recipients of the email"] = None, - bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None, -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """ - Send an email using the Gmail API. - """ - service = _build_gmail_service(context) - email = build_email_message(recipient, subject, body, cc, bcc) - - sent_message = service.users().messages().send(userId="me", body=email).execute() - - email = parse_plain_text_email(sent_message) - email["url"] = get_sent_email_url(sent_message["id"]) - return email - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.send"], - ) -) -async def send_draft_email( - context: ToolContext, email_id: Annotated[str, "The ID of the draft to send"] -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """ - Send a draft email using the Gmail API. - """ - - service = _build_gmail_service(context) - - # Send the draft email - sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute() - - email = parse_plain_text_email(sent_message) - email["url"] = get_sent_email_url(sent_message["id"]) - return email - - -# Note: in the Gmail UI, a user can customize the recipient and cc fields before replying. -# We decided not to support this feature, since we'd need a way for LLMs to tell apart between -# adding or removing recipients/cc, or replacing with an entirely new list of addresses, -# which would make the tool more complex to call. -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.send"], - ) -) -async def reply_to_email( - context: ToolContext, - body: Annotated[str, "The body of the email"], - reply_to_message_id: Annotated[str, "The ID of the message to reply to"], - reply_to_whom: Annotated[ - GmailReplyToWhom, - "Whether to reply to every recipient (including cc) or only to the original sender. " - f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.", - ] = GMAIL_DEFAULT_REPLY_TO, - bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None, -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """ - Send a reply to an email message. - """ - if isinstance(reply_to_whom, str): - reply_to_whom = GmailReplyToWhom(reply_to_whom) - - service = _build_gmail_service(context) - - current_user = service.users().getProfile(userId="me").execute() - - try: - replying_to_email = ( - service.users().messages().get(userId="me", id=reply_to_message_id).execute() - ) - except HttpError as e: - raise RetryableToolError( - message=f"Could not retrieve the message with id {reply_to_message_id}.", - developer_message=( - f"Could not retrieve the message with id {reply_to_message_id}. " - f"Reason: '{e.reason}'. Error details: '{e.error_details}'" - ), - ) from e - - replying_to_email = parse_multipart_email(replying_to_email) - - recipients = build_reply_recipients( - replying_to_email, current_user["emailAddress"], reply_to_whom - ) - - email = build_email_message( - recipient=recipients, - subject=f"Re: {replying_to_email['subject']}", - body=body, - cc=None - if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER - else replying_to_email["cc"].split(","), - bcc=bcc, - replying_to=replying_to_email, - ) - - sent_message = service.users().messages().send(userId="me", body=email).execute() - - email = parse_plain_text_email(sent_message) - email["url"] = get_sent_email_url(sent_message["id"]) - return email - - -# Draft Management Tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def write_draft_email( - context: ToolContext, - subject: Annotated[str, "The subject of the draft email"], - body: Annotated[str, "The body of the draft email"], - recipient: Annotated[str, "The recipient of the draft email"], - cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None, - bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None, -) -> Annotated[dict, "A dictionary containing the created draft email details"]: - """ - Compose a new email draft using the Gmail API. - """ - # Set up the Gmail API client - service = _build_gmail_service(context) - - draft = { - "message": build_email_message(recipient, subject, body, cc, bcc, action=GmailAction.DRAFT) - } - - draft_message = service.users().drafts().create(userId="me", body=draft).execute() - email = parse_draft_email(draft_message) - email["url"] = get_draft_url(draft_message["id"]) - return email - - -# Note: in the Gmail UI, a user can customize the recipient and cc fields before replying. -# We decided not to support this feature, since we'd need a way for LLMs to tell apart between -# adding or removing recipients/cc, or replacing with an entirely new list of addresses, -# which would make the tool more complex to call. -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def write_draft_reply_email( - context: ToolContext, - body: Annotated[str, "The body of the draft reply email"], - reply_to_message_id: Annotated[str, "The Gmail message ID of the message to draft a reply to"], - reply_to_whom: Annotated[ - GmailReplyToWhom, - "Whether to reply to every recipient (including cc) or only to the original sender. " - f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.", - ] = GMAIL_DEFAULT_REPLY_TO, - bcc: Annotated[list[str] | None, "BCC recipients of the draft reply email"] = None, -) -> Annotated[dict, "A dictionary containing the created draft reply email details"]: - """ - Compose a draft reply to an email message. - """ - if isinstance(reply_to_whom, str): - reply_to_whom = GmailReplyToWhom(reply_to_whom) - - service = _build_gmail_service(context) - - current_user = service.users().getProfile(userId="me").execute() - - try: - replying_to_email = ( - service.users().messages().get(userId="me", id=reply_to_message_id).execute() - ) - except HttpError as e: - raise RetryableToolError( - message="Could not retrieve the message to respond to.", - developer_message=( - "Could not retrieve the message to respond to. " - f"Reason: '{e.reason}'. Error details: '{e.error_details}'" - ), - ) - - replying_to_email = parse_multipart_email(replying_to_email) - - recipients = build_reply_recipients( - replying_to_email, current_user["emailAddress"], reply_to_whom - ) - - draft_message = { - "message": build_email_message( - recipient=recipients, - subject=f"Re: {replying_to_email['subject']}", - body=body, - cc=None - if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER - else replying_to_email["cc"].split(","), - bcc=bcc, - replying_to=replying_to_email, - action=GmailAction.DRAFT, - ), - } - - draft = service.users().drafts().create(userId="me", body=draft_message).execute() - - email = parse_draft_email(draft) - email["url"] = get_draft_url(draft["id"]) - return email - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def update_draft_email( - context: ToolContext, - draft_email_id: Annotated[str, "The ID of the draft email to update."], - subject: Annotated[str, "The subject of the draft email"], - body: Annotated[str, "The body of the draft email"], - recipient: Annotated[str, "The recipient of the draft email"], - cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None, - bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None, -) -> Annotated[dict, "A dictionary containing the updated draft email details"]: - """ - Update an existing email draft using the Gmail API. - """ - service = _build_gmail_service(context) - - message = MIMEText(body) - message["to"] = recipient - message["subject"] = subject - if cc: - message["Cc"] = ", ".join(cc) - if bcc: - message["Bcc"] = ", ".join(bcc) - - # Encode the message in base64 - raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode() - - # Update the draft - draft = {"id": draft_email_id, "message": {"raw": raw_message}} - - updated_draft_message = ( - service.users().drafts().update(userId="me", id=draft_email_id, body=draft).execute() - ) - - email = parse_draft_email(updated_draft_message) - email["url"] = get_draft_url(updated_draft_message["id"]) - - return email - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.compose"], - ) -) -async def delete_draft_email( - context: ToolContext, - draft_email_id: Annotated[str, "The ID of the draft email to delete"], -) -> Annotated[str, "A confirmation message indicating successful deletion"]: - """ - Delete a draft email using the Gmail API. - """ - service = _build_gmail_service(context) - - # Delete the draft - service.users().drafts().delete(userId="me", id=draft_email_id).execute() - return f"Draft email with ID {draft_email_id} deleted successfully." - - -# Email Management Tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.modify"], - ) -) -async def trash_email( - context: ToolContext, email_id: Annotated[str, "The ID of the email to trash"] -) -> Annotated[dict, "A dictionary containing the trashed email details"]: - """ - Move an email to the trash folder using the Gmail API. - """ - - service = _build_gmail_service(context) - - # Trash the email - trashed_email = service.users().messages().trash(userId="me", id=email_id).execute() - - email = parse_plain_text_email(trashed_email) - email["url"] = get_email_in_trash_url(trashed_email["id"]) - return email - - -# Draft Search Tools -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_draft_emails( - context: ToolContext, - n_drafts: Annotated[int, "Number of draft emails to read"] = 5, -) -> Annotated[dict, "A dictionary containing a list of draft email details"]: - """ - Lists draft emails in the user's draft mailbox using the Gmail API. - """ - service = _build_gmail_service(context) - - listed_drafts = service.users().drafts().list(userId="me").execute() - - if not listed_drafts: - return {"emails": []} - - draft_ids = [draft["id"] for draft in listed_drafts.get("drafts", [])][:n_drafts] - - emails = [] - for draft_id in draft_ids: - try: - draft_data = service.users().drafts().get(userId="me", id=draft_id).execute() - draft_details = parse_draft_email(draft_data) - if draft_details: - emails.append(draft_details) - except Exception as e: - raise GmailToolError( - message=f"Error reading draft email {draft_id}.", developer_message=str(e) - ) - - return {"emails": emails} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_emails_by_header( - context: ToolContext, - sender: Annotated[str | None, "The name or email address of the sender of the email"] = None, - recipient: Annotated[str | None, "The name or email address of the recipient"] = None, - subject: Annotated[str | None, "Words to find in the subject of the email"] = None, - body: Annotated[str | None, "Words to find in the body of the email"] = None, - date_range: Annotated[DateRange | None, "The date range of the email"] = None, - label: Annotated[str | None, "The label name to filter by"] = None, - max_results: Annotated[int, "The maximum number of emails to return"] = 25, -) -> Annotated[ - dict, "A dictionary containing a list of email details matching the search criteria" -]: - """ - Search for emails by header using the Gmail API. - - At least one of the following parameters MUST be provided: sender, recipient, - subject, date_range, label, or body. - """ - service = _build_gmail_service(context) - # Ensure at least one search parameter is provided - if not any([sender, recipient, subject, body, label, date_range]): - raise RetryableToolError( - message=( - "At least one of sender, recipient, subject, body, label, query, " - "or date_range must be provided." - ), - developer_message=( - "At least one of sender, recipient, subject, body, label, query, " - "or date_range must be provided." - ), - ) - - # Check if label is valid - if label: - label_ids = get_label_ids(service, [label]) - - if not label_ids: - labels = service.users().labels().list(userId="me").execute().get("labels", []) - label_names = [label["name"] for label in labels] - raise RetryableToolError( - message=f"Invalid label: {label}", - developer_message=f"Invalid label: {label}", - additional_prompt_content=f"List of valid labels: {label_names}", - ) - - # Build a Gmail-style query string based on the filters - query = build_gmail_query_string(sender, recipient, subject, body, date_range, label) - - # Fetch matching messages. This fetches message metadata from Gmail - messages = fetch_messages(service, query, max_results) - - # If no messages found, return an empty list - if not messages: - return {"emails": []} - - # Process each message into a structured email object - emails = get_email_details(service, messages) - - # Return the list of emails in a dictionary with key "emails" - return {"emails": emails} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_emails( - context: ToolContext, - n_emails: Annotated[int, "Number of emails to read"] = 5, -) -> Annotated[dict, "A dictionary containing a list of email details"]: - """ - Read emails from a Gmail account and extract plain text content. - """ - service = _build_gmail_service(context) - - messages = service.users().messages().list(userId="me").execute().get("messages", []) - - if not messages: - return {"emails": []} - - emails = [] - for msg in messages[:n_emails]: - try: - email_data = service.users().messages().get(userId="me", id=msg["id"]).execute() - email_details = parse_plain_text_email(email_data) - if email_details: - emails.append(email_details) - except Exception as e: - raise GmailToolError( - message=f"Error reading email {msg['id']}.", developer_message=str(e) - ) - return {"emails": emails} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def search_threads( - context: ToolContext, - page_token: Annotated[ - str | None, "Page token to retrieve a specific page of results in the list" - ] = None, - max_results: Annotated[int, "The maximum number of threads to return"] = 10, - include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False, - label_ids: Annotated[list[str] | None, "The IDs of labels to filter by"] = None, - sender: Annotated[str | None, "The name or email address of the sender of the email"] = None, - recipient: Annotated[str | None, "The name or email address of the recipient"] = None, - subject: Annotated[str | None, "Words to find in the subject of the email"] = None, - body: Annotated[str | None, "Words to find in the body of the email"] = None, - date_range: Annotated[DateRange | None, "The date range of the email"] = None, -) -> Annotated[dict, "A dictionary containing a list of thread details"]: - """Search for threads in the user's mailbox""" - service = _build_gmail_service(context) - - query = ( - build_gmail_query_string(sender, recipient, subject, body, date_range) - if any([sender, recipient, subject, body, date_range]) - else None - ) - - params = { - "userId": "me", - "maxResults": min(max_results, 500), - "pageToken": page_token, - "includeSpamTrash": include_spam_trash, - "labelIds": label_ids, - "q": query, - } - params = remove_none_values(params) - - threads: list[dict[str, Any]] = [] - next_page_token = None - # Paginate through thread pages until we have the desired number of threads - while len(threads) < max_results: - response = service.users().threads().list(**params).execute() - - threads.extend(response.get("threads", [])) - next_page_token = response.get("nextPageToken") - - if not next_page_token: - break - - params["pageToken"] = next_page_token - params["maxResults"] = min(max_results - len(threads), 500) - - return { - "threads": threads, - "num_threads": len(threads), - "next_page_token": next_page_token, - } - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_threads( - context: ToolContext, - page_token: Annotated[ - str | None, "Page token to retrieve a specific page of results in the list" - ] = None, - max_results: Annotated[int, "The maximum number of threads to return"] = 10, - include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False, -) -> Annotated[dict, "A dictionary containing a list of thread details"]: - """List threads in the user's mailbox.""" - threads: dict[str, Any] = await search_threads( - context, page_token, max_results, include_spam_trash - ) - return threads - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def get_thread( - context: ToolContext, - thread_id: Annotated[str, "The ID of the thread to retrieve"], -) -> Annotated[dict, "A dictionary containing the thread details"]: - """Get the specified thread by ID.""" - params = { - "userId": "me", - "id": thread_id, - "format": "full", - } - params = remove_none_values(params) - - service = _build_gmail_service(context) - - thread = service.users().threads().get(**params).execute() - thread["messages"] = [parse_plain_text_email(message) for message in thread.get("messages", [])] - - return dict(thread) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.modify"], - ) -) -async def change_email_labels( - context: ToolContext, - email_id: Annotated[str, "The ID of the email to modify labels for"], - labels_to_add: Annotated[list[str], "List of label names to add"], - labels_to_remove: Annotated[list[str], "List of label names to remove"], -) -> Annotated[dict, "List of labels that were added, removed, and not found"]: - """ - Add and remove labels from an email using the Gmail API. - """ - service = _build_gmail_service(context) - - add_labels = get_label_ids(service, labels_to_add) - remove_labels = get_label_ids(service, labels_to_remove) - - invalid_labels = ( - set(labels_to_add + labels_to_remove) - set(add_labels.keys()) - set(remove_labels.keys()) - ) - - if invalid_labels: - # prepare the list of valid labels - labels = service.users().labels().list(userId="me").execute().get("labels", []) - label_names = [label["name"] for label in labels] - - # raise a retryable error with the list of valid labels - raise RetryableToolError( - message=f"Invalid labels: {invalid_labels}", - developer_message=f"Invalid labels: {invalid_labels}", - additional_prompt_content=f"List of valid labels: {label_names}", - ) - - # Prepare the modification body with label IDs. - body = { - "addLabelIds": list(add_labels.values()), - "removeLabelIds": list(remove_labels.values()), - } - - try: # Modify the email labels. - service.users().messages().modify(userId="me", id=email_id, body=body).execute() - - except Exception as e: - raise GmailToolError( - message=f"Error modifying labels for email {email_id}", developer_message=str(e) - ) - - # Confirmation JSON with lists for added and removed labels. - confirmation = { - "addedLabels": list(add_labels.keys()), - "removedLabels": list(remove_labels.keys()), - } - - return {"confirmation": dict(confirmation)} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.readonly"], - ) -) -async def list_labels( - context: ToolContext, -) -> Annotated[dict, "A dictionary containing a list of label details"]: - """List all the labels in the user's mailbox.""" - - service = _build_gmail_service(context) - - labels = service.users().labels().list(userId="me").execute().get("labels", []) - - return {"labels": labels} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/gmail.labels"], - ) -) -async def create_label( - context: ToolContext, - label_name: Annotated[str, "The name of the label to create"], -) -> Annotated[dict, "The details of the created label"]: - """Create a new label in the user's mailbox.""" - - service = _build_gmail_service(context) - label = service.users().labels().create(userId="me", body={"name": label_name}).execute() - - return {"label": label} diff --git a/toolkits/google/arcade_google/tools/sheets.py b/toolkits/google/arcade_google/tools/sheets.py deleted file mode 100644 index d803b9fb..00000000 --- a/toolkits/google/arcade_google/tools/sheets.py +++ /dev/null @@ -1,144 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import RetryableToolError - -from arcade_google.models import ( - SheetDataInput, - Spreadsheet, - SpreadsheetProperties, -) -from arcade_google.utils import ( - build_sheets_service, - create_sheet, - parse_get_spreadsheet_response, - parse_write_to_cell_response, - validate_write_to_cell_params, -) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -def create_spreadsheet( - context: ToolContext, - title: Annotated[str, "The title of the new spreadsheet"] = "Untitled spreadsheet", - data: Annotated[ - str | None, - "The data to write to the spreadsheet. A JSON string " - "(property names enclosed in double quotes) representing a dictionary that " - "maps row numbers to dictionaries that map column letters to cell values. " - "For example, data[23]['C'] would be the value of the cell in row 23, column C. " - "Type hint: dict[int, dict[str, Union[int, float, str, bool]]]", - ] = None, -) -> Annotated[dict, "The created spreadsheet's id and title"]: - """Create a new spreadsheet with the provided title and data in its first sheet - - Returns the newly created spreadsheet's id and title - """ - service = build_sheets_service(context.get_auth_token_or_empty()) - - try: - sheet_data = SheetDataInput(data=data) # type: ignore[arg-type] - except Exception as e: - msg = "Invalid JSON or unexpected data format for parameter `data`" - raise RetryableToolError( - message=msg, - additional_prompt_content=f"{msg}: {e}", - retry_after_ms=100, - ) - - spreadsheet = Spreadsheet( - properties=SpreadsheetProperties(title=title), - sheets=[create_sheet(sheet_data)], - ) - - body = spreadsheet.model_dump() - - response = ( - service.spreadsheets() - .create(body=body, fields="spreadsheetId,spreadsheetUrl,properties/title") - .execute() - ) - - return { - "title": response["properties"]["title"], - "spreadsheetId": response["spreadsheetId"], - "spreadsheetUrl": response["spreadsheetUrl"], - } - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -async def get_spreadsheet( - context: ToolContext, - spreadsheet_id: Annotated[str, "The id of the spreadsheet to get"], -) -> Annotated[ - dict, - "The spreadsheet properties and data for all sheets in the spreadsheet", -]: - """ - Get the user entered values and formatted values for all cells in all sheets in the spreadsheet - along with the spreadsheet's properties - """ - service = build_sheets_service(context.get_auth_token_or_empty()) - response = ( - service.spreadsheets() - .get( - spreadsheetId=spreadsheet_id, - includeGridData=True, - fields="spreadsheetId,spreadsheetUrl,properties/title,sheets/properties,sheets/data/rowData/values/userEnteredValue,sheets/data/rowData/values/formattedValue,sheets/data/rowData/values/effectiveValue", - ) - .execute() - ) - return parse_get_spreadsheet_response(response) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -def write_to_cell( - context: ToolContext, - spreadsheet_id: Annotated[str, "The id of the spreadsheet to write to"], - column: Annotated[str, "The column string to write to. For example, 'A', 'F', or 'AZ'"], - row: Annotated[int, "The row number to write to"], - value: Annotated[str, "The value to write to the cell"], - sheet_name: Annotated[ - str, "The name of the sheet to write to. Defaults to 'Sheet1'" - ] = "Sheet1", -) -> Annotated[dict, "The status of the operation"]: - """ - Write a value to a single cell in a spreadsheet. - """ - service = build_sheets_service(context.get_auth_token_or_empty()) - validate_write_to_cell_params(service, spreadsheet_id, sheet_name, column, row) - - range_ = f"'{sheet_name}'!{column.upper()}{row}" - body = { - "range": range_, - "majorDimension": "ROWS", - "values": [[value]], - } - - sheet_properties = ( - service.spreadsheets() - .values() - .update( - spreadsheetId=spreadsheet_id, - range=range_, - valueInputOption="USER_ENTERED", - includeValuesInResponse=True, - body=body, - ) - .execute() - ) - - return parse_write_to_cell_response(sheet_properties) diff --git a/toolkits/google/arcade_google/utils.py b/toolkits/google/arcade_google/utils.py deleted file mode 100644 index 1d81854c..00000000 --- a/toolkits/google/arcade_google/utils.py +++ /dev/null @@ -1,1564 +0,0 @@ -import logging -import re -from base64 import urlsafe_b64decode, urlsafe_b64encode -from datetime import date, datetime, time, timedelta, timezone -from email.message import EmailMessage -from email.mime.text import MIMEText -from enum import Enum -from typing import Any, cast -from zoneinfo import ZoneInfo - -from arcade_tdk import ToolContext -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from bs4 import BeautifulSoup -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build - -from arcade_google.constants import ( - DEFAULT_SEARCH_CONTACTS_LIMIT, - DEFAULT_SHEET_COLUMN_COUNT, - DEFAULT_SHEET_ROW_COUNT, -) -from arcade_google.exceptions import GmailToolError, GoogleServiceError -from arcade_google.models import ( - CellData, - CellExtendedValue, - CellFormat, - CellValue, - Corpora, - Day, - GmailAction, - GmailReplyToWhom, - GridData, - GridProperties, - NumberFormat, - NumberFormatType, - OrderBy, - RowData, - Sheet, - SheetDataInput, - SheetProperties, - TimeSlot, -) - -## Set up basic configuration for logging to the console with DEBUG level and a specific format. -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def parse_datetime(datetime_str: str, time_zone: str) -> datetime: - """ - Parse a datetime string in ISO 8601 format and ensure it is timezone-aware. - - Args: - datetime_str (str): The datetime string to parse. Expected format: 'YYYY-MM-DDTHH:MM:SS'. - time_zone (str): The timezone to apply if the datetime string is naive. - - Returns: - datetime: A timezone-aware datetime object. - - Raises: - ValueError: If the datetime string is not in the correct format. - """ - datetime_str = datetime_str.upper().strip().rstrip("Z") - try: - dt = datetime.fromisoformat(datetime_str) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo(time_zone)) - except ValueError as e: - raise ValueError( - f"Invalid datetime format: '{datetime_str}'. " - "Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'." - ) from e - return dt - - -class DateRange(Enum): - TODAY = "today" - YESTERDAY = "yesterday" - LAST_7_DAYS = "last_7_days" - LAST_30_DAYS = "last_30_days" - THIS_MONTH = "this_month" - LAST_MONTH = "last_month" - THIS_YEAR = "this_year" - - def to_date_query(self) -> str: - today = datetime.now() - result = "after:" - comparison_date = today - - if self == DateRange.YESTERDAY: - comparison_date = today - timedelta(days=1) - elif self == DateRange.LAST_7_DAYS: - comparison_date = today - timedelta(days=7) - elif self == DateRange.LAST_30_DAYS: - comparison_date = today - timedelta(days=30) - elif self == DateRange.THIS_MONTH: - comparison_date = today.replace(day=1) - elif self == DateRange.LAST_MONTH: - comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1) - elif self == DateRange.THIS_YEAR: - comparison_date = today.replace(month=1, day=1) - elif self == DateRange.LAST_MONTH: - comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace( - month=1, day=1 - ) - - return result + comparison_date.strftime("%Y/%m/%d") - - -def build_email_message( - recipient: str, - subject: str, - body: str, - cc: list[str] | None = None, - bcc: list[str] | None = None, - replying_to: dict[str, Any] | None = None, - action: GmailAction = GmailAction.SEND, -) -> dict[str, Any]: - if replying_to: - body = build_reply_body(body, replying_to) - - message: EmailMessage | MIMEText - - if action == GmailAction.SEND: - message = EmailMessage() - message.set_content(body) - elif action == GmailAction.DRAFT: - message = MIMEText(body) - - message["To"] = recipient - message["Subject"] = subject - - if cc: - message["Cc"] = ",".join(cc) - if bcc: - message["Bcc"] = ",".join(bcc) - if replying_to: - message["In-Reply-To"] = replying_to["header_message_id"] - message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}" - - encoded_message = urlsafe_b64encode(message.as_bytes()).decode() - - data = {"raw": encoded_message} - - if replying_to: - data["threadId"] = replying_to["thread_id"] - - return data - - -def build_reply_body(body: str, replying_to: dict[str, Any]) -> str: - attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:" - lines = replying_to["plain_text_body"].split("\n") - quoted_plain = "\n".join([f"> {line}" for line in lines]) - return f"{body}\n\n{attribution}\n\n{quoted_plain}" - - -def build_reply_recipients( - replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom -) -> str: - if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER: - recipients = [replying_to["from"]] - elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT: - recipients = [replying_to["from"], *replying_to["to"].split(",")] - else: - raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}") - - recipients = [ - email_address.strip() - for email_address in recipients - if email_address.strip().lower() != current_user_email_address.lower().strip() - ] - - return ", ".join(recipients) - - -def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]: - """ - Parse email data and extract relevant information. - Only returns the plain text body. - - Args: - email_data (dict[str, Any]): Raw email data from Gmail API. - - Returns: - dict[str, str]: Parsed email details - """ - payload = email_data.get("payload", {}) - headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])} - - body_data = _get_email_plain_text_body(payload) - - email_details = { - "id": email_data.get("id", ""), - "thread_id": email_data.get("threadId", ""), - "label_ids": email_data.get("labelIds", []), - "history_id": email_data.get("historyId", ""), - "snippet": email_data.get("snippet", ""), - "to": headers.get("to", ""), - "cc": headers.get("cc", ""), - "from": headers.get("from", ""), - "reply_to": headers.get("reply-to", ""), - "in_reply_to": headers.get("in-reply-to", ""), - "references": headers.get("references", ""), - "header_message_id": headers.get("message-id", ""), - "date": headers.get("date", ""), - "subject": headers.get("subject", ""), - "body": body_data or "", - } - - return email_details - - -def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]: - """ - Parse email data and extract relevant information. - Returns the plain text and HTML body along with the images. - - Args: - email_data (Dict[str, Any]): Raw email data from Gmail API. - - Returns: - dict[str, Any]: Parsed email details - """ - - payload = email_data.get("payload", {}) - headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])} - - # Extract different parts of the email - plain_text_body = _get_email_plain_text_body(payload) - html_body = _get_email_html_body(payload) - - email_details = { - "id": email_data.get("id", ""), - "thread_id": email_data.get("threadId", ""), - "label_ids": email_data.get("labelIds", []), - "history_id": email_data.get("historyId", ""), - "snippet": email_data.get("snippet", ""), - "to": headers.get("to", ""), - "cc": headers.get("cc", ""), - "from": headers.get("from", ""), - "reply_to": headers.get("reply-to", ""), - "in_reply_to": headers.get("in-reply-to", ""), - "references": headers.get("references", ""), - "header_message_id": headers.get("message-id", ""), - "date": headers.get("date", ""), - "subject": headers.get("subject", ""), - "plain_text_body": plain_text_body or _clean_email_body(html_body), - "html_body": html_body or "", - } - - return email_details - - -def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]: - """ - Parse draft email data and extract relevant information. - - Args: - draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API. - - Returns: - dict[str, str]: Parsed draft email details - """ - message = draft_email_data.get("message", {}) - payload = message.get("payload", {}) - headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])} - - body_data = _get_email_plain_text_body(payload) - - return { - "id": draft_email_data.get("id", ""), - "thread_id": draft_email_data.get("threadId", ""), - "from": headers.get("from", ""), - "date": headers.get("internaldate", ""), - "subject": headers.get("subject", ""), - "body": _clean_email_body(body_data) if body_data else "", - } - - -def get_draft_url(draft_id: str) -> str: - return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}" - - -def get_sent_email_url(sent_email_id: str) -> str: - return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}" - - -def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Retrieves full message data for each message ID in the given list and extracts email details. - - :param service: Authenticated Gmail API service instance. - :param messages: A list of dictionaries, each representing a message with an 'id' key. - :return: A list of dictionaries, each containing parsed email details. - """ - - emails = [] - for msg in messages: - try: - # Fetch the full message data from Gmail using the message ID - email_data = service.users().messages().get(userId="me", id=msg["id"]).execute() - # Parse the raw email data into a structured form - email_details = parse_plain_text_email(email_data) - # Only add the details if parsing was successful - if email_details: - emails.append(email_details) - except Exception as e: - # Log any errors encountered while trying to fetch or parse a message - raise GmailToolError( - message=f"Error reading email {msg['id']}.", developer_message=str(e) - ) - return emails - - -def get_email_in_trash_url(email_id: str) -> str: - return f"https://mail.google.com/mail/u/0/#trash/{email_id}" - - -def _build_gmail_service(context: ToolContext) -> Any: - """ - Private helper function to build and return the Gmail service client. - - Args: - context (ToolContext): The context containing authorization details. - - Returns: - googleapiclient.discovery.Resource: An authorized Gmail API service instance. - """ - try: - credentials = Credentials( - context.authorization.token - if context.authorization and context.authorization.token - else "" - ) - except Exception as e: - raise GoogleServiceError(message="Failed to build Gmail service.", developer_message=str(e)) - - return build("gmail", "v1", credentials=credentials) - - -def _extract_plain_body(parts: list) -> str | None: - """ - Recursively extract the email body from parts, handling both plain text and HTML. - - Args: - parts (List[Dict[str, Any]]): List of email parts. - - Returns: - str | None: Decoded and cleaned email body or None if not found. - """ - for part in parts: - mime_type = part.get("mimeType") - - if mime_type == "text/plain" and "data" in part.get("body", {}): - return urlsafe_b64decode(part["body"]["data"]).decode() - - elif mime_type.startswith("multipart/"): - subparts = part.get("parts", []) - body = _extract_plain_body(subparts) - if body: - return body - - return _extract_html_body(parts) - - -def _extract_html_body(parts: list) -> str | None: - """ - Recursively extract the email body from parts, handling only HTML. - - Args: - parts (List[Dict[str, Any]]): List of email parts. - - Returns: - str | None: Decoded and cleaned email body or None if not found. - """ - for part in parts: - mime_type = part.get("mimeType") - - if mime_type == "text/html" and "data" in part.get("body", {}): - html_content = urlsafe_b64decode(part["body"]["data"]).decode() - return html_content - - elif mime_type.startswith("multipart/"): - subparts = part.get("parts", []) - body = _extract_html_body(subparts) - if body: - return body - - return None - - -def _get_email_images(payload: dict[str, Any]) -> list[str] | None: - """ - Extract the email images from an email payload. - - Args: - payload (Dict[str, Any]): Email payload data. - - Returns: - list[str] | None: List of decoded image contents or None if none found. - """ - images = [] - for part in payload.get("parts", []): - mime_type = part.get("mimeType") - - if mime_type.startswith("image/") and "data" in part.get("body", {}): - image_content = part["body"]["data"] - images.append(image_content) - - elif mime_type.startswith("multipart/"): - subparts = part.get("parts", []) - subimages = _get_email_images(subparts) - if subimages: - images.extend(subimages) - - if images: - return images - - return None - - -def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None: - """ - Extract email body from payload, handling 'multipart/alternative' parts. - - Args: - payload (Dict[str, Any]): Email payload data. - - Returns: - str | None: Decoded email body or None if not found. - """ - # Direct body extraction - if "body" in payload and payload["body"].get("data"): - return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode()) - - # Handle multipart and alternative parts - return _clean_email_body(_extract_plain_body(payload.get("parts", []))) - - -def _get_email_html_body(payload: dict[str, Any]) -> str | None: - """ - Extract email html body from payload, handling 'multipart/alternative' parts. - - Args: - payload (Dict[str, Any]): Email payload data. - - Returns: - str | None: Decoded email body or None if not found. - """ - # Direct body extraction - if "body" in payload and payload["body"].get("data"): - return urlsafe_b64decode(payload["body"]["data"]).decode() - - # Handle multipart and alternative parts - return _extract_html_body(payload.get("parts", [])) - - -def _clean_email_body(body: str | None) -> str: - """ - Remove HTML tags and clean up email body text while preserving most content. - - Args: - body (str): The raw email body text. - - Returns: - str: Cleaned email body text. - """ - if not body: - return "" - - try: - # Remove HTML tags using BeautifulSoup - soup = BeautifulSoup(body, "html.parser") - text = soup.get_text(separator=" ") - - # Clean up the text - cleaned_text = _clean_text(text) - - return cleaned_text.strip() - except Exception: - logger.exception("Error cleaning email body") - return body - - -def _clean_text(text: str) -> str: - """ - Clean up the text while preserving most content. - - Args: - text (str): The input text. - - Returns: - str: Cleaned text. - """ - # Replace multiple newlines with a single newline - text = re.sub(r"\n+", "\n", text) - - # Replace multiple spaces with a single space - text = re.sub(r"\s+", " ", text) - - # Remove leading/trailing whitespace from each line - text = "\n".join(line.strip() for line in text.split("\n")) - - return text - - -def _update_datetime(day: Day | None, time: TimeSlot | None, time_zone: str) -> dict | None: - """ - Update the datetime for a Google Calendar event. - - Args: - day (Day | None): The day of the event. - time (TimeSlot | None): The time of the event. - time_zone (str): The time zone of the event. - - Returns: - dict | None: The updated datetime for the event. - """ - if day and time: - dt = datetime.combine(day.to_date(time_zone), time.to_time()) - return {"dateTime": dt.isoformat(), "timeZone": time_zone} - return None - - -def build_gmail_query_string( - sender: str | None = None, - recipient: str | None = None, - subject: str | None = None, - body: str | None = None, - date_range: DateRange | None = None, - label: str | None = None, -) -> str: - """Helper function to build a query string - for Gmail list_emails_by_header and search_threads tools. - """ - query = [] - if sender: - query.append(f"from:{sender}") - if recipient: - query.append(f"to:{recipient}") - if subject: - query.append(f"subject:{subject}") - if body: - query.append(body) - if date_range: - query.append(date_range.to_date_query()) - if label: - query.append(f"label:{label}") - return " ".join(query) - - -def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]: - """ - Retrieve label IDs for given label names. - Returns a dictionary mapping label names to their IDs. - - Args: - service: Authenticated Gmail API service instance. - label_names: List of label names to retrieve IDs for. - - Returns: - A dictionary mapping found label names to their corresponding IDs. - """ - try: - # Fetch all existing labels from Gmail - labels = service.users().labels().list(userId="me").execute().get("labels", []) - except Exception as e: - raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e - - # Create a mapping from label names to their IDs - label_id_map = {label["name"]: label["id"] for label in labels} - - found_labels = {} - for name in label_names: - label_id = label_id_map.get(name) - if label_id: - found_labels[name] = label_id - else: - logger.warning(f"Label '{name}' does not exist") - - return found_labels - - -def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]: - """ - Helper function to fetch messages from Gmail API for the list_emails_by_header tool. - """ - response = ( - service.users() - .messages() - .list(userId="me", q=query_string, maxResults=limit or 100) - .execute() - ) - return response.get("messages", []) # type: ignore[no-any-return] - - -def remove_none_values(params: dict) -> dict: - """ - Remove None values from a dictionary. - :param params: The dictionary to clean - :return: A new dictionary with None values removed - """ - return {k: v for k, v in params.items() if v is not None} - - -# Drive utils -def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Drive service object. - """ - auth_token = auth_token or "" - return build("drive", "v3", credentials=Credentials(auth_token)) - - -def build_files_list_query( - mime_type: str, - document_contains: list[str] | None = None, - document_not_contains: list[str] | None = None, -) -> str: - query = [f"(mimeType = '{mime_type}' and trashed = false)"] - - if isinstance(document_contains, str): - document_contains = [document_contains] - - if isinstance(document_not_contains, str): - document_not_contains = [document_not_contains] - - if document_contains: - for keyword in document_contains: - name_contains = keyword.replace("'", "\\'") - full_text_contains = keyword.replace("'", "\\'") - keyword_query = ( - f"(name contains '{name_contains}' or fullText contains '{full_text_contains}')" - ) - query.append(keyword_query) - - if document_not_contains: - for keyword in document_not_contains: - name_not_contains = keyword.replace("'", "\\'") - full_text_not_contains = keyword.replace("'", "\\'") - keyword_query = ( - f"(name not contains '{name_not_contains}' and " - f"fullText not contains '{full_text_not_contains}')" - ) - query.append(keyword_query) - - return " and ".join(query) - - -def build_files_list_params( - mime_type: str, - page_size: int, - order_by: list[OrderBy], - pagination_token: str | None, - include_shared_drives: bool, - search_only_in_shared_drive_id: str | None, - include_organization_domain_documents: bool, - document_contains: list[str] | None = None, - document_not_contains: list[str] | None = None, -) -> dict[str, Any]: - query = build_files_list_query( - mime_type=mime_type, - document_contains=document_contains, - document_not_contains=document_not_contains, - ) - - params = { - "q": query, - "pageSize": page_size, - "orderBy": ",".join([item.value for item in order_by]), - "pageToken": pagination_token, - } - - if ( - include_shared_drives - or search_only_in_shared_drive_id - or include_organization_domain_documents - ): - params["includeItemsFromAllDrives"] = "true" - params["supportsAllDrives"] = "true" - - if search_only_in_shared_drive_id: - params["driveId"] = search_only_in_shared_drive_id - params["corpora"] = Corpora.DRIVE.value - - if include_organization_domain_documents: - params["corpora"] = Corpora.DOMAIN.value - - params = remove_none_values(params) - - return params - - -def build_file_tree_request_params( - order_by: list[OrderBy] | None, - page_token: str | None, - limit: int | None, - include_shared_drives: bool, - restrict_to_shared_drive_id: str | None, - include_organization_domain_documents: bool, -) -> dict[str, Any]: - if order_by is None: - order_by = [OrderBy.MODIFIED_TIME_DESC] - elif isinstance(order_by, OrderBy): - order_by = [order_by] - - params = { - "q": "trashed = false", - "corpora": Corpora.USER.value, - "pageToken": page_token, - "fields": ( - "files(id, name, parents, mimeType, driveId, size, createdTime, modifiedTime, owners)" - ), - "orderBy": ",".join([item.value for item in order_by]), - } - - if limit: - params["pageSize"] = str(limit) - - if ( - include_shared_drives - or restrict_to_shared_drive_id - or include_organization_domain_documents - ): - params["includeItemsFromAllDrives"] = "true" - params["supportsAllDrives"] = "true" - - if restrict_to_shared_drive_id: - params["driveId"] = restrict_to_shared_drive_id - params["corpora"] = Corpora.DRIVE.value - - if include_organization_domain_documents: - params["corpora"] = Corpora.DOMAIN.value - - return params - - -def build_file_tree(files: dict[str, Any]) -> dict[str, Any]: - file_tree: dict[str, Any] = {} - - for file in files.values(): - owners = file.get("owners", []) - if owners: - owners = [ - {"name": owner.get("displayName", ""), "email": owner.get("emailAddress", "")} - for owner in owners - ] - file["owners"] = owners - - if "size" in file: - file["size"] = {"value": int(file["size"]), "unit": "bytes"} - - # Although "parents" is a list, a file can only have one parent - try: - parent_id = file["parents"][0] - del file["parents"] - except (KeyError, IndexError): - parent_id = None - - # Determine the file's Drive ID - if "driveId" in file: - drive_id = file["driveId"] - del file["driveId"] - # If a shared drive id is not present, the file is in "My Drive" - else: - drive_id = "My Drive" - - if drive_id not in file_tree: - file_tree[drive_id] = [] - - # Root files will have the Drive's id as the parent. If the parent id is not in the files - # list, the file must be at drive's root - if parent_id not in files: - file_tree[drive_id].append(file) - - # Associate the file with its parent - else: - if "children" not in files[parent_id]: - files[parent_id]["children"] = [] - files[parent_id]["children"].append(file) - - return file_tree - - -# Docs utils -def build_docs_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Drive service object. - """ - auth_token = auth_token or "" - return build("docs", "v1", credentials=Credentials(auth_token)) - - -def parse_rfc3339_datetime_str(dt_str: str, tz: timezone = timezone.utc) -> datetime: - """ - Parse an RFC3339 datetime string into a timezone-aware datetime. - Converts a trailing 'Z' (UTC) into +00:00. - If the parsed datetime is naive, assume it is in the provided timezone. - """ - if dt_str.endswith("Z"): - dt_str = dt_str[:-1] + "+00:00" - dt = datetime.fromisoformat(dt_str) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=tz) - return dt - - -def merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]: - """ - Given a list of (start, end) tuples, merge overlapping or adjacent intervals. - """ - merged: list[tuple[datetime, datetime]] = [] - for start, end in sorted(intervals, key=lambda x: x[0]): - if not merged: - merged.append((start, end)) - else: - last_start, last_end = merged[-1] - if start <= last_end: - merged[-1] = (last_start, max(last_end, end)) - else: - merged.append((start, end)) - return merged - - -# Calendar utils - - -def build_oauth_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build an OAuth2 service object. - """ - auth_token = auth_token or "" - return build("oauth2", "v2", credentials=Credentials(auth_token)) - - -def build_calendar_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Calendar service object. - """ - auth_token = auth_token or "" - return build("calendar", "v3", credentials=Credentials(auth_token)) - - -def weekday_to_name(weekday: int) -> str: - return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday] - - -def get_time_boundaries_for_date( - current_date: date, - global_start: datetime, - global_end: datetime, - start_time_boundary: time, - end_time_boundary: time, - tz: ZoneInfo, -) -> tuple[datetime, datetime]: - """Compute the allowed start and end times for the given day, adjusting for global bounds.""" - day_start_time = datetime.combine(current_date, start_time_boundary).replace(tzinfo=tz) - day_end_time = datetime.combine(current_date, end_time_boundary).replace(tzinfo=tz) - - if current_date == global_start.date(): - day_start_time = max(day_start_time, global_start) - - if current_date == global_end.date(): - day_end_time = min(day_end_time, global_end) - - return day_start_time, day_end_time - - -def gather_busy_intervals( - busy_data: dict[str, Any], - day_start: datetime, - day_end: datetime, - business_tz: ZoneInfo, -) -> list[tuple[datetime, datetime]]: - """ - Collect busy intervals from all calendars that intersect with the day's business hours. - Busy intervals are clipped to lie within [day_start, day_end]. - """ - busy_intervals = [] - for calendar in busy_data: - for slot in busy_data[calendar].get("busy", []): - slot_start = parse_rfc3339_datetime_str(slot["start"]).astimezone(business_tz) - slot_end = parse_rfc3339_datetime_str(slot["end"]).astimezone(business_tz) - if slot_end > day_start and slot_start < day_end: - busy_intervals.append((max(slot_start, day_start), min(slot_end, day_end))) - return busy_intervals - - -def subtract_busy_intervals( - business_start: datetime, - business_end: datetime, - busy_intervals: list[tuple[datetime, datetime]], -) -> list[dict[str, Any]]: - """ - Subtract the merged busy intervals from the business hours and return free time slots. - """ - free_slots = [] - merged_busy = merge_intervals(busy_intervals) - - # If there are no busy intervals, return the entire business window as free. - if not merged_busy: - return [ - { - "start": { - "datetime": business_start.isoformat(), - "weekday": weekday_to_name(business_start.weekday()), - }, - "end": { - "datetime": business_end.isoformat(), - "weekday": weekday_to_name(business_end.weekday()), - }, - } - ] - - current_free_start = business_start - for busy_start, busy_end in merged_busy: - if current_free_start < busy_start: - free_slots.append({ - "start": { - "datetime": current_free_start.isoformat(), - "weekday": weekday_to_name(current_free_start.weekday()), - }, - "end": { - "datetime": busy_start.isoformat(), - "weekday": weekday_to_name(busy_start.weekday()), - }, - }) - current_free_start = max(current_free_start, busy_end) - if current_free_start < business_end: - free_slots.append({ - "start": { - "datetime": current_free_start.isoformat(), - "weekday": weekday_to_name(current_free_start.weekday()), - }, - "end": { - "datetime": business_end.isoformat(), - "weekday": weekday_to_name(business_end.weekday()), - }, - }) - return free_slots - - -def compute_free_time_intersection( - busy_data: dict[str, Any], - global_start: datetime, - global_end: datetime, - start_time_boundary: time, - end_time_boundary: time, - include_weekends: bool, - tz: ZoneInfo, -) -> list[dict[str, Any]]: - """ - Returns the free time slots across all calendars within the global bounds, - ensuring that the global start is not in the past. - - Only considers business days (Monday to Friday) and business hours (08:00-19:00) - in the provided timezone. - """ - # Ensure global_start is never in the past relative to now. - now = get_now(tz) - - if now > global_start: - global_start = now - - # If after adjusting the start, there's no interval left, return empty. - if global_start >= global_end: - return [] - - free_slots = [] - current_date = global_start.date() - - while current_date <= global_end.date(): - if not include_weekends and current_date.weekday() >= 5: - current_date += timedelta(days=1) - continue - - day_start, day_end = get_time_boundaries_for_date( - current_date=current_date, - global_start=global_start, - global_end=global_end, - start_time_boundary=start_time_boundary, - end_time_boundary=end_time_boundary, - tz=tz, - ) - - # Skip if the day's allowed time window is empty. - if day_start >= day_end: - current_date += timedelta(days=1) - continue - - busy_intervals = gather_busy_intervals(busy_data, day_start, day_end, tz) - free_slots.extend(subtract_busy_intervals(day_start, day_end, busy_intervals)) - - current_date += timedelta(days=1) - - return free_slots - - -def get_now(tz: ZoneInfo | None = None) -> datetime: - if not tz: - tz = ZoneInfo("UTC") - return datetime.now(tz) - - -# Contacts utils -def build_people_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a People service object. - """ - auth_token = auth_token or "" - return build("people", "v1", credentials=Credentials(auth_token)) - - -def search_contacts(service: Any, query: str, limit: int | None) -> list[dict[str, Any]]: - """ - Search the user's contacts in Google Contacts. - """ - response = ( - service.people() - .searchContacts( - query=query, - pageSize=limit or DEFAULT_SEARCH_CONTACTS_LIMIT, - readMask=",".join([ - "names", - "nicknames", - "emailAddresses", - "phoneNumbers", - "addresses", - "organizations", - "biographies", - "urls", - "userDefined", - ]), - ) - .execute() - ) - - return cast(list[dict[str, Any]], response.get("results", [])) - - -# ---------------------------------------------------------------- -# Sheets utils -# ---------------------------------------------------------------- - - -def build_sheets_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Sheets service object. - """ - auth_token = auth_token or "" - return build("sheets", "v4", credentials=Credentials(auth_token)) - - -def col_to_index(col: str) -> int: - """Convert a sheet's column string to a 0-indexed column index - - Args: - col (str): The column string to convert. e.g., "A", "AZ", "QED" - - Returns: - int: The 0-indexed column index. - """ - result = 0 - for char in col.upper(): - result = result * 26 + (ord(char) - ord("A") + 1) - return result - 1 - - -def index_to_col(index: int) -> str: - """Convert a 0-indexed column index to its corresponding column string - - Args: - index (int): The 0-indexed column index to convert. - - Returns: - str: The column string. e.g., "A", "AZ", "QED" - """ - result = "" - index += 1 - while index > 0: - index, rem = divmod(index - 1, 26) - result = chr(rem + ord("A")) + result - return result - - -def is_col_greater(col1: str, col2: str) -> bool: - """Determine if col1 represents a column that comes after col2 in a sheet - - This comparison is based on: - 1. The length of the column string (longer means greater). - 2. Lexicographical comparison if both strings are the same length. - - Args: - col1 (str): The first column string to compare. - col2 (str): The second column string to compare. - - Returns: - bool: True if col1 comes after col2, False otherwise. - """ - if len(col1) != len(col2): - return len(col1) > len(col2) - return col1.upper() > col2.upper() - - -def compute_sheet_data_dimensions( - sheet_data_input: SheetDataInput, -) -> tuple[tuple[int, int], tuple[int, int]]: - """ - Compute the dimensions of a sheet based on the data provided. - - Args: - sheet_data_input (SheetDataInput): - The data to compute the dimensions of. - - Returns: - tuple[tuple[int, int], tuple[int, int]]: The dimensions of the sheet. The first tuple - contains the row range (start, end) and the second tuple contains the column range - (start, end). - """ - max_row = 0 - min_row = 10_000_000 # max number of cells in a sheet - max_col_str = None - min_col_str = None - - for key, row in sheet_data_input.data.items(): - try: - row_num = int(key) - except ValueError: - continue - if row_num > max_row: - max_row = row_num - if row_num < min_row: - min_row = row_num - - if isinstance(row, dict): - for col in row: - # Update max column string - if max_col_str is None or is_col_greater(col, max_col_str): - max_col_str = col - # Update min column string - if min_col_str is None or is_col_greater(min_col_str, col): - min_col_str = col - - max_col_index = col_to_index(max_col_str) if max_col_str is not None else -1 - min_col_index = col_to_index(min_col_str) if min_col_str is not None else 0 - - return (min_row, max_row), (min_col_index, max_col_index) - - -def create_sheet(sheet_data_input: SheetDataInput) -> Sheet: - """Create a Google Sheet from a dictionary of data. - - Args: - sheet_data_input (SheetDataInput): The data to create the sheet from. - - Returns: - Sheet: The created sheet. - """ - (_, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(sheet_data_input) - sheet_data = create_sheet_data(sheet_data_input, min_col_index, max_col_index) - sheet_properties = create_sheet_properties( - row_count=max(DEFAULT_SHEET_ROW_COUNT, max_row), - column_count=max(DEFAULT_SHEET_COLUMN_COUNT, max_col_index + 1), - ) - - return Sheet(properties=sheet_properties, data=sheet_data) - - -def create_sheet_properties( - sheet_id: int = 1, - title: str = "Sheet1", - row_count: int = DEFAULT_SHEET_ROW_COUNT, - column_count: int = DEFAULT_SHEET_COLUMN_COUNT, -) -> SheetProperties: - """Create a SheetProperties object - - Args: - sheet_id (int): The ID of the sheet. - title (str): The title of the sheet. - row_count (int): The number of rows in the sheet. - column_count (int): The number of columns in the sheet. - - Returns: - SheetProperties: The created sheet properties object. - """ - return SheetProperties( - sheetId=sheet_id, - title=title, - gridProperties=GridProperties(rowCount=row_count, columnCount=column_count), - ) - - -def group_contiguous_rows(row_numbers: list[int]) -> list[list[int]]: - """Groups a sorted list of row numbers into contiguous groups - - A contiguous group is a list of row numbers that are consecutive integers. - For example, [1,2,3,5,6] is converted to [[1,2,3],[5,6]]. - - Args: - row_numbers (list[int]): The list of row numbers to group. - - Returns: - list[list[int]]: The grouped row numbers. - """ - if not row_numbers: - return [] - groups = [] - current_group = [row_numbers[0]] - for r in row_numbers[1:]: - if r == current_group[-1] + 1: - current_group.append(r) - else: - groups.append(current_group) - current_group = [r] - groups.append(current_group) - return groups - - -def create_cell_data(cell_value: CellValue) -> CellData: - """ - Create a CellData object based on the type of cell_value. - """ - if isinstance(cell_value, bool): - return _create_bool_cell(cell_value) - elif isinstance(cell_value, int): - return _create_int_cell(cell_value) - elif isinstance(cell_value, float): - return _create_float_cell(cell_value) - elif isinstance(cell_value, str): - return _create_string_cell(cell_value) - - -def _create_formula_cell(cell_value: str) -> CellData: - cell_val = CellExtendedValue(formulaValue=cell_value) - return CellData(userEnteredValue=cell_val) - - -def _create_currency_cell(cell_value: str) -> CellData: - value_without_symbol = cell_value[1:] - try: - num_value = int(value_without_symbol) - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0") - ) - cell_val = CellExtendedValue(numberValue=num_value) - return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format) - except ValueError: - try: - num_value = float(value_without_symbol) # type: ignore[assignment] - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0.00") - ) - cell_val = CellExtendedValue(numberValue=num_value) - return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format) - except ValueError: - return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value)) - - -def _create_percent_cell(cell_value: str) -> CellData: - try: - num_value = float(cell_value[:-1].strip()) - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.PERCENT, pattern="0.00%") - ) - cell_val = CellExtendedValue(numberValue=num_value) - return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format) - except ValueError: - return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value)) - - -def _create_bool_cell(cell_value: bool) -> CellData: - return CellData(userEnteredValue=CellExtendedValue(boolValue=cell_value)) - - -def _create_int_cell(cell_value: int) -> CellData: - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0") - ) - return CellData( - userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format - ) - - -def _create_float_cell(cell_value: float) -> CellData: - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0.00") - ) - return CellData( - userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format - ) - - -def _create_string_cell(cell_value: str) -> CellData: - if cell_value.startswith("="): - return _create_formula_cell(cell_value) - elif cell_value.startswith("$") and len(cell_value) > 1: - return _create_currency_cell(cell_value) - elif cell_value.endswith("%") and len(cell_value) > 1: - return _create_percent_cell(cell_value) - - return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value)) - - -def create_row_data( - row_data: dict[str, CellValue], min_col_index: int, max_col_index: int -) -> RowData: - """Constructs RowData for a single row using the provided row_data. - - Args: - row_data (dict[str, CellValue]): The data to create the row from. - min_col_index (int): The minimum column index from the SheetDataInput. - max_col_index (int): The maximum column index from the SheetDataInput. - """ - row_cells = [] - for col_idx in range(min_col_index, max_col_index + 1): - col_letter = index_to_col(col_idx) - if col_letter in row_data: - cell_data = create_cell_data(row_data[col_letter]) - else: - cell_data = CellData(userEnteredValue=CellExtendedValue(stringValue="")) - row_cells.append(cell_data) - return RowData(values=row_cells) - - -def create_sheet_data( - sheet_data_input: SheetDataInput, - min_col_index: int, - max_col_index: int, -) -> list[GridData]: - """Create grid data from SheetDataInput by grouping contiguous rows and processing cells. - - Args: - sheet_data_input (SheetDataInput): The data to create the sheet from. - min_col_index (int): The minimum column index from the SheetDataInput. - max_col_index (int): The maximum column index from the SheetDataInput. - - Returns: - list[GridData]: The created grid data. - """ - row_numbers = list(sheet_data_input.data.keys()) - if not row_numbers: - return [] - - sorted_rows = sorted(row_numbers) - groups = group_contiguous_rows(sorted_rows) - - sheet_data = [] - for group in groups: - rows_data = [] - for r in group: - current_row_data = sheet_data_input.data.get(r, {}) - row = create_row_data(current_row_data, min_col_index, max_col_index) - rows_data.append(row) - grid_data = GridData( - startRow=group[0] - 1, # convert to 0-indexed - startColumn=min_col_index, - rowData=rows_data, - ) - sheet_data.append(grid_data) - - return sheet_data - - -def parse_get_spreadsheet_response(api_response: dict) -> dict: - """ - Parse the get spreadsheet Google Sheets API response into a structured dictionary. - """ - properties = api_response.get("properties", {}) - sheets = [parse_sheet(sheet) for sheet in api_response.get("sheets", [])] - - return { - "title": properties.get("title", ""), - "spreadsheetId": api_response.get("spreadsheetId", ""), - "spreadsheetUrl": api_response.get("spreadsheetUrl", ""), - "sheets": sheets, - } - - -def parse_sheet(api_sheet: dict) -> dict: - """ - Parse an individual sheet's data from the Google Sheets 'get spreadsheet' - API response into a structured dictionary. - """ - props = api_sheet.get("properties", {}) - grid_props = props.get("gridProperties", {}) - cell_data = convert_api_grid_data_to_dict(api_sheet.get("data", [])) - - return { - "sheetId": props.get("sheetId"), - "title": props.get("title", ""), - "rowCount": grid_props.get("rowCount", 0), - "columnCount": grid_props.get("columnCount", 0), - "data": cell_data, - } - - -def extract_user_entered_cell_value(cell: dict) -> Any: - """ - Extract the user entered value from a cell's 'userEnteredValue'. - - Args: - cell (dict): A cell dictionary from the grid data. - - Returns: - The extracted value if present, otherwise None. - """ - user_val = cell.get("userEnteredValue", {}) - for key in ["stringValue", "numberValue", "boolValue", "formulaValue"]: - if key in user_val: - return user_val[key] - - return "" - - -def process_row(row: dict, start_column_index: int) -> dict: - """ - Process a single row from grid data, converting non-empty cells into a dictionary - that maps column letters to cell values. - - Args: - row (dict): A row from the grid data. - start_column_index (int): The starting column index for this row. - - Returns: - dict: A mapping of column letters to cell values for non-empty cells. - """ - row_result = {} - for j, cell in enumerate(row.get("values", [])): - column_index = start_column_index + j - column_string = index_to_col(column_index) - user_entered_cell_value = extract_user_entered_cell_value(cell) - formatted_cell_value = cell.get("formattedValue", "") - - if user_entered_cell_value != "" or formatted_cell_value != "": - row_result[column_string] = { - "userEnteredValue": user_entered_cell_value, - "formattedValue": formatted_cell_value, - } - - return row_result - - -def convert_api_grid_data_to_dict(grids: list[dict]) -> dict: - """ - Convert a list of grid data dictionaries from the 'get spreadsheet' API - response into a structured cell dictionary. - - The returned dictionary maps row numbers to sub-dictionaries that map column letters - (e.g., 'A', 'B', etc.) to their corresponding non-empty cell values. - - Args: - grids (list[dict]): The list of grid data dictionaries from the API. - - Returns: - dict: A dictionary mapping row numbers to dictionaries of column letter/value pairs. - Only includes non-empty rows and non-empty cells. - """ - result = {} - for grid in grids: - start_row = grid.get("startRow", 0) - start_column = grid.get("startColumn", 0) - - for i, row in enumerate(grid.get("rowData", []), start=1): - current_row = start_row + i - row_data = process_row(row, start_column) - - if row_data: - result[current_row] = row_data - - return dict(sorted(result.items())) - - -def validate_write_to_cell_params( # type: ignore[no-any-unimported] - service: Resource, - spreadsheet_id: str, - sheet_name: str, - column: str, - row: int, -) -> None: - """Validates the input parameters for the write to cell tool. - - Args: - service (Resource): The Google Sheets service. - spreadsheet_id (str): The ID of the spreadsheet provided to the tool. - sheet_name (str): The name of the sheet provided to the tool. - column (str): The column to write to provided to the tool. - row (int): The row to write to provided to the tool. - - Raises: - RetryableToolError: - If the sheet name is not found in the spreadsheet - ToolExecutionError: - If the column is not alphabetical - If the row is not a positive number - If the row is out of bounds for the sheet - If the column is out of bounds for the sheet - """ - if not column.isalpha(): - raise ToolExecutionError( - message=( - f"Invalid column name {column}. " - "It must be a non-empty string containing only letters" - ), - ) - - if row < 1: - raise ToolExecutionError( - message=(f"Invalid row number {row}. It must be a positive integer greater than 0."), - ) - - sheet_properties = ( - service.spreadsheets() - .get( - spreadsheetId=spreadsheet_id, - includeGridData=True, - fields="sheets/properties/title,sheets/properties/gridProperties/rowCount,sheets/properties/gridProperties/columnCount", - ) - .execute() - ) - sheet_names = [sheet["properties"]["title"] for sheet in sheet_properties["sheets"]] - sheet_row_count = sheet_properties["sheets"][0]["properties"]["gridProperties"]["rowCount"] - sheet_column_count = sheet_properties["sheets"][0]["properties"]["gridProperties"][ - "columnCount" - ] - - if sheet_name not in sheet_names: - raise RetryableToolError( - message=f"Sheet name {sheet_name} not found in spreadsheet with id {spreadsheet_id}", - additional_prompt_content=f"Sheet names in the spreadsheet: {sheet_names}", - retry_after_ms=100, - ) - - if row > sheet_row_count: - raise ToolExecutionError( - message=( - f"Row {row} is out of bounds for sheet {sheet_name} " - f"in spreadsheet with id {spreadsheet_id}. " - f"Sheet only has {sheet_row_count} rows which is less than the requested row {row}" - ) - ) - - if col_to_index(column) > sheet_column_count: - raise ToolExecutionError( - message=( - f"Column {column} is out of bounds for sheet {sheet_name} " - f"in spreadsheet with id {spreadsheet_id}. " - f"Sheet only has {sheet_column_count} columns which " - f"is less than the requested column {column}" - ) - ) - - -def parse_write_to_cell_response(response: dict) -> dict: - return { - "spreadsheetId": response["spreadsheetId"], - "sheetTitle": response["updatedData"]["range"].split("!")[0], - "updatedCell": response["updatedData"]["range"].split("!")[1], - "value": response["updatedData"]["values"][0][0], - } diff --git a/toolkits/google/conftest.py b/toolkits/google/conftest.py deleted file mode 100644 index c848402a..00000000 --- a/toolkits/google/conftest.py +++ /dev/null @@ -1,1163 +0,0 @@ -import pytest - - -@pytest.fixture -def sample_drive_file_tree_request_responses() -> tuple[dict, list]: - files_list = { - "files": [ - # Shared Drive 1 files and folders - { - "id": "19WVyQndQsc0AxxfdrIt5CvDQd6r-BvpqnB8bWZoL7Xk", - "name": "shared-1-folder-1-doc-1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt"], - "createdTime": "2025-02-26T00:28:20.571Z", - "modifiedTime": "2025-02-26T00:28:30.773Z", - "driveId": "0AFqcR6obkydtUk9PVA", - "size": "1024", - }, - { - "id": "1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt", - "name": "shared-1-folder-1", - "mimeType": "application/vnd.google-apps.folder", - "parents": ["0AFqcR6obkydtUk9PVA"], - "createdTime": "2025-02-26T00:27:45.526Z", - "modifiedTime": "2025-02-26T00:27:45.526Z", - "driveId": "0AFqcR6obkydtUk9PVA", - }, - { - "id": "1didt_h-tDjuJ-dmYtHUSyOCPci30K_kSszvg0G3tKBM", - "name": "shared-1-doc-1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["0AFqcR6obkydtUk9PVA"], - "createdTime": "2025-02-26T00:27:19.287Z", - "modifiedTime": "2025-02-26T00:27:26.079Z", - "driveId": "0AFqcR6obkydtUk9PVA", - "size": "1024", - }, - # My Drive files and folders - { - "id": "1vB6sv0MD0hYSraYvWU_fcci3GN_-Jf4g-LfyXdG8ZMo", - "name": "The Birth of MX Engineering", - "mimeType": "application/vnd.google-apps.document", - "parents": ["0AIbBwO2hjeHqUk9PVA"], - "createdTime": "2025-01-24T06:34:22.305Z", - "modifiedTime": "2025-02-25T21:54:30.632Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "6634", - }, - { - "id": "1wv2dmYo0skJTI59ZIcwH9vm-wt7psMwXTvihuEGeHeI", - "name": "test document 1.1.1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO"], - "createdTime": "2025-02-25T17:59:03.325Z", - "modifiedTime": "2025-02-25T17:59:11.445Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "1024", - }, - { - "id": "1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO", - "name": "test folder 1.1", - "mimeType": "application/vnd.google-apps.folder", - "parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"], - "createdTime": "2025-02-25T17:58:58.987Z", - "modifiedTime": "2025-02-25T17:58:58.987Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - }, - { - "id": "1DSmL7d07kjT6b6L-t4JIT06ElUbZ1q0K6_gEpn_UGZ8", - "name": "test document 1.2", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"], - "createdTime": "2025-02-25T17:58:38.628Z", - "modifiedTime": "2025-02-25T17:58:46.713Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "1024", - }, - { - "id": "1Fcxz7HsyO2Zyc-5DTD3zBQnaVrZwD29BP9KD9rPnYfE", - "name": "test document 1.1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"], - "createdTime": "2025-02-25T17:57:53.850Z", - "modifiedTime": "2025-02-25T17:58:28.745Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "1024", - }, - { - "id": "1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo", - "name": "test folder 1", - "mimeType": "application/vnd.google-apps.folder", - "parents": ["0AIbBwO2hjeHqUk9PVA"], - "createdTime": "2025-02-25T17:57:46.036Z", - "modifiedTime": "2025-02-25T17:57:46.036Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - }, - { - "id": "16PUe97yGQeOjQgrgd54iCoxzid4SEvu_J33P_ELd5r8", - "name": "Hello world presentation", - "mimeType": "application/vnd.google-apps.presentation", - "createdTime": "2025-02-18T20:48:52.786Z", - "modifiedTime": "2025-02-19T23:31:20.483Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "john.doe", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": False, - "permissionId": "06420661154928749996", - "emailAddress": "john.doe@arcade.dev", - } - ], - "size": "15774558", - }, - { - "id": "1nG7lSvIyK05N9METPczVJa4iGgE7uoo-A6zpqjpUsDY", - "name": "Shared doc 1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-19T18:51:44.622Z", - "modifiedTime": "2025-02-19T19:30:39.773Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "theboss", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": False, - "permissionId": "11571864250637401873", - "emailAddress": "theboss@arcade.dev", - } - ], - "size": "2700", - }, - ], - } - - drives_get = [ - { - "id": "0AFqcR6obkydtUk9PVA", - "name": "Shared Drive 1", - } - ] - - return files_list, drives_get - - -@pytest.fixture -def sample_document_and_expected_formats(): - document = { - "title": "The Birth of Machine Experience Engineering", - "documentId": "1234567890", - "body": { - "content": [ - { - "endIndex": 1, - "sectionBreak": { - "sectionStyle": { - "columnSeparatorStyle": "NONE", - "contentDirection": "LEFT_TO_RIGHT", - "sectionType": "CONTINUOUS", - } - }, - }, - { - "startIndex": 1, - "endIndex": 45, - "paragraph": { - "elements": [ - { - "endIndex": 45, - "startIndex": 1, - "textRun": { - "content": "The Birth of Machine Experience Engineering\n", - "textStyle": { - "bold": True, - "fontSize": {"magnitude": 23, "unit": "PT"}, - }, - }, - } - ], - "paragraphStyle": { - "direction": "LEFT_TO_RIGHT", - "headingId": "h.wwd7ec37bh6k", - "keepLinesTogether": False, - "keepWithNext": False, - "namedStyleType": "HEADING_1", - "spaceAbove": {"magnitude": 24, "unit": "PT"}, - }, - }, - }, - { - "startIndex": 45, - "endIndex": 46, - "paragraph": { - "elements": [ - { - "startIndex": 304, - "endIndex": 305, - "inlineObjectElement": { - "inlineObjectId": "kix.2s5wy5oiaf79", - "textStyle": {}, - }, - }, - { - "endIndex": 46, - "startIndex": 45, - "textRun": {"content": "\n", "textStyle": {}}, - }, - ], - "paragraphStyle": { - "direction": "LEFT_TO_RIGHT", - "namedStyleType": "NORMAL_TEXT", - "spaceAbove": {"magnitude": 12, "unit": "PT"}, - "spaceBelow": {"magnitude": 12, "unit": "PT"}, - }, - }, - }, - { - "startIndex": 46, - "endIndex": 297, - "paragraph": { - "elements": [ - { - "startIndex": 46, - "endIndex": 146, - "textRun": { - "content": ( - "LLMs acting on behalf of humans and interacting with real-" - "world systems isn't theoretical anymore - " - ), - "textStyle": {}, - }, - }, - { - "startIndex": 146, - "endIndex": 175, - "textRun": { - "content": "Arcade has made it a reality.", - "textStyle": { - "bold": True, - "italic": True, - }, - }, - }, - { - "startIndex": 175, - "endIndex": 248, - "textRun": { - "content": ( - " With this shift, we're seeing the emergence of a new " - "software practice: " - ), - "textStyle": {}, - }, - }, - { - "startIndex": 248, - "endIndex": 295, - "textRun": { - "content": "Machine Experience Engineering (MX Engineering)", - "textStyle": { - "italic": True, - }, - }, - }, - { - "startIndex": 295, - "endIndex": 297, - "textRun": { - "content": ".\n", - "textStyle": {}, - }, - }, - ], - "paragraphStyle": { - "direction": "LEFT_TO_RIGHT", - "namedStyleType": "NORMAL_TEXT", - "spaceAbove": {"magnitude": 12, "unit": "PT"}, - "spaceBelow": {"magnitude": 12, "unit": "PT"}, - }, - }, - }, - { - "endIndex": 407, - "startIndex": 297, - "table": { - "columns": 3, - "rows": 3, - "tableRows": [ - { - "endIndex": 338, - "startIndex": 297, - "tableCells": [ - { - "content": [ - { - "endIndex": 318, - "paragraph": { - "elements": [ - { - "endIndex": 318, - "startIndex": 309, - "textRun": { - "content": "Column 1\n", - "textStyle": {"bold": True}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 309, - } - ], - "endIndex": 318, - "startIndex": 308, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 334, - "paragraph": { - "elements": [ - { - "endIndex": 326, - "startIndex": 319, - "textRun": { - "content": "Another", - "textStyle": {"italic": True}, - }, - }, - { - "endIndex": 334, - "startIndex": 326, - "textRun": { - "content": " column\n", - "textStyle": {}, - }, - }, - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 319, - } - ], - "endIndex": 334, - "startIndex": 318, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 348, - "paragraph": { - "elements": [ - { - "endIndex": 348, - "startIndex": 335, - "textRun": { - "content": "Third column\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 335, - } - ], - "endIndex": 348, - "startIndex": 334, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - ], - "tableRowStyle": {"minRowHeight": {"unit": "PT"}}, - }, - { - "endIndex": 366, - "startIndex": 348, - "tableCells": [ - { - "content": [ - { - "endIndex": 356, - "paragraph": { - "elements": [ - { - "endIndex": 356, - "startIndex": 350, - "textRun": { - "content": "Hello\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 350, - } - ], - "endIndex": 356, - "startIndex": 349, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 364, - "paragraph": { - "elements": [ - { - "endIndex": 364, - "startIndex": 357, - "textRun": { - "content": "world!\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 357, - } - ], - "endIndex": 364, - "startIndex": 356, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 366, - "paragraph": { - "elements": [ - { - "endIndex": 366, - "startIndex": 365, - "textRun": { - "content": "\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 365, - } - ], - "endIndex": 366, - "startIndex": 364, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - ], - "tableRowStyle": {"minRowHeight": {"unit": "PT"}}, - }, - { - "endIndex": 415, - "startIndex": 366, - "tableCells": [ - { - "content": [ - { - "endIndex": 388, - "paragraph": { - "elements": [ - { - "endIndex": 388, - "startIndex": 368, - "textRun": { - "content": "The quick brown fox\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 368, - } - ], - "endIndex": 388, - "startIndex": 367, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 401, - "paragraph": { - "elements": [ - { - "endIndex": 395, - "startIndex": 389, - "textRun": { - "content": "jumped", - "textStyle": {"italic": True}, - }, - }, - { - "endIndex": 401, - "startIndex": 395, - "textRun": { - "content": " over\n", - "textStyle": {}, - }, - }, - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 389, - } - ], - "endIndex": 401, - "startIndex": 388, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 415, - "paragraph": { - "elements": [ - { - "endIndex": 415, - "startIndex": 402, - "textRun": { - "content": "the lazy dog\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 402, - } - ], - "endIndex": 415, - "startIndex": 401, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - ], - "tableRowStyle": {"minRowHeight": {"unit": "PT"}}, - }, - ], - "tableStyle": { - "tableColumnProperties": [ - {"widthType": "EVENLY_DISTRIBUTED"}, - {"widthType": "EVENLY_DISTRIBUTED"}, - {"widthType": "EVENLY_DISTRIBUTED"}, - ] - }, - }, - }, - ] - }, - } - - expected_markdown = ( - "---\ntitle: The Birth of Machine Experience Engineering\ndocumentId: 1234567890\n---\n" - "# **The Birth of Machine Experience Engineering**\n" - "\n" - "LLMs acting on behalf of humans and interacting with real-world systems isn't theoretical " - "anymore - " - "**_Arcade has made it a reality._** With this shift, we're seeing the emergence of a new " - "software practice: " - "_Machine Experience Engineering (MX Engineering)_.\n" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
Column 1Another columnThird column
Helloworld!
The quick brown foxjumped overthe lazy dog
" - ) - - expected_html = ( - "" - "The Birth of Machine Experience Engineering" - '' - "" - "

The Birth of Machine Experience Engineering

" - "

LLMs acting on behalf of humans and interacting with real-world systems isn't " - "theoretical anymore - " - "Arcade has made it a reality. With this shift, we're seeing the emergence " - "of a new software practice: Machine Experience Engineering (MX Engineering).

" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
Column 1Another columnThird column
Helloworld!
The quick brown foxjumped overthe lazy dog
" - "" - ) - - return document, expected_markdown, expected_html diff --git a/toolkits/google/evals/eval_calendar_free_slots.py b/toolkits/google/evals/eval_calendar_free_slots.py deleted file mode 100644 index 2b3b98df..00000000 --- a/toolkits/google/evals/eval_calendar_free_slots.py +++ /dev/null @@ -1,470 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NoneCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.critics import AnyDatetimeCritic, DatetimeOrNoneCritic -from arcade_google.tools import find_time_slots_when_everyone_is_free - -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - - -@tool_eval() -def get_free_slots_eval_suite() -> EvalSuite: - """Create an evaluation suite for free slots Calendar tool.""" - suite = EvalSuite( - name="Calendar Tools Evaluation", - system_message=( - "You are an AI assistant that can manage calendars and events using the provided tools. " - "The first day of a week is Monday and the last day is Sunday. " - "Today is Thursday, March 6, 2025 (2025-03-06). " - "This week started on Monday, March 3, 2025 (2025-03-03) and ends on Sunday, March 9, 2025 (2025-03-09). " - "Last week started on Monday, February 24, 2025 (2025-02-24) and ended on Sunday, March 2, 2025 (2025-03-02). " - "Next week starts on Monday, March 10, 2025 (2025-03-10) and ends on Sunday, March 16, 2025 (2025-03-16). " - "This month started on March 1, 2025 (2025-03-01) and ends on March 31, 2025 (2025-03-31). " - "Last month started on February 1, 2025 (2025-02-01) and ended on February 28, 2025 (2025-02-28). " - "Next month starts on April 1, 2025 (2025-04-01) and ends on April 30, 2025 (2025-04-30). " - "This quarter started on January 1, 2025 (2025-01-01) and ends on March 31, 2025 (2025-03-31). " - "Last quarter started on December 1, 2024 (2024-12-01) and ended on December 31, 2024 (2024-12-31). " - "Next quarter starts on April 1, 2025 (2025-04-01) and ends on June 30, 2025 (2025-06-30). " - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get free slots for the next 5 days", - user_message=("At what times am I free in the next 5 days?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-06", - "end_date": "2025-03-11", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeOrNoneCritic( - critic_field="start_date", weight=0.35, tolerance=timedelta(days=1) - ), - DatetimeCritic(critic_field="end_date", weight=0.35, tolerance=timedelta(days=1)), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots for the next 10 days", - user_message=("At what times am I free in the next 10 days?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-06", - "end_date": "2025-03-16", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeOrNoneCritic( - critic_field="start_date", weight=0.35, tolerance=timedelta(days=1) - ), - DatetimeCritic(critic_field="end_date", weight=0.35, tolerance=timedelta(days=1)), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots this week", - user_message=("At what times am I free this week?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - # Models sometimes will consider today as the start range, other times it will - # consider last Monday. The question is ambiguous, so we allow both. - "start_date": ["2025-03-03", "2025-03-06"], - "end_date": "2025-03-09", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - AnyDatetimeCritic(critic_field="start_date", weight=0.35), - BinaryCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots next week", - user_message=("At what times am I free next week?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-10", - "end_date": "2025-03-16", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - BinaryCritic(critic_field="start_date", weight=0.35), - BinaryCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots today", - user_message=("At what times am I free today?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-06", - "end_date": "2025-03-06", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeOrNoneCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots today", - user_message=("At what times am I free tonight before 10 PM?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-06", - "end_date": "2025-03-06", - "start_time_boundary": "08:00", - "end_time_boundary": "22:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeOrNoneCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots this weekend", - user_message=("At what times am I free this weekend?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-08", - "end_date": "2025-03-09", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots this month", - user_message=("At what times am I free this month?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": ["2025-03-06", "2025-03-01"], - "end_date": "2025-03-31", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - AnyDatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots next month", - user_message=("At what times am I free next month?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-04-01", - "end_date": "2025-04-30", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots last week", - user_message=("At what times was I free last week?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-02-24", - "end_date": "2025-03-02", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots next quarter", - user_message=("At what times am I free next quarter?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-04-01", - "end_date": "2025-06-30", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots in the next 30 days", - user_message=("At what times am I free in the next 30 days?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-03-06", - "end_date": "2025-04-05", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeOrNoneCritic( - critic_field="start_date", weight=0.35, tolerance=timedelta(days=1) - ), - DatetimeCritic(critic_field="end_date", weight=0.35, tolerance=timedelta(days=1)), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots in April", - user_message=("At what times am I free in April?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-04-01", - "end_date": "2025-04-30", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots for a specific email address", - user_message=("Is johndoe@example.com free some time tomorrow?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": ["johndoe@example.com"], - "start_date": "2025-03-07", - "end_date": "2025-03-07", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="email_addresses", weight=0.30), - DatetimeCritic(critic_field="start_date", weight=0.25), - DatetimeCritic(critic_field="end_date", weight=0.25), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots for a specific email address", - user_message=( - "I need to schedule a meeting with johndoe@example.com tomorrow. When are both of us free?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": ["johndoe@example.com"], - "start_date": "2025-03-07", - "end_date": "2025-03-07", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="email_addresses", weight=0.3), - DatetimeCritic(critic_field="start_date", weight=0.25), - DatetimeCritic(critic_field="end_date", weight=0.25), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - suite.add_case( - name="Get free slots for a specific email address", - user_message=( - "I need to schedule a meeting with johndoe@example.com tomorrow morning. When are both of us free?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": ["johndoe@example.com"], - "start_date": "2025-03-07", - "end_date": "2025-03-07", - "start_time_boundary": "08:00", - "end_time_boundary": "12:00", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="email_addresses", weight=0.2), - DatetimeCritic(critic_field="start_date", weight=0.2), - DatetimeCritic(critic_field="end_date", weight=0.2), - BinaryCritic(critic_field="start_time_boundary", weight=0.2), - BinaryCritic(critic_field="end_time_boundary", weight=0.2), - ], - ) - - suite.add_case( - name="Get free slots for a specific date range", - user_message=("At what times am I free between 2025-04-27 and 2025-04-29?"), - expected_tool_calls=[ - ExpectedToolCall( - func=find_time_slots_when_everyone_is_free, - args={ - "email_addresses": None, - "start_date": "2025-04-27", - "end_date": "2025-04-29", - "start_time_boundary": "08:00", - "end_time_boundary": "18:00", - }, - ) - ], - critics=[ - NoneCritic(critic_field="email_addresses", weight=0.1), - DatetimeCritic(critic_field="start_date", weight=0.35), - DatetimeCritic(critic_field="end_date", weight=0.35), - BinaryCritic(critic_field="start_time_boundary", weight=0.1), - BinaryCritic(critic_field="end_time_boundary", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/google/evals/eval_file_picker.py b/toolkits/google/evals/eval_file_picker.py deleted file mode 100644 index 713cee11..00000000 --- a/toolkits/google/evals/eval_file_picker.py +++ /dev/null @@ -1,105 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.tools import generate_google_file_picker_url - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - -history_1 = [ - { - "role": "user", - "content": "get the contents of my spreadsheet named 'How to teach an old dog a new trick'", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_errutwjcpZLiHu8LKip6XKLq", - "type": "function", - "function": { - "name": "Google_SearchDocuments", - "arguments": '{"document_contains":["How to teach an old dog a new trick"],"limit":5}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"documents":[],"documents_count":0}', - "tool_call_id": "call_errutwjcpZLiHu8LKip6XKLq", - "name": "Google_SearchDocuments", - }, - { - "role": "assistant", - "content": "It seems that I don't have access to your spreadsheet named \"How to teach an old dog a new trick.\" If you need help retrieving the data, you can check your cloud storage or computer files where the spreadsheet might be saved, such as Google Sheets, Microsoft Excel, or other spreadsheet software. Let me know if there's anything specific you'd like assistance with regarding the content!", - }, -] - -history_2 = [ - {"role": "system", "content": "Today is 2025-04-15, Tuesday."}, - { - "role": "user", - "content": "I got this error from you: \n❌ Arcade Chat failed with error: Error code: 500 - {'name':'non_retryable_tool_call_error', 'message': 'tool call failed - \nGoogle.InsertTextAtEndOfDocument failed: Error inexecution of GetDocumentById: Error in get_document_by_id: '}", - }, - { - "role": "assistant", - "content": "The error message indicates that there was an attempt to retrieve a document with a specific ID \\(`192394932924`) from Google Docs, but the document could not be found. The HTTP 404 error code means that the document doesn't \\exist or is not accessible with the current permissions. Here's what you can do:\n\n1. **Check Document ID:** Make sure the \\document ID (`192394932924`) is correct. A typo or incorrect ID will lead to this error.\n\n2. **Permissions:** Ensure that you \\have permission to access the document. If the document is owned by someone else or is in a shared drive with restricted access,\\you might not be able to view it.\n\n3. **Document Existence:** Double-check that the document hasn't been deleted or moved to \the trash or a location you don't have access to.\n\nLet me know if there's anything else you'd like to address or any other \assistance you need!", - }, -] - - -@tool_eval() -def file_picker_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail tools.""" - suite = EvalSuite( - name="File Picker Tools Evaluation", - system_message="You are an AI assistant that can perform actions for the user using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get file picker url 1", - user_message="I am certain that the spreadsheet exists", - expected_tool_calls=[ - ExpectedToolCall( - func=generate_google_file_picker_url, - args={}, - ) - ], - additional_messages=history_1, - ) - - suite.add_case( - name="Get file picker url 2", - user_message="Please help in anyway you can. I'm literally looking at the document right now in the browser.", - expected_tool_calls=[ - ExpectedToolCall(func=generate_google_file_picker_url, args={}), - ], - additional_messages=history_2, - ) - - suite.add_case( - name="Get file picker url 3", - user_message="I want to give you permissions to more files in my Drive right now.", - expected_tool_calls=[ - ExpectedToolCall(func=generate_google_file_picker_url, args={}), - ], - ) - - return suite diff --git a/toolkits/google/evals/eval_google_calendar.py b/toolkits/google/evals/eval_google_calendar.py deleted file mode 100644 index e804161e..00000000 --- a/toolkits/google/evals/eval_google_calendar.py +++ /dev/null @@ -1,215 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.models import EventVisibility, SendUpdatesOptions -from arcade_google.tools import ( - create_event, - delete_event, - list_calendars, - list_events, - update_event, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - -history_after_list_events = [ - {"role": "user", "content": "do i have any events on my calendar for today?"}, - { - "role": "assistant", - "content": "Please go to this URL and authorize the action: \n[Link](https://accounts.google.com/o/oauth2/v2/auth?)", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_uHdRlr4z7sFm39ZrPsE5wcdT", - "type": "function", - "function": { - "name": "Google_ListEvents", - "arguments": '{"min_end_datetime":"2024-09-26T00:00:00-07:00","max_start_datetime":"2024-09-27T00:00:00-07:00"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"events_count": 3, "events": [{"creator": {"email": "john@example.com", "self": true}, "description": "1:1 meeting with Joe", "end": {"dateTime": "2024-09-26T00:15:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=01234", "id": "10009199283838467", "location": "622 Rainbow Ave, South San Francisco, CA 94080, USA", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-25T23:15:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "1:1 meeting"}, {"attendees": [{"email": "joe@example.com", "responseStatus": "accepted"}], "creator": {"email": "john@example.com", "self": true}, "description": "This is just a test", "end": {"dateTime": "2024-09-26T14:00:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=OXB2OGFwcmZraWk1N234324", "id": "00099992228181818181", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-26T12:00:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "API test"}, {"attendees": [{"email": "henry@example.com", "responseStatus": "needsAction"}], "creator": {"email": "john@example.com", "self": true}, "end": {"dateTime": "2024-09-26T17:00:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=Z3I1ZzE4b324534556", "id": "gr5g18lf88tfpp3vkareukkc7g", "location": "611 Rainbow Road", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-26T15:00:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "Focus Time", "visibility": "public"}]}', - "tool_call_id": "call_uHdRlr4z7sFm39ZrPsE5wcdT", - "name": "Google_ListEvents", - }, - { - "role": "assistant", - "content": "Yes, you have three events on your calendar for today:\n\n1. **Event:** Test2\n - **Time:** 23:15 - 00:15 (PST)\n - **Location:** 611 Gateway Blvd, South San Francisco, CA 94080, USA\n - **Description:** 1:1 meeting with Joe\n 2. **Event:** API Test\n - **Time:** 12:00 - 14:00 (PST)\n **Description:** This is just a test\n - [View Event](https://www.google.com/calendar/event?eid=OXB2OGFwcmZraWk1NGUwa24xaTNya2lvZDggZXJpY0BhcmNhZGUtYWkuY29t)\n\n3. **Event:** Focus Time\n - **Time:** 15:00 - 17:00 (PST)\n - **Location:** 611 Rainbow Road\n - **Visibility:** Public\n - [View Event](https://www.google.com/calendar/event?eid=Z3I1ZzE4bGY4OHRmcHAzdmthcmV1a2tjN2cgZXJpY0BhcmNhZGUtYWkuY29t)\n\nIf you need more details or help with anything else, feel free to ask!", - }, -] - - -@tool_eval() -def calendar_eval_suite() -> EvalSuite: - """Create an evaluation suite for Calendar tools.""" - suite = EvalSuite( - name="Calendar Tools Evaluation", - system_message=( - "You are an AI assistant that can create, list, update, and delete events using the provided tools. Today is 2024-09-26" - ), - catalog=catalog, - rubric=rubric, - ) - - # Cases for list_calendars - suite.add_case( - name="List Calendars", - user_message=("What calendars do I have?"), - expected_tool_calls=[ - ExpectedToolCall( - func=list_calendars, - args={}, - ) - ], - critics=[], - ) - - # Cases for create_event - suite.add_case( - name="Create calendar event", - user_message=( - "Create a meeting for 'Team Meeting' starting on September 26, 2024, from 11:45pm to 12:15am. Invite johndoe@example.com" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_event, - args={ - "summary": "Team Meeting", - "start_datetime": "2024-09-26T23:45:00", - "end_datetime": "2024-09-27T00:15:00", - "calendar_id": "primary", - "attendee_emails": ["johndoe@example.com"], - "visibility": EventVisibility.DEFAULT, - "description": "Team Meeting", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="summary", weight=0.2), - DatetimeCritic( - critic_field="start_datetime", weight=0.2, tolerance=timedelta(seconds=10) - ), - DatetimeCritic( - critic_field="end_datetime", weight=0.2, tolerance=timedelta(seconds=10) - ), - BinaryCritic(critic_field="attendee_emails", weight=0.2), - BinaryCritic(critic_field="description", weight=0.1), - BinaryCritic(critic_field="location", weight=0.1), - ], - ) - - # Cases for list_events - suite.add_case( - name="List calendar events", - user_message="Do I have any events on my calendar today?", - expected_tool_calls=[ - ExpectedToolCall( - func=list_events, - args={ - "min_end_datetime": "2024-09-26T00:00:00", - "max_start_datetime": "2024-09-27T00:00:00", - "calendar_id": "primary", - "max_results": 10, - }, - ) - ], - critics=[ - DatetimeCritic( - critic_field="min_end_datetime", weight=0.3, tolerance=timedelta(hours=1) - ), - DatetimeCritic( - critic_field="max_start_datetime", weight=0.3, tolerance=timedelta(hours=1) - ), - BinaryCritic(critic_field="calendar_id", weight=0.2), - BinaryCritic(critic_field="max_results", weight=0.2), - ], - ) - - # Cases for update_event - suite.add_case( - name="Update a calendar event", - user_message=( - "Oh no! I can't make it to the API Test since I have lunch with an old friend at that time. " - "Change my meeting tomorrow at 3pm to 4pm. Let everyone know." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=update_event, - args={ - "event_id": "00099992228181818181", - "updated_start_datetime": "2024-09-27T16:00:00", - "updated_end_datetime": "2024-09-27T18:00:00", - "updated_calendar_id": "primary", - "updated_summary": "API Test", - "updated_description": "API Test", - "updated_location": "611 Gateway Blvd", - "updated_visibility": EventVisibility.DEFAULT, - "attendee_emails_to_add": None, - "attendee_emails_to_remove": None, - "send_updates": SendUpdatesOptions.ALL, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="event_id", weight=0.4), - DatetimeCritic( - critic_field="updated_start_datetime", weight=0.2, tolerance=timedelta(minutes=15) - ), - DatetimeCritic( - critic_field="updated_end_datetime", - weight=0.2, - tolerance=timedelta(minutes=15), - ), - BinaryCritic(critic_field="send_updates", weight=0.2), - ], - additional_messages=history_after_list_events, - ) - - # Cases for delete_event - suite.add_case( - name="Delete a calendar event", - user_message=( - "I don't need to have focus time today. Please delete it from my calendar. Don't send any notifications." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=delete_event, - args={ - "event_id": "gr5g18lf88tfpp3vkareukkc7g", - "calendar_id": "primary", - "send_updates": SendUpdatesOptions.NONE, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="event_id", weight=0.6), - BinaryCritic(critic_field="calendar_id", weight=0.2), - BinaryCritic(critic_field="send_updates", weight=0.2), - ], - additional_messages=history_after_list_events, - ) - - return suite diff --git a/toolkits/google/evals/eval_google_contacts.py b/toolkits/google/evals/eval_google_contacts.py deleted file mode 100644 index 191250ed..00000000 --- a/toolkits/google/evals/eval_google_contacts.py +++ /dev/null @@ -1,135 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.tools import ( - create_contact, - search_contacts_by_email, - search_contacts_by_name, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - - -@tool_eval() -def contacts_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Contacts tools.""" - suite = EvalSuite( - name="Google Contacts Tools Evaluation", - system_message="You are an AI assistant that can manage Google Contacts using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search contacts by name", - user_message="Find my contact Bob", - expected_tool_calls=[ - ExpectedToolCall( - func=search_contacts_by_name, - args={ - "name": "Bob", - }, - ) - ], - ) - - suite.add_case( - name="Search contacts by email", - user_message="Find my contact alice@example.com", - expected_tool_calls=[ - ExpectedToolCall( - func=search_contacts_by_email, - args={ - "email": "alice@example.com", - }, - ) - ], - ) - - suite.add_case( - name="Search contacts with query and limit", - user_message="Find 5 contacts whose names include 'Alice'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_contacts_by_name, - args={ - "name": "Alice", - "limit": 5, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="Create new contact with only given name", - user_message="Create a new contact for Alice", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "given_name": "Alice", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="given_name", weight=1.0), - ], - ) - - suite.add_case( - name="Create new contact with only email (infer name from email)", - user_message="Create a new contact for alice@example.com", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "given_name": "Alice", - "email": "alice@example.com", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="email", weight=0.5), - BinaryCritic(critic_field="given_name", weight=0.5), - ], - ) - - suite.add_case( - name="Create new contact with full name and email", - user_message="Create a contact for Bob Smith (bob.smith@example.com)", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "given_name": "Bob", - "family_name": "Smith", - "email": "bob.smith@example.com", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="given_name", weight=0.33), - BinaryCritic(critic_field="family_name", weight=0.33), - BinaryCritic(critic_field="email", weight=0.34), - ], - ) - - return suite diff --git a/toolkits/google/evals/eval_google_docs.py b/toolkits/google/evals/eval_google_docs.py deleted file mode 100644 index bcd3125e..00000000 --- a/toolkits/google/evals/eval_google_docs.py +++ /dev/null @@ -1,186 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.tools import ( - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - - -@tool_eval() -def docs_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Docs tools.""" - suite = EvalSuite( - name="Google Docs Tools Evaluation", - system_message="You are an AI assistant that can create and manage Google Docs using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # A previous tool call to list_documents - additional_messages = [ - {"role": "user", "content": "list my 10 most recently created docs"}, - { - "role": "assistant", - "content": "Please go to this URL and authorize the action: [Link](https://accounts.google.com/)", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_gegK723W2hXsORjBmq1Oexqk", - "type": "function", - "function": { - "name": "Google_ListDocuments", - "arguments": '{"limit":10,"order_by":"createdTime desc"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"documents":[{"id":"1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst10"},{"id":"1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst9"},{"id":"19Dqugn0rVi89K0C__lpg1HbhQOTenccyZOhPgivTHMs","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst8"},{"id":"1RCibzx14eqP3vS9yI4nD13OKf8Vee56RiszS53OkR7I","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst7"},{"id":"1imFb04JQuBn8SiSsRFf6fEuYCyXkbII4KX8fsmnT0jo","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst6"},{"id":"1ZC3oypdfLWFgBd-emeSykJf9tZOae6USsFboygRCr-w","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst5"},{"id":"1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst4"},{"id":"1eQ8UBO_PY3Lem4R8OVdIc9ODXt0MrSUAnEu994Qz8P8","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst3"},{"id":"1TOxB0MLry-JzntDWDT1LFywTLdr3XDWPT5L5UsHMs5c","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst2"},{"id":"1a1UQ7C90s8kGfnO8k6wfAZz_Cy5nGN2MkCoRB5y2j3w","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst1"}],"documents_count":10}', - "tool_call_id": "call_gegK723W2hXsORjBmq1Oexqk", - "name": "Google_ListDocuments", - }, - { - "role": "assistant", - "content": "Here are your 10 most recently created Google Docs:\n\n1. [Tst10](https://docs.google.com/document/d/1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc)\n2. [Tst9](https://docs.google.com/document/d/1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts)\n3. [Tst8](https://docs.google.com/document/d/19Dqugn0rVi89K0C__lpg1HbhQOTenccyZOhPgivTHMs)\n4. [Tst7](https://docs.google.com/document/d/1RCibzx14eqP3vS9yI4nD13OKf8Vee56RiszS53OkR7I)\n5. [Tst6](https://docs.google.com/document/d/1imFb04JQuBn8SiSsRFf6fEuYCyXkbII4KX8fsmnT0jo)\n6. [Tst5](https://docs.google.com/document/d/1ZC3oypdfLWFgBd-emeSykJf9tZOae6USsFboygRCr-w)\n7. [Tst4](https://docs.google.com/document/d/1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc)\n8. [Tst3](https://docs.google.com/document/d/1eQ8UBO_PY3Lem4R8OVdIc9ODXt0MrSUAnEu994Qz8P8)\n9. [Tst2](https://docs.google.com/document/d/1TOxB0MLry-JzntDWDT1LFywTLdr3XDWPT5L5UsHMs5c)\n10. [Tst1](https://docs.google.com/document/d/1a1UQ7C90s8kGfnO8k6wfAZz_Cy5nGN2MkCoRB5y2j3w)\n\nYou can click the links to open each document.", - }, - ] - - suite.add_case( - name="Get document content", - user_message="Can you read me the contents of Tst9 doc and also Tst10 doc please", - expected_tool_calls=[ - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts", - }, - ), - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="document_id", weight=0.6), - ], - additional_messages=additional_messages, - ) - - suite.add_case( - name="Insert text at end of document", - user_message="Please add the text 'This is a new paragraph.' to the end of Tst4.", - expected_tool_calls=[ - ExpectedToolCall( - func=insert_text_at_end_of_document, - args={ - "document_id": "1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc", - "text_content": "This is a new paragraph.", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_id", weight=0.5), - SimilarityCritic(critic_field="text_content", weight=0.5), - ], - additional_messages=additional_messages, - ) - - suite.add_case( - name="Read the contents of two documents and then insert text at end of a different document.", - user_message="Can you read me the contents of Tst9 doc and also Tst10 doc please. Also, please add the text 'This is a new paragraph.' to the end of Tst4.", - expected_tool_calls=[ - ExpectedToolCall( - func=insert_text_at_end_of_document, - args={ - "document_id": "1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc", - "text_content": "This is a new paragraph.", - }, - ), - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts", - }, - ), - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="document_id", weight=0.3), - SimilarityCritic(critic_field="text_content", weight=0.3), - ], - additional_messages=additional_messages, - ) - - suite.add_case( - name="Create blank document", - user_message="Create a new Doc titled 'Meeting Notes'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_blank_document, - args={ - "title": "Meeting Notes", - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="title", weight=1.0), - ], - ) - - suite.add_case( - name="Create document from text", - user_message="Create a new doc called To-Do List with the content 'Buy groceries, Call mom, Finish report'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_document_from_text, - args={ - "title": "To-Do List", - "text_content": "Buy groceries\nCall mom\nFinish report", - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="title", weight=0.5), - SimilarityCritic(critic_field="text_content", weight=0.5), - ], - ) - - suite.add_case( - name="No tool call case", - user_message="Create a new microsoft word document titled 'My Resume'.", - expected_tool_calls=[], - critics=[], - ) - - return suite diff --git a/toolkits/google/evals/eval_google_drive.py b/toolkits/google/evals/eval_google_drive.py deleted file mode 100644 index 00eba7f8..00000000 --- a/toolkits/google/evals/eval_google_drive.py +++ /dev/null @@ -1,329 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.models import DocumentFormat, OrderBy -from arcade_google.tools import ( - get_file_tree_structure, - search_and_retrieve_documents, - search_documents, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - - -@tool_eval() -def get_file_tree_structure_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="get my google drive's file tree structure including shared drives", - user_message="get my google drive's file tree structure including shared drives", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": True, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - suite.add_case( - name="get my google drive's file tree structure without shared drives", - user_message="get my google drive's file tree structure without shared drives", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": False, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - suite.add_case( - name="what are the files in the folder 'hello world' in my google drive?", - user_message="what are the files in the folder 'hello world' in my google drive?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": False, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - suite.add_case( - name="how many files are there in all my google drives, including shared ones?", - user_message="how many files are there in all my google drives, including shared ones?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": True, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - return suite - - -@tool_eval() -def search_documents_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search documents in Google Drive", - user_message="get my 49 most recently created documents, list the ones created most recently first.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "order_by": [OrderBy.CREATED_TIME_DESC.value], - "limit": 49, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="order_by", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="Search documents in Google Drive based on document keywords", - user_message="Search the documents that contain the word 'greedy' and the phrase 'hello, world'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "document_contains": ["greedy", "hello, world"], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=1.0), - ], - ) - - suite.add_case( - name="Search documents in a specific Google Drive based on document keywords", - user_message="Search the documents that contain the word 'greedy' and the phrase 'hello, world' in the drive with id 'abc123'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "document_contains": ["greedy", "hello, world"], - "search_only_in_shared_drive_id": "abc123", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="search_only_in_shared_drive_id", weight=0.5), - BinaryCritic(critic_field="document_contains", weight=0.5), - ], - ) - - suite.add_case( - name="Search documents in a Google Drive Workspace organization domain based on document keywords", - user_message="Search the documents that contain the phrase 'hello, world' in the organization domain", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "document_contains": ["hello, world"], - "include_organization_domain_documents": True, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5), - BinaryCritic(critic_field="document_contains", weight=0.5), - ], - ) - - suite.add_case( - name="Search documents in shared drives", - user_message="Search the 5 documents from all drives corpora that nobody has touched in forever, excluding shared drives.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "limit": 5, - "include_shared_drives": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="No tool call case", - user_message="List my 10 most recently modified documents that are stored in my Microsoft OneDrive.", - expected_tool_calls=[], - critics=[], - ) - - return suite - - -@tool_eval() -def search_and_retrieve_documents_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive search and retrieve tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search and retrieve (write summary)", - user_message="Write a summary of the documents in my Google Drive about 'MX Engineering'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["MX Engineering"], - "return_format": DocumentFormat.MARKDOWN, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=0.5), - BinaryCritic(critic_field="return_format", weight=0.5), - ], - ) - - suite.add_case( - name="Search and retrieve (project proposal)", - user_message="Display the document contents in HTML format from my Google Drive that contain the phrase 'project proposal'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["project proposal"], - "return_format": DocumentFormat.HTML, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=0.5), - BinaryCritic(critic_field="return_format", weight=0.5), - ], - ) - - suite.add_case( - name="Search and retrieve (meeting notes)", - user_message="Retrieve documents that contain both 'meeting notes' and 'budget' in JSON format.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["meeting notes", "budget"], - "return_format": DocumentFormat.GOOGLE_API_JSON, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=0.5), - BinaryCritic(critic_field="return_format", weight=0.5), - ], - ) - - suite.add_case( - name="Search and retrieve (Q1 report)", - user_message="Show me the content of the documents that mention 'Q1 report' but do not include the expression 'Project XYZ'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["Q1 report"], - "document_not_contains": ["Project XYZ"], - "return_format": DocumentFormat.MARKDOWN, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=1 / 3), - BinaryCritic(critic_field="document_not_contains", weight=1 / 3), - BinaryCritic(critic_field="return_format", weight=1 / 3), - ], - ) - - return suite diff --git a/toolkits/google/evals/eval_google_gmail.py b/toolkits/google/evals/eval_google_gmail.py deleted file mode 100644 index 4f320f59..00000000 --- a/toolkits/google/evals/eval_google_gmail.py +++ /dev/null @@ -1,431 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.models import GmailReplyToWhom -from arcade_google.tools import ( - get_thread, - list_emails_by_header, - list_threads, - reply_to_email, - search_threads, - send_email, - write_draft_reply_email, -) -from arcade_google.utils import DateRange - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - - -@tool_eval() -def gmail_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail tools.""" - suite = EvalSuite( - name="Gmail Tools Evaluation", - system_message="You are an AI assistant that can send and manage emails using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Send email to user with clear username", - user_message="Send a email to johndoe@example.com saying 'Hello, can we meet at 3 PM?'. CC his boss janedoe@example.com", - expected_tool_calls=[ - ExpectedToolCall( - func=send_email, - args={ - "subject": "Meeting Request", - "body": "Hello, can we meet at 3 PM?", - "recipient": "johndoe@example.com", - "cc": ["janedoe@example.com"], - "bcc": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=0.125), - SimilarityCritic(critic_field="body", weight=0.25), - BinaryCritic(critic_field="recipient", weight=0.25), - BinaryCritic(critic_field="cc", weight=0.25), - BinaryCritic(critic_field="bcc", weight=0.125), - ], - ) - - suite.add_case( - name="Simple list threads", - user_message="Get 42 threads like right now i even wanna see the ones in my trash", - expected_tool_calls=[ - ExpectedToolCall( - func=list_threads, - args={"max_results": 42, "include_spam_trash": True}, - ) - ], - critics=[ - BinaryCritic(critic_field="max_results", weight=0.5), - BinaryCritic(critic_field="include_spam_trash", weight=0.5), - ], - ) - - history = [ - {"role": "user", "content": "list 1 thread"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_X8V5Hw9iJ3wfB8WMZf8omAMi", - "type": "function", - "function": {"name": "Google_ListThreads", "arguments": '{"max_results":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"next_page_token":"10321400718999360131","num_threads":1,"threads":[{"historyId":"61691","id":"1934a8f8deccb749","snippet":"Hi Joe, I hope this email finds you well. Thank you for being a part of our community."}]}', - "tool_call_id": "call_X8V5Hw9iJ3wfB8WMZf8omAMi", - "name": "Google_ListThreads", - }, - { - "role": "assistant", - "content": "Here is one email thread:\n\n- **Snippet:** Hi Joe, I hope this email finds you well. Thank you for being a part of our community.\n- **Thread ID:** 1934a8f8deccb749\n- **History ID:** 61691", - }, - ] - suite.add_case( - name="List threads with history", - user_message="Get the next 5 threads", - additional_messages=history, - expected_tool_calls=[ - ExpectedToolCall( - func=list_threads, - args={ - "max_results": 5, - "page_token": "10321400718999360131", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="max_results", weight=0.2), - BinaryCritic(critic_field="page_token", weight=0.8), - ], - ) - - suite.add_case( - name="Search threads", - user_message="Search for threads from johndoe@example.com to janedoe@example.com about that talk about 'Arcade AI' from yesterday", - expected_tool_calls=[ - ExpectedToolCall( - func=search_threads, - args={ - "sender": "johndoe@example.com", - "recipient": "janedoe@example.com", - "body": "Arcade AI", - "date_range": DateRange.YESTERDAY, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="sender", weight=0.25), - BinaryCritic(critic_field="recipient", weight=0.25), - SimilarityCritic(critic_field="body", weight=0.25), - BinaryCritic(critic_field="date_range", weight=0.25), - ], - ) - - suite.add_case( - name="Get a thread by ID", - user_message="Get the thread r-124325435467568867667878874565464564563523424323524235242412", - expected_tool_calls=[ - ExpectedToolCall( - func=get_thread, - args={ - "thread_id": "r-124325435467568867667878874565464564563523424323524235242412", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="thread_id", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def gmail_reply_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail reply tools.""" - suite = EvalSuite( - name="Gmail Reply Tools Evaluation", - system_message="You are an AI assistant that can send and manage emails using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - email_history = [ - {"role": "user", "content": "get the latest emails I received from johndoe@gmail.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_jowMD7aB9sVPClOfvNof7Llu", - "type": "function", - "function": { - "name": "Google_ListEmailsByHeader", - "arguments": json.dumps({ - "sender": "johndoe@gmail.com", - "max_results": 5, - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "emails": [ - { - "body": "test 1", - "cc": "", - "date": "Tue, 11 Feb 2025 11:33:08 -0300", - "from": "John Doe ", - "header_message_id": "", - "history_id": "123456", - "id": "q34759q435nv", - "in_reply_to": "", - "label_ids": ["INBOX"], - "references": "", - "reply_to": "", - "snippet": "test 1", - "subject": "test 1", - "thread_id": "345y6v3596", - "to": "myself@gmail.com", - }, - { - "body": "test 2", - "cc": "", - "date": "Mon, 20 Jan 2025 13:04:42 -0800", - "from": "John Doe ", - "header_message_id": "<28745ytvw8745ct4@mail.gmail.com>", - "history_id": "3456758", - "id": "9475tvy24578yx", - "in_reply_to": "", - "label_ids": [], - "references": "", - "reply_to": "", - "snippet": "test 2", - "subject": "test 2", - "thread_id": "249576v3496", - "to": "myself@gmail.com", - }, - ] - }), - "tool_call_id": "call_jowMD7aB9sVPClOfvNof7Llu", - "name": "Google_ListEmailsByHeader", - }, - { - "role": "assistant", - "content": "Here are the latest emails you received from johndoe@gmail.com:\n\n1. **Subject**: test 1\n - **Date**: Tue, 11 Feb 2025 11:33:08 -0300\n - **Snippet**: test 1\n\n2. **Subject**: test 2\n - **Date**: Mon, 20 Jan 2025 13:04:42 -0800\n - **Snippet**: test 2\n\nIf you need further details from any specific email, let me know!", - }, - ] - - suite.add_case( - name="Reply to an email", - user_message="Reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Reply to an email with every recipient", - user_message="Reply to every recipient in the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.EVERY_RECIPIENT.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Reply to an email with bcc", - user_message="Reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well' and send it to janedoe@example.com as bcc as well", - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "bcc": ["janedoe@example.com"], - "reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Write draft reply", - user_message="Write a draft reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=write_draft_reply_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=1 / 7), - ], - additional_messages=email_history, - ) - - suite.add_case( - name="Write draft reply to every recipient", - user_message="Write a draft reply to every recipient in the email from johndoe@example.com about 'test 2' saying 'tested and working well'", - expected_tool_calls=[ - ExpectedToolCall( - func=write_draft_reply_email, - args={ - "reply_to_message_id": "9475tvy24578yx", - "body": "tested and working well", - "reply_to_whom": GmailReplyToWhom.EVERY_RECIPIENT.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 7), - SimilarityCritic(critic_field="body", weight=1 / 7), - BinaryCritic(critic_field="recipient", weight=1 / 7), - BinaryCritic(critic_field="cc", weight=1 / 7), - BinaryCritic(critic_field="bcc", weight=1 / 7), - BinaryCritic(critic_field="reply_to_whom", weight=0.125), - BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7), - ], - additional_messages=email_history, - ) - - return suite - - -@tool_eval() -def gmail_list_emails_by_header_eval_suite() -> EvalSuite: - """Create an evaluation suite for Gmail tools.""" - suite = EvalSuite( - name="Gmail list_emails_by_header tool evaluation", - system_message="You are an AI assistant that can send and manage emails using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List emails by header using date-range", - user_message="List all emails from johndoe@example.com to janedoe@example.com about 'Arcade AI' from yesterday", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_header, - args={ - "sender": "johndoe@example.com", - "recipient": "janedoe@example.com", - "subject": "Arcade AI", - "date_range": DateRange.YESTERDAY.value, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="sender", weight=1 / 4), - BinaryCritic(critic_field="recipient", weight=1 / 4), - SimilarityCritic(critic_field="subject", weight=1 / 4), - BinaryCritic(critic_field="date_range", weight=1 / 4), - ], - ) - - suite.add_case( - name="List emails by header using date-range", - user_message="List all emails from johndoe@example.com to janedoe@example.com about 'Arcade AI' from the last month", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_header, - args={ - "sender": "johndoe@example.com", - "recipient": "janedoe@example.com", - "subject": "Arcade AI", - "date_range": DateRange.LAST_MONTH.value, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="sender", weight=1 / 4), - BinaryCritic(critic_field="recipient", weight=1 / 4), - SimilarityCritic(critic_field="subject", weight=1 / 4), - BinaryCritic(critic_field="date_range", weight=1 / 4), - ], - ) - - return suite diff --git a/toolkits/google/evals/eval_google_sheets.py b/toolkits/google/evals/eval_google_sheets.py deleted file mode 100644 index 9b377ef7..00000000 --- a/toolkits/google/evals/eval_google_sheets.py +++ /dev/null @@ -1,169 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google -from arcade_google.tools import ( - create_spreadsheet, - get_spreadsheet, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google) - -sheet_content_prompt = """name age email score gender city country registration_date -John Doe 28 johndoe@example.com 85 Male New York USA 2023-01-15 -Jane Smith 34 janesmith@example.com 92 Female Los Angeles USA 2023-02-20 -Alice Johnson 22 alicej@example.com 78 Female Chicago USA 2023-03-10 -Bob Brown 45 bobbrown@example.com 88 Male Houston USA 2023-04-05 -Charlie Davis 30 charlied@example.com 95 Male Phoenix USA 2023-05-12 -Eve White 27 evewhite@example.com 82 Female Philadelphia USA 2023-06-18 -Frank Black 40 frankb@example.com 90 Male San Antonio USA 2023-07-25 -Grace Green 29 graceg@example.com 76 Female Dallas USA 2023-08-30 -Hank Blue 35 hankb@example.com 89 Male San Diego USA 2023-09-15 -Ivy Red 31 ivyred@example.com 91 Female San Jose USA 2023-10-01 -Michael Grey 33 michaelg@example.com 87 Male Seattle USA 2023-10-05 -Nina Black 26 ninab@example.com 84 Female Miami USA 2023-10-10 -Oscar White 38 oscarw@example.com 90 Male Atlanta USA 2023-10-15 -Paula Green 32 paulag@example.com 93 Female Boston USA 2023-10-20 -Quentin Brown 29 quentinb@example.com 81 Male Denver USA 2023-10-25 -Rachel Blue 24 rachelb@example.com 79 Female Orlando USA 2023-10-30 -Steve Red 36 stever@example.com 88 Male Las Vegas USA 2023-11-01 -Tina Yellow 30 tinay@example.com 85 Female Portland USA 2023-11-05 -Ursula Pink 27 ursulap@example.com 82 Female San Francisco USA 2023-11-10 -Victor Grey 41 victorg@example.com 91 Male Charlotte USA 2023-11-15 -Wendy Black 34 wendyb@example.com 89 Female Detroit USA 2023-11-20 -Xander White 29 xanderw@example.com 86 Male Indianapolis USA 2023-11-25 -Yvonne Green 25 yvonnag@example.com 83 Female Columbus USA 2023-11-30 -Zachary Blue 37 zacharyb@example.com 90 Male Jacksonville USA 2023-12-01 -Alice Brown 28 aliceb@example.com 80 Female Memphis USA 2023-12-05 -Brian Black 39 brianb@example.com 92 Male Nashville USA 2023-12-10 -Cathy Green 31 cathyg@example.com 84 Female Virginia Beach USA 2023-12-15 -Daniel White 30 danielw@example.com 88 Male Atlanta USA 2023-12-20 -Eva Red 26 evar@example.com 81 Female New Orleans USA 2023-12-25 -Frankie Grey 35 frankieg@example.com 90 Male San Antonio USA 2023-12-30 -Gina Blue 29 ginab@example.com 87 Female San Diego USA 2024-01-01 -Henry Black 42 henryb@example.com 93 Male Philadelphia USA 2024-01-05 -Isla Green 24 islag@example.com 79 Female Chicago USA 2024-01-10 -Jack White 33 jackw@example.com 85 Male Los Angeles USA 2024-01-15 -Kathy Red 31 kathyr@example.com 82 Female Miami USA 2024-01-20 -Liam Grey 36 liamg@example.com 89 Male Seattle USA 2024-01-25 -Mia Black 27 miab@example.com 80 Female Denver USA 2024-01-30 -Nate Green 30 nateg@example.com 88 Male Orlando USA 2024-02-01 -- (empty row) -- (empty row) -- (empty row) -100, 300, 234, 399, 5039, 2345, 23526, 123, 54, 234, 54, 23, 12, 57, 1324, (the formula for sum of everything to the left) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -456, 234, 234, 399, 234, 1234, 23526, 123, 54, 234, 4567, 23, 12, 234, 1324, (the formula for sum of everything to the left) -""" - - -@tool_eval() -def create_spreadsheet_eval() -> EvalSuite: - """Create an evaluation suite for Google Sheets create_spreadsheet tool.""" - - sheet_content_expected1 = """{"1": {"A": "name", "B": "age", "C": "email", "D": "score", "E": "gender", "F": "city", "G": "country", "H": "registration_date"}, "2": {"A": "John Doe", "B": 28, "C": "johndoe@example.com", "D": 85, "E": "Male", "F": "New York", "G": "USA", "H": "2023-01-15"}, "3": {"A": "Jane Smith", "B": 34, "C": "janesmith@example.com", "D": 92, "E": "Female", "F": "Los Angeles", "G": "USA", "H": "2023-02-20"}, "4": {"A": "Alice Johnson", "B": 22, "C": "alicej@example.com", "D": 78, "E": "Female", "F": "Chicago", "G": "USA", "H": "2023-03-10"}, "5": {"A": "Bob Brown", "B": 45, "C": "bobbrown@example.com", "D": 88, "E": "Male", "F": "Houston", "G": "USA", "H": "2023-04-05"}, "6": {"A": "Charlie Davis", "B": 30, "C": "charlied@example.com", "D": 95, "E": "Male", "F": "Phoenix", "G": "USA", "H": "2023-05-12"}, "7": {"A": "Eve White", "B": 27, "C": "evewhite@example.com", "D": 82, "E": "Female", "F": "Philadelphia", "G": "USA", "H": "2023-06-18"}, "8": {"A": "Frank Black", "B": 40, "C": "frankb@example.com", "D": 90, "E": "Male", "F": "San Antonio", "G": "USA", "H": "2023-07-25"}, "9": {"A": "Grace Green", "B": 29, "C": "graceg@example.com", "D": 76, "E": "Female", "F": "Dallas", "G": "USA", "H": "2023-08-30"}, "10": {"A": "Hank Blue", "B": 35, "C": "hankb@example.com", "D": 89, "E": "Male", "F": "San Diego", "G": "USA", "H": "2023-09-15"}, "11": {"A": "Ivy Red", "B": 31, "C": "ivyred@example.com", "D": 91, "E": "Female", "F": "San Jose", "G": "USA", "H": "2023-10-01"}, "12": {"A": "Michael Grey", "B": 33, "C": "michaelg@example.com", "D": 87, "E": "Male", "F": "Seattle", "G": "USA", "H": "2023-10-05"}, "13": {"A": "Nina Black", "B": 26, "C": "ninab@example.com", "D": 84, "E": "Female", "F": "Miami", "G": "USA", "H": "2023-10-10"}, "14": {"A": "Oscar White", "B": 38, "C": "oscarw@example.com", "D": 90, "E": "Male", "F": "Atlanta", "G": "USA", "H": "2023-10-15"}, "15": {"A": "Paula Green", "B": 32, "C": "paulag@example.com", "D": 93, "E": "Female", "F": "Boston", "G": "USA", "H": "2023-10-20"}, "16": {"A": "Quentin Brown", "B": 29, "C": "quentinb@example.com", "D": 81, "E": "Male", "F": "Denver", "G": "USA", "H": "2023-10-25"}, "17": {"A": "Rachel Blue", "B": 24, "C": "rachelb@example.com", "D": 79, "E": "Female", "F": "Orlando", "G": "USA", "H": "2023-10-30"}, "18": {"A": "Steve Red", "B": 36, "C": "stever@example.com", "D": 88, "E": "Male", "F": "Las Vegas", "G": "USA", "H": "2023-11-01"}, "19": {"A": "Tina Yellow", "B": 30, "C": "tinay@example.com", "D": 85, "E": "Female", "F": "Portland", "G": "USA", "H": "2023-11-05"}, "20": {"A": "Ursula Pink", "B": 27, "C": "ursulap@example.com", "D": 82, "E": "Female", "F": "San Francisco", "G": "USA", "H": "2023-11-10"}, "21": {"A": "Victor Grey", "B": 41, "C": "victorg@example.com", "D": 91, "E": "Male", "F": "Charlotte", "G": "USA", "H": "2023-11-15"}, "22": {"A": "Wendy Black", "B": 34, "C": "wendyb@example.com", "D": 89, "E": "Female", "F": "Detroit", "G": "USA", "H": "2023-11-20"}, "23": {"A": "Xander White", "B": 29, "C": "xanderw@example.com", "D": 86, "E": "Male", "F": "Indianapolis", "G": "USA", "H": "2023-11-25"}, "24": {"A": "Yvonne Green", "B": 25, "C": "yvonnag@example.com", "D": 83, "E": "Female", "F": "Columbus", "G": "USA", "H": "2023-11-30"}, "25": {"A": "Zachary Blue", "B": 37, "C": "zacharyb@example.com", "D": 90, "E": "Male", "F": "Jacksonville", "G": "USA", "H": "2023-12-01"}, "26": {"A": "Alice Brown", "B": 28, "C": "aliceb@example.com", "D": 80, "E": "Female", "F": "Memphis", "G": "USA", "H": "2023-12-05"}, "27": {"A": "Brian Black", "B": 39, "C": "brianb@example.com", "D": 92, "E": "Male", "F": "Nashville", "G": "USA", "H": "2023-12-10"}, "28": {"A": "Cathy Green", "B": 31, "C": "cathyg@example.com", "D": 84, "E": "Female", "F": "Virginia Beach", "G": "USA", "H": "2023-12-15"}, "29": {"A": "Daniel White", "B": 30, "C": "danielw@example.com", "D": 88, "E": "Male", "F": "Atlanta", "G": "USA", "H": "2023-12-20"}, "30": {"A": "Eva Red", "B": 26, "C": "evar@example.com", "D": 81, "E": "Female", "F": "New Orleans", "G": "USA", "H": "2023-12-25"}, "31": {"A": "Frankie Grey", "B": 35, "C": "frankieg@example.com", "D": 90, "E": "Male", "F": "San Antonio", "G": "USA", "H": "2023-12-30"}, "32": {"A": "Gina Blue", "B": 29, "C": "ginab@example.com", "D": 87, "E": "Female", "F": "San Diego", "G": "USA", "H": "2024-01-01"}, "33": {"A": "Henry Black", "B": 42, "C": "henryb@example.com", "D": 93, "E": "Male", "F": "Philadelphia", "G": "USA", "H": "2024-01-05"}, "34": {"A": "Isla Green", "B": 24, "C": "islag@example.com", "D": 79, "E": "Female", "F": "Chicago", "G": "USA", "H": "2024-01-10"}, "35": {"A": "Jack White", "B": 33, "C": "jackw@example.com", "D": 85, "E": "Male", "F": "Los Angeles", "G": "USA", "H": "2024-01-15"}, "36": {"A": "Kathy Red", "B": 31, "C": "kathyr@example.com", "D": 82, "E": "Female", "F": "Miami", "G": "USA", "H": "2024-01-20"}, "37": {"A": "Liam Grey", "B": 36, "C": "liamg@example.com", "D": 89, "E": "Male", "F": "Seattle", "G": "USA", "H": "2024-01-25"}, "38": {"A": "Mia Black", "B": 27, "C": "miab@example.com", "D": 80, "E": "Female", "F": "Denver", "G": "USA", "H": "2024-01-30"}, "39": {"A": "Nate Green", "B": 30, "C": "nateg@example.com", "D": 88, "E": "Male", "F": "Orlando", "G": "USA", "H": "2024-02-01"}, "40": {}, "41": {}, "42": {}, "43": {"A": 100, "B": 300, "C": 234, "D": 399, "E": 5039, "F": 2345, "G": 23526, "H": 123, "I": 54, "J": 234, "K": 54, "L": 23, "M": 12, "N": 57, "O": 1324, "P": "(the formula for sum of everything to the left)"}, "44": {}, "45": {}, "46": {}, "47": {}, "48": {}, "49": {}, "50": {}, "51": {}, "52": {}, "53": {}, "54": {}, "55": {}, "56": {}, "57": {}, "58": {}, "59": {}, "60": {"A": 456, "B": 234, "C": 234, "D": 399, "E": 234, "F": 1234, "G": 23526, "H": 123, "I": 54, "J": 234, "K": 4567, "L": 899, "M": 12, "N": 234, "O": 45, "P": "(the formula for sum of everything to the left)"}}""" - sheet_content_sparse_expected = """{"1": {"AA": "=SUM(A1,A2,A3)", "3782": {"A": 3783, "D": 3784, "AAZ": 3785, "ZZFS": 3786, "CA": 3787}}}""" - - suite = EvalSuite( - name="Google Sheets Tools Evaluation", - system_message="You are an AI assistant that can manage Google Sheets using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create a spreadsheet from large data payload", - user_message=f"Create a spreadsheet named 'Data' with the following content:\n{sheet_content_prompt}", - expected_tool_calls=[ - ExpectedToolCall( - func=create_spreadsheet, - args={ - "title": "Data", - "data": sheet_content_expected1, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="title", weight=0.1), - SimilarityCritic(critic_field="data", weight=0.9, similarity_threshold=0.99), - ], - ) - - suite.add_case( - name="Create a spreadsheet from sparse data payload", - user_message="Create a spreadsheet named 'Sparse Data' that fills the 27th column in the first row with the formula that sums A1, A2, and A3 cells. The 3782nd row should have its A, D, AAZ, ZZFS, and CA columns filled with the numbers 1, 2, 3, 4, and 5, respectively, summed with its row number.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_spreadsheet, - args={ - "title": "Sparse Data", - "data": sheet_content_sparse_expected, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="title", weight=0.1), - SimilarityCritic(critic_field="data", weight=0.9, similarity_threshold=0.95), - ], - ) - - return suite - - -@tool_eval() -def get_spreadsheet_eval() -> EvalSuite: - """Create an evaluation suite for Google Sheets get_spreadsheet tool.""" - - suite = EvalSuite( - name="Google Sheets Tools Evaluation", - system_message="You are an AI assistant that can manage Google Sheets using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get a spreadsheet", - user_message="Get the data in the second sheet of the spreadsheet with the following id 1L2ovCUcRNOacoWxtLV3jgaidWZq4Bw_WXbIWJcxobN0", - expected_tool_calls=[ - ExpectedToolCall( - func=get_spreadsheet, - args={ - "spreadsheet_id": "1L2ovCUcRNOacoWxtLV3jgaidWZq4Bw_WXbIWJcxobN0", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="spreadsheet_id", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/google/pyproject.toml b/toolkits/google/pyproject.toml deleted file mode 100644 index 23fe79ca..00000000 --- a/toolkits/google/pyproject.toml +++ /dev/null @@ -1,63 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google" -version = "1.2.4" -description = "Arcade.dev LLM tools for Google Workspace" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "google-auth-oauthlib>=1.2.1,<2.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", - "beautifulsoup4>=4.10.0,<5.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_google/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google",] diff --git a/toolkits/google/tests/__init__.py b/toolkits/google/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google/tests/test_calendar.py b/toolkits/google/tests/test_calendar.py deleted file mode 100644 index a6ea87fe..00000000 --- a/toolkits/google/tests/test_calendar.py +++ /dev/null @@ -1,582 +0,0 @@ -from datetime import datetime -from unittest.mock import MagicMock, patch -from zoneinfo import ZoneInfo - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google.models import EventVisibility, SendUpdatesOptions -from arcade_google.tools import ( - create_event, - delete_event, - find_time_slots_when_everyone_is_free, - list_calendars, - list_events, - update_event, -) - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_list_calendars(mock_build_calendar_service, mock_context): - mock_service = MagicMock() - mock_build_calendar_service.return_value = mock_service - - expected_api_response = { - "etag": '"p33for2n0pvc8o0o"', - "items": [ - { - "accessRole": "reader", - "backgroundColor": "#16a765", - "colorId": "8", - "conferenceProperties": {"allowedConferenceSolutionTypes": ["hangoutsMeet"]}, - "defaultReminders": [], - "description": "Holidays and Observances in Brazil", - "etag": '"2347287866334000"', - "foregroundColor": "#000000", - "id": "en.brazilian#holiday@group.v.calendar.google.com", - "kind": "calendar#calendarListEntry", - "selected": True, - "summary": "Holidays in Brazil", - "timeZone": "America/Sao_Paulo", - }, - { - "accessRole": "owner", - "backgroundColor": "#9fe1e7", - "colorId": "14", - "conferenceProperties": {"allowedConferenceSolutionTypes": ["hangoutsMeet"]}, - "defaultReminders": [{"method": "popup", "minutes": 10}], - "etag": '"1743169667849567"', - "foregroundColor": "#000000", - "id": "example@arcade.dev", - "kind": "calendar#calendarListEntry", - "notificationSettings": { - "notifications": [ - {"method": "email", "type": "eventCreation"}, - {"method": "email", "type": "eventChange"}, - {"method": "email", "type": "eventCancellation"}, - {"method": "email", "type": "eventResponse"}, - ] - }, - "primary": True, - "selected": True, - "summary": "example@arcade.dev", - "timeZone": "America/Sao_Paulo", - }, - ], - "kind": "calendar#calendarList", - "nextSyncToken": "XkJ8Hy5mN2pQvL9sR4tW7cA3fE1iU6nB", - } - - expected_tool_response = { - "num_calendars": 2, - "calendars": [ - { - "description": "Holidays and Observances in Brazil", - "id": "en.brazilian#holiday@group.v.calendar.google.com", - "summary": "Holidays in Brazil", - "timeZone": "America/Sao_Paulo", - }, - { - "id": "example@arcade.dev", - "summary": "example@arcade.dev", - "timeZone": "America/Sao_Paulo", - }, - ], - "next_page_token": None, - } - - mock_service.calendarList().list().execute.return_value = expected_api_response - - response = await list_calendars(context=mock_context) - assert response == expected_tool_response - - # Case: HttpError during calendars listing - mock_service.calendarList().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_calendars(context=mock_context) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_create_event(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the calendar's time zone - mock_service.calendars().get().execute.return_value = {"timeZone": "America/Los_Angeles"} - - # Case: HttpError during event creation - mock_service.events().insert().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await create_event( - context=mock_context, - summary="Test Event", - start_datetime="2024-12-31T15:30:00", - end_datetime="2024-12-31T17:30:00", - description="Test Description", - location="Test Location", - visibility=EventVisibility.PRIVATE, - attendee_emails=["test@example.com"], - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_list_events(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - # Mock the calendar's time zone - mock_service.calendars().get().execute.return_value = {"timeZone": "America/Los_Angeles"} - - # Mock the events list response - mock_events_list_response = { - "items": [ - { - "creator": {"email": "example@arcade-ai.com", "self": True}, - "end": {"dateTime": "2024-09-27T01:00:00-07:00", "timeZone": "America/Los_Angeles"}, - "eventType": "default", - "htmlLink": "https://www.google.com/calendar/event?eid=event1", - "id": "event1", - "organizer": {"email": "example@arcade-ai.com", "self": True}, - "start": { - "dateTime": "2024-09-27T00:00:00-07:00", - "timeZone": "America/Los_Angeles", - }, - "summary": "Event 1", - }, - { - "creator": {"email": "example@arcade-ai.com", "self": True}, - "end": {"dateTime": "2024-09-27T17:00:00-07:00", "timeZone": "America/Los_Angeles"}, - "eventType": "default", - "htmlLink": "https://www.google.com/calendar/event?eid=event2", - "id": "event2", - "organizer": {"email": "example@arcade-ai.com", "self": True}, - "start": { - "dateTime": "2024-09-27T14:00:00-07:00", - "timeZone": "America/Los_Angeles", - }, - "summary": "Event 2", - }, - ] - } - expected_tool_response = { - "events_count": len(mock_events_list_response["items"]), - "events": mock_events_list_response["items"], - } - mock_service.events().list().execute.return_value = mock_events_list_response - response = await list_events( - context=mock_context, - min_end_datetime="2024-09-15T09:00:00", - max_start_datetime="2024-09-16T17:00:00", - ) - assert response == expected_tool_response - - # Case: HttpError during events listing - mock_service.events().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_events( - context=mock_context, - min_end_datetime="2024-09-15T09:00:00", - max_start_datetime="2024-09-16T17:00:00", - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_update_event(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock retrieval of the event - mock_service.events().get().execute.side_effect = HttpError( - resp=MagicMock(status=404), - content=b'{"error": {"message": "Event not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await update_event( - context=mock_context, - event_id="1234567890", - updated_start_datetime="2024-12-31T00:15:00", - updated_end_datetime="2024-12-31T01:15:00", - updated_summary="Updated Event", - updated_description="Updated Description", - updated_location="Updated Location", - updated_visibility=EventVisibility.PRIVATE, - attendee_emails_to_add=["test@example.com"], - attendee_emails_to_remove=["test@example2.com"], - send_updates=SendUpdatesOptions.ALL, - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_delete_event(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - mock_service.events().delete().execute.side_effect = HttpError( - resp=MagicMock(status=404), - content=b'{"error": {"message": "Event not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await delete_event( - context=mock_context, - event_id="nonexistent_event", - send_updates=SendUpdatesOptions.ALL, - ) - - -@pytest.mark.asyncio -@patch("arcade_google.utils.get_now") -@patch("arcade_google.tools.calendar.build_oauth_service") -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_find_free_slots_happiest_path_single_user( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": {"busy": []}, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - response = await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) - - assert response == { - "free_slots": [ - { - "start": { - "datetime": "2025-03-10T09:25:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T18:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-11T08:00:00-07:00", - "weekday": "Tuesday", - }, - "end": { - "datetime": "2025-03-11T18:00:00-07:00", - "weekday": "Tuesday", - }, - }, - ], - "timezone": "America/Los_Angeles", - } - - -@pytest.mark.asyncio -@patch("arcade_google.utils.get_now") -@patch("arcade_google.tools.calendar.build_oauth_service") -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_find_free_slots_happiest_path_single_user_with_busy_times( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:00:00-07:00", - "end": "2025-03-10T12:00:00-07:00", - }, - { - "start": "2025-03-10T14:15:00-07:00", - "end": "2025-03-10T14:30:00-07:00", - }, - ] - }, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - response = await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) - - assert response == { - "free_slots": [ - { - "start": { - "datetime": "2025-03-10T09:25:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T11:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T12:00:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T14:15:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T14:30:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T18:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-11T08:00:00-07:00", - "weekday": "Tuesday", - }, - "end": { - "datetime": "2025-03-11T18:00:00-07:00", - "weekday": "Tuesday", - }, - }, - ], - "timezone": "America/Los_Angeles", - } - - -@pytest.mark.asyncio -@patch("arcade_google.utils.get_now") -@patch("arcade_google.tools.calendar.build_oauth_service") -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_find_free_slots_happiest_path_multiple_users_with_busy_times( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:00:00-07:00", - "end": "2025-03-10T12:00:00-07:00", - }, - { - "start": "2025-03-10T14:15:00-07:00", - "end": "2025-03-10T14:30:00-07:00", - }, - ] - }, - "example2@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:30:00-07:00", - "end": "2025-03-10T12:45:00-07:00", - }, - { - "start": "2025-03-11T06:00:00-07:00", - "end": "2025-03-11T07:00:00-07:00", - }, - ] - }, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - response = await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev", "example2@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) - - assert response == { - "free_slots": [ - { - "start": { - "datetime": "2025-03-10T09:25:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T11:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T12:45:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T14:15:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T14:30:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T18:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-11T08:00:00-07:00", - "weekday": "Tuesday", - }, - "end": { - "datetime": "2025-03-11T18:00:00-07:00", - "weekday": "Tuesday", - }, - }, - ], - "timezone": "America/Los_Angeles", - } - - -@pytest.mark.asyncio -@patch("arcade_google.utils.get_now") -@patch("arcade_google.tools.calendar.build_oauth_service") -@patch("arcade_google.tools.calendar.build_calendar_service") -async def test_find_free_slots_with_google_calendar_error_not_found( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:00:00-07:00", - "end": "2025-03-10T12:00:00-07:00", - }, - { - "start": "2025-03-10T14:15:00-07:00", - "end": "2025-03-10T14:30:00-07:00", - }, - ] - }, - "example2@arcade.dev": { - "errors": [ - { - "reason": "notFound", - "domain": "calendar", - } - ] - }, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - with pytest.raises(RetryableToolError): - await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev", "example2@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) diff --git a/toolkits/google/tests/test_contacts.py b/toolkits/google/tests/test_contacts.py deleted file mode 100644 index 409a1e6e..00000000 --- a/toolkits/google/tests/test_contacts.py +++ /dev/null @@ -1,96 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from arcade_tdk import ToolContext - -from arcade_google.tools import create_contact - - -@pytest.fixture -def mock_context(): - context = AsyncMock(spec=ToolContext) - context.authorization = MagicMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.mark.asyncio -async def test_create_contact_success(mock_context): - # Test create_contact with all parameters (given, family names and email) - created_contact_data = {"resourceName": "people/123", "etag": "abc"} - - create_contact_call = MagicMock() - create_contact_call.execute.return_value = created_contact_data - - people_mock = MagicMock() - people_mock.createContact.return_value = create_contact_call - - service_mock = MagicMock() - service_mock.people.return_value = people_mock - - with patch( - "arcade_google.tools.contacts.build_people_service", return_value=service_mock - ) as mock_build: - result = await create_contact( - mock_context, - given_name="Alice", - family_name="Smith", - email="alice@example.com", - ) - assert "contact" in result - assert result["contact"] == created_contact_data - - # Verify that the createContact API was called with the correct body contents. - expected_body = { - "names": [{"givenName": "Alice", "familyName": "Smith"}], - "emailAddresses": [{"value": "alice@example.com", "type": "work"}], - } - people_mock.createContact.assert_called_once_with( - body=expected_body, personFields="names,emailAddresses" - ) - mock_build.assert_called_once() - - -@pytest.mark.asyncio -async def test_create_contact_success_without_optional(mock_context): - # Test create_contact without optional parameters family_name and email. - created_contact_data = {"resourceName": "people/456", "etag": "def"} - - create_contact_call = MagicMock() - create_contact_call.execute.return_value = created_contact_data - - people_mock = MagicMock() - people_mock.createContact.return_value = create_contact_call - - service_mock = MagicMock() - service_mock.people.return_value = people_mock - - with patch("arcade_google.tools.contacts.build_people_service", return_value=service_mock): - result = await create_contact(mock_context, given_name="Bob", family_name=None, email=None) - assert "contact" in result - assert result["contact"] == created_contact_data - - # Expected body should only include the givenName when family_name and email are omitted. - expected_body = {"names": [{"givenName": "Bob"}]} - people_mock.createContact.assert_called_once_with( - body=expected_body, personFields="names,emailAddresses" - ) - - -@pytest.mark.asyncio -async def test_create_contact_error(mock_context): - # Simulate an error thrown by createContact - error_call = MagicMock() - error_call.execute.side_effect = Exception("Create error") - - people_mock = MagicMock() - people_mock.createContact.return_value = error_call - - service_mock = MagicMock() - service_mock.people.return_value = people_mock - - with ( - patch("arcade_google.tools.contacts.build_people_service", return_value=service_mock), - pytest.raises(Exception, match="Error in execution of CreateContact"), - ): - await create_contact(mock_context, given_name="Alice", family_name="Doe", email=None) diff --git a/toolkits/google/tests/test_doc_to_markdown.py b/toolkits/google/tests/test_doc_to_markdown.py deleted file mode 100644 index d68a74ab..00000000 --- a/toolkits/google/tests/test_doc_to_markdown.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - -from arcade_google.doc_to_markdown import convert_document_to_markdown - - -@pytest.mark.asyncio -async def test_convert_document_to_markdown(sample_document_and_expected_formats): - (sample_document, expected_markdown, _) = sample_document_and_expected_formats - markdown = convert_document_to_markdown(sample_document) - assert markdown == expected_markdown diff --git a/toolkits/google/tests/test_docs.py b/toolkits/google/tests/test_docs.py deleted file mode 100644 index 2e9e5470..00000000 --- a/toolkits/google/tests/test_docs.py +++ /dev/null @@ -1,164 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google.tools import ( - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, -) -from arcade_google.utils import build_docs_service - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_service(): - with patch("arcade_google.tools.docs." + build_docs_service.__name__) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.mark.asyncio -async def test_get_document_by_id_success(mock_context, mock_service): - # Mock the service.documents().get().execute() method - mock_service.documents.return_value.get.return_value.execute.return_value = { - "body": {"content": [{"endIndex": 1, "paragraph": {}}]}, - "documentId": "test_document_id", - "title": "Test Document", - } - - result = await get_document_by_id(mock_context, "test_document_id") - - assert result["documentId"] == "test_document_id" - assert result["title"] == "Test Document" - - -@pytest.mark.asyncio -async def test_get_document_by_id_http_error(mock_context, mock_service): - # Simulate HttpError - mock_service.documents.return_value.get.return_value.execute.side_effect = HttpError( - resp=AsyncMock(status=404), content=b'{"error": {"message": "Not Found"}}' - ) - - with pytest.raises(ToolExecutionError, match="Error in execution of GetDocumentById"): - await get_document_by_id(mock_context, "invalid_document_id") - - -@pytest.mark.asyncio -async def test_insert_text_at_end_of_document_success(mock_context, mock_service): - # Mock get_document_by_id to return a document with endIndex - with patch( - "arcade_google.tools.docs.get_document_by_id", - return_value={"body": {"content": [{"endIndex": 1, "paragraph": {}}]}}, - ): - # Mock the service.documents().batchUpdate().execute() method - mock_service.documents.return_value.batchUpdate.return_value.execute.return_value = { - "documentId": "test_document_id", - "replies": [], - } - - result = await insert_text_at_end_of_document( - mock_context, "test_document_id", "Sample text" - ) - - assert result["documentId"] == "test_document_id" - - -@pytest.mark.asyncio -@pytest.mark.asyncio -async def test_insert_text_at_end_of_document_http_error(mock_context, mock_service): - with patch( - "arcade_google.tools.docs.get_document_by_id", - return_value={"body": {"content": [{"endIndex": 1, "paragraph": {}}]}}, - ): - # Simulate HttpError during batchUpdate - mock_service.documents.return_value.batchUpdate.return_value.execute.side_effect = ( - HttpError(resp=AsyncMock(status=400), content=b'{"error": {"message": "Bad Request"}}') - ) - - with pytest.raises( - ToolExecutionError, match="Error in execution of InsertTextAtEndOfDocument" - ): - await insert_text_at_end_of_document(mock_context, "test_document_id", "Sample text") - - -@pytest.mark.asyncio -async def test_create_blank_document_success(mock_context, mock_service): - # Mock the service.documents().create().execute() method - mock_service.documents.return_value.create.return_value.execute.return_value = { - "documentId": "new_document_id", - "title": "New Document", - } - - result = await create_blank_document(mock_context, "New Document") - - assert result["documentId"] == "new_document_id" - assert result["title"] == "New Document" - assert "documentUrl" in result - - -@pytest.mark.asyncio -async def test_create_blank_document_http_error(mock_context, mock_service): - # Simulate HttpError during create - mock_service.documents.return_value.create.return_value.execute.side_effect = HttpError( - resp=AsyncMock(status=403), content=b'{"error": {"message": "Forbidden"}}' - ) - - with pytest.raises(ToolExecutionError, match="Error in execution of CreateBlankDocument"): - await create_blank_document(mock_context, "New Document") - - -@pytest.mark.asyncio -async def test_create_document_from_text_success(mock_context, mock_service): - with patch( - "arcade_google.tools.docs." + create_blank_document.__name__ - ) as mock_create_blank_document: - # Mock create_blank_document - mock_create_blank_document.return_value = { - "documentId": "new_document_id", - "title": "New Document", - } - - # Mock the service.documents().batchUpdate().execute() method - mock_service.documents.return_value.batchUpdate.return_value.execute.return_value = { - "documentId": "new_document_id", - "replies": [], - } - - result = await create_document_from_text(mock_context, "New Document", "Hello, World!") - - assert result["documentId"] == "new_document_id" - assert result["title"] == "New Document" - assert "documentUrl" in result - - -@pytest.mark.asyncio -async def test_create_document_from_text_http_error(mock_context, mock_service): - with patch( - "arcade_google.tools.docs." + create_blank_document.__name__ - ) as mock_create_blank_document: - # Mock create_blank_document - mock_create_blank_document.return_value = { - "documentId": "new_document_id", - "title": "New Document", - } - - # Simulate HttpError during batchUpdate - mock_service.documents.return_value.batchUpdate.return_value.execute.side_effect = ( - HttpError( - resp=AsyncMock(status=500), content=b'{"error": {"message": "Internal Error"}}' - ) - ) - - with pytest.raises( - ToolExecutionError, match="Error in execution of CreateDocumentFromText" - ): - await create_document_from_text(mock_context, "New Document", "Hello, World!") diff --git a/toolkits/google/tests/test_drive.py b/toolkits/google/tests/test_drive.py deleted file mode 100644 index 962d9bf6..00000000 --- a/toolkits/google/tests/test_drive.py +++ /dev/null @@ -1,391 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google.models import Corpora, DocumentFormat, OrderBy -from arcade_google.tools import ( - get_file_tree_structure, - search_and_retrieve_documents, - search_documents, -) -from arcade_google.utils import build_drive_service - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_service(): - with patch("arcade_google.tools.drive." + build_drive_service.__name__) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.mark.asyncio -async def test_search_documents_success(mock_context, mock_service): - # Mock the service.files().list().execute() method - mock_service.files.return_value.list.return_value.execute.side_effect = [ - { - "files": [ - {"id": "file1", "name": "Document 1"}, - {"id": "file2", "name": "Document 2"}, - ], - "nextPageToken": None, - } - ] - - result = await search_documents(mock_context, limit=2) - - assert result["documents_count"] == 2 - assert len(result["documents"]) == 2 - assert result["documents"][0]["id"] == "file1" - assert result["documents"][1]["id"] == "file2" - - -@pytest.mark.asyncio -async def test_search_documents_pagination(mock_context, mock_service): - # Simulate multiple pages - mock_service.files.return_value.list.return_value.execute.side_effect = [ - { - "files": [{"id": f"file{i}", "name": f"Document {i}"} for i in range(1, 11)], - "nextPageToken": "token1", - }, - { - "files": [{"id": f"file{i}", "name": f"Document {i}"} for i in range(11, 21)], - "nextPageToken": None, - }, - ] - - result = await search_documents(mock_context, limit=15) - - assert result["documents_count"] == 15 - assert len(result["documents"]) == 15 - assert result["documents"][0]["id"] == "file1" - assert result["documents"][-1]["id"] == "file15" - - -@pytest.mark.asyncio -async def test_search_documents_http_error(mock_context, mock_service): - # Simulate HttpError - mock_service.files.return_value.list.return_value.execute.side_effect = HttpError( - resp=AsyncMock(status=403), content=b'{"error": {"message": "Forbidden"}}' - ) - - with pytest.raises( - ToolExecutionError, match=f"Error in execution of {search_documents.__tool_name__}" - ): - await search_documents(mock_context) - - -@pytest.mark.asyncio -async def test_search_documents_unexpected_error(mock_context, mock_service): - # Simulate unexpected exception - mock_service.files.return_value.list.return_value.execute.side_effect = Exception( - "Unexpected error" - ) - - with pytest.raises( - ToolExecutionError, match=f"Error in execution of {search_documents.__tool_name__}" - ): - await search_documents(mock_context) - - -@pytest.mark.asyncio -async def test_search_documents_in_organization_domains(mock_context, mock_service): - # Mock the service.files().list().execute() method - mock_service.files.return_value.list.return_value.execute.side_effect = [ - { - "files": [ - {"id": "file1", "name": "Document 1"}, - ], - "nextPageToken": None, - } - ] - - result = await search_documents( - mock_context, - order_by=OrderBy.MODIFIED_TIME_DESC, - include_shared_drives=False, - include_organization_domain_documents=True, - limit=1, - ) - - assert result["documents_count"] == 1 - mock_service.files.return_value.list.assert_called_with( - q="(mimeType = 'application/vnd.google-apps.document' and trashed = false)", - corpora=Corpora.DOMAIN.value, - pageSize=1, - orderBy=OrderBy.MODIFIED_TIME_DESC.value, - includeItemsFromAllDrives="true", - supportsAllDrives="true", - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.drive.search_documents") -@patch("arcade_google.tools.drive.get_document_by_id") -async def test_search_and_retrieve_documents_in_markdown_format( - mock_get_document_by_id, - mock_search_documents, - mock_context, - sample_document_and_expected_formats, -): - (sample_document, expected_markdown, _) = sample_document_and_expected_formats - mock_search_documents.return_value = { - "documents_count": 1, - "documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}], - } - mock_get_document_by_id.return_value = sample_document - result = await search_and_retrieve_documents( - mock_context, - document_contains=[sample_document["title"]], - return_format=DocumentFormat.MARKDOWN, - ) - assert result["documents_count"] == 1 - assert result["documents"][0] == expected_markdown - - -@pytest.mark.asyncio -@patch("arcade_google.tools.drive.search_documents") -@patch("arcade_google.tools.drive.get_document_by_id") -async def test_search_and_retrieve_documents_in_html_format( - mock_get_document_by_id, - mock_search_documents, - mock_context, - sample_document_and_expected_formats, -): - (sample_document, _, expected_html) = sample_document_and_expected_formats - mock_search_documents.return_value = { - "documents_count": 1, - "documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}], - } - mock_get_document_by_id.return_value = sample_document - result = await search_and_retrieve_documents( - mock_context, - document_contains=[sample_document["title"]], - return_format=DocumentFormat.HTML, - ) - assert result["documents_count"] == 1 - assert result["documents"][0] == expected_html - - -@pytest.mark.asyncio -@patch("arcade_google.tools.drive.search_documents") -@patch("arcade_google.tools.drive.get_document_by_id") -async def test_search_and_retrieve_documents_in_google_json_format( - mock_get_document_by_id, - mock_search_documents, - mock_context, - sample_document_and_expected_formats, -): - (sample_document, _, _) = sample_document_and_expected_formats - mock_search_documents.return_value = { - "documents_count": 1, - "documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}], - } - mock_get_document_by_id.return_value = sample_document - result = await search_and_retrieve_documents( - mock_context, - document_contains=[sample_document["title"]], - return_format=DocumentFormat.GOOGLE_API_JSON, - ) - assert result["documents_count"] == 1 - assert result["documents"][0] == sample_document - - -@pytest.mark.asyncio -async def test_get_file_tree_structure( - mock_context, mock_service, sample_drive_file_tree_request_responses -): - files_list_sample, drives_get_sample = sample_drive_file_tree_request_responses - - mock_service.files.return_value.list.return_value.execute.side_effect = [files_list_sample] - mock_service.drives.return_value.get.return_value.execute.side_effect = drives_get_sample - - result = await get_file_tree_structure(mock_context, include_shared_drives=True) - - expected_file_tree = { - "drives": [ - { - "id": "0AFqcR6obkydtUk9PVA", - "name": "Shared Drive 1", - "children": [ - { - "createdTime": "2025-02-26T00:27:45.526Z", - "id": "1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt", - "mimeType": "application/vnd.google-apps.folder", - "modifiedTime": "2025-02-26T00:27:45.526Z", - "name": "shared-1-folder-1", - "children": [ - { - "createdTime": "2025-02-26T00:28:20.571Z", - "id": "19WVyQndQsc0AxxfdrIt5CvDQd6r-BvpqnB8bWZoL7Xk", - "mimeType": "application/vnd.google-apps.document", - "modifiedTime": "2025-02-26T00:28:30.773Z", - "name": "shared-1-folder-1-doc-1", - "size": { - "unit": "bytes", - "value": 1024, - }, - } - ], - }, - { - "createdTime": "2025-02-26T00:27:19.287Z", - "id": "1didt_h-tDjuJ-dmYtHUSyOCPci30K_kSszvg0G3tKBM", - "mimeType": "application/vnd.google-apps.document", - "modifiedTime": "2025-02-26T00:27:26.079Z", - "name": "shared-1-doc-1", - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - ], - }, - { - "name": "My Drive", - "children": [ - { - "createdTime": "2025-01-24T06:34:22.305Z", - "id": "1vB6sv0MD0hYSraYvWU_fcci3GN_-Jf4g-LfyXdG8ZMo", - "mimeType": "application/vnd.google-apps.document", - "modifiedTime": "2025-02-25T21:54:30.632Z", - "name": "The Birth of MX Engineering", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 6634, - }, - }, - { - "createdTime": "2025-02-25T17:57:46.036Z", - "id": "1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo", - "mimeType": "application/vnd.google-apps.folder", - "modifiedTime": "2025-02-25T17:57:46.036Z", - "name": "test folder 1", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "children": [ - { - "id": "1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO", - "name": "test folder 1.1", - "mimeType": "application/vnd.google-apps.folder", - "createdTime": "2025-02-25T17:58:58.987Z", - "modifiedTime": "2025-02-25T17:58:58.987Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "children": [ - { - "id": "1wv2dmYo0skJTI59ZIcwH9vm-wt7psMwXTvihuEGeHeI", - "name": "test document 1.1.1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-25T17:59:03.325Z", - "modifiedTime": "2025-02-25T17:59:11.445Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - ], - }, - { - "id": "1DSmL7d07kjT6b6L-t4JIT06ElUbZ1q0K6_gEpn_UGZ8", - "name": "test document 1.2", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-25T17:58:38.628Z", - "modifiedTime": "2025-02-25T17:58:46.713Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - { - "id": "1Fcxz7HsyO2Zyc-5DTD3zBQnaVrZwD29BP9KD9rPnYfE", - "name": "test document 1.1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-25T17:57:53.850Z", - "modifiedTime": "2025-02-25T17:58:28.745Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - ], - }, - { - "createdTime": "2025-02-18T20:48:52.786Z", - "id": "16PUe97yGQeOjQgrgd54iCoxzid4SEvu_J33P_ELd5r8", - "mimeType": "application/vnd.google-apps.presentation", - "modifiedTime": "2025-02-19T23:31:20.483Z", - "name": "Hello world presentation", - "owners": [ - { - "email": "john.doe@arcade.dev", - "name": "john.doe", - } - ], - "size": { - "unit": "bytes", - "value": 15774558, - }, - }, - { - "id": "1nG7lSvIyK05N9METPczVJa4iGgE7uoo-A6zpqjpUsDY", - "name": "Shared doc 1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-19T18:51:44.622Z", - "modifiedTime": "2025-02-19T19:30:39.773Z", - "owners": [ - { - "name": "theboss", - "email": "theboss@arcade.dev", - } - ], - "size": { - "unit": "bytes", - "value": 2700, - }, - }, - ], - }, - ] - } - - assert result == expected_file_tree diff --git a/toolkits/google/tests/test_file_picker.py b/toolkits/google/tests/test_file_picker.py deleted file mode 100644 index 67082dfe..00000000 --- a/toolkits/google/tests/test_file_picker.py +++ /dev/null @@ -1,44 +0,0 @@ -import base64 -import json -from urllib.parse import parse_qs, urlparse - -import pytest -from arcade_tdk import ToolContext, ToolMetadataItem, ToolMetadataKey - -from arcade_google.tools import generate_google_file_picker_url - - -@pytest.fixture -def mock_context(): - context = ToolContext( - metadata=[ - ToolMetadataItem(key=ToolMetadataKey.CLIENT_ID, value="1234-3444534323"), - ToolMetadataItem( - key=ToolMetadataKey.COORDINATOR_URL, value="https://mock_coordinator_url" - ), - ], - ) - return context - - -@pytest.mark.asyncio -async def test_generate_google_file_picker_url(mock_context): - expected_decoded_config = { - "auth": { - "client_id": "1234-3444534323", - "app_id": "1234", - }, - } - - result = generate_google_file_picker_url(mock_context) - - assert result["url"].startswith("https://mock_coordinator_url/google/drive_picker?config=") - - # Decode the config from the URL - parsed_url = urlparse(result["url"]) - query_params = parse_qs(parsed_url.query) - encoded_config = query_params.get("config", [None])[0] - assert encoded_config is not None - - decoded_config = json.loads(base64.urlsafe_b64decode(encoded_config).decode("utf-8")) - assert decoded_config == expected_decoded_config diff --git a/toolkits/google/tests/test_gmail.py b/toolkits/google/tests/test_gmail.py deleted file mode 100644 index b423b4ab..00000000 --- a/toolkits/google/tests/test_gmail.py +++ /dev/null @@ -1,951 +0,0 @@ -from base64 import urlsafe_b64encode -from email.message import EmailMessage -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google.models import GmailReplyToWhom -from arcade_google.tools import ( - delete_draft_email, - get_thread, - list_draft_emails, - list_emails, - list_emails_by_header, - list_threads, - reply_to_email, - search_threads, - send_draft_email, - send_email, - trash_email, - update_draft_email, - write_draft_email, -) -from arcade_google.utils import ( - build_reply_body, - parse_draft_email, - parse_multipart_email, - parse_plain_text_email, -) - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_send_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await send_email( - context=mock_context, - subject="Test Subject", - body="Test Body", - recipient="test@example.com", - ) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().messages().send().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid recipient"}}', - ) - - with pytest.raises(ToolExecutionError): - await send_email( - context=mock_context, - subject="Test Subject", - body="Test Body", - recipient="invalid@example.com", - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_write_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await write_draft_email( - context=mock_context, - subject="Test Draft Subject", - body="Test Draft Body", - recipient="draft@example.com", - ) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().drafts().create().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await write_draft_email( - context=mock_context, - subject="Test Draft Subject", - body="Test Draft Body", - recipient="draft@example.com", - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_update_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await update_draft_email( - context=mock_context, - draft_email_id="draft123", - subject="Updated Subject", - body="Updated Body", - recipient="updated@example.com", - ) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().drafts().update().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Draft not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await update_draft_email( - context=mock_context, - draft_email_id="nonexistent_draft", - subject="Updated Subject", - body="Updated Body", - recipient="updated@example.com", - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_send_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await send_draft_email(context=mock_context, email_id="draft456") - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().drafts().send().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Draft not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await send_draft_email(context=mock_context, email_id="nonexistent_draft") - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_delete_draft_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - result = await delete_draft_email(context=mock_context, draft_email_id="draft789") - - assert "Draft email with ID" in result - assert "deleted successfully" in result - - # Test http error - mock_service.users().drafts().delete().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Draft not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await delete_draft_email(context=mock_context, draft_email_id="nonexistent_draft") - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -@patch("arcade_google.tools.gmail.parse_draft_email") -async def test_get_draft_emails(mock_parse_draft_email, mock_build, mock_context): - # Setup test data - mock_drafts_list_response = { - "drafts": [ - { - "id": "r9999999999999999999", - "message": {"id": "0000000000000000", "threadId": "0000000000000000"}, - } - ], - "resultSizeEstimate": 1, - } - mock_drafts_get_response = { - "id": "r9999999999999999999", - "message": { - "id": "0000000000000000", - "threadId": "0000000000000000", - "labelIds": ["DRAFT"], - "snippet": "Hello! This is a test. Best regards, John", - "payload": { - "partId": "", - "mimeType": "text/plain", - "filename": "", - "headers": [ - {"name": "to", "value": "test@arcade-ai.com"}, - {"name": "subject", "value": "New Draft"}, - {"name": "Date", "value": "Mon, 16 Sep 2024 13:02:10 -0400"}, - {"name": "From", "value": "john-doe@arcade-ai.com"}, - ], - "body": { - "size": 41, - "data": "SGVsbG8hIFRoaXMgaXMgYSB0ZXN0LgoKQmVzdCByZWdhcmRzLApCb2I=", - }, - }, - "sizeEstimate": 453, - "historyId": "7061", - "internalDate": "1726506130000", - }, - } - - # Setup mocking - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the response from the Gmail list drafts API - mock_service.users().drafts().list().execute.return_value = mock_drafts_list_response - - # Mock the response from the Gmail get drafts API - mock_service.users().drafts().get().execute.return_value = mock_drafts_get_response - - # Mock the parse_draft_email function since parse_draft_email doesn't accept object of type MagicMock - mock_parse_draft_email.return_value = parse_draft_email(mock_drafts_get_response) - - # Test happy path - result = await list_draft_emails(context=mock_context, n_drafts=2) - - assert isinstance(result, dict) - assert "emails" in result - assert len(result["emails"]) == 1 - assert all("id" in draft and "subject" in draft for draft in result["emails"]) - - # Test http error - mock_service.users().drafts().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_draft_emails(context=mock_context, n_drafts=2) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -@patch("arcade_google.tools.gmail.parse_plain_text_email") -async def test_search_emails_by_header(mock_parse_plain_text_email, mock_build, mock_context): - # Setup test data - mock_messages_list_response = { - "messages": [ - {"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"}, - {"id": "191fbc0ea11efa90", "threadId": "191fbc0ea11efa90"}, - ], - "nextPageToken": "00755945214480102915", - "resultSizeEstimate": 201, - } - mock_messages_get_response = { - "id": "191f2cf4d24bf23d", - "threadId": "191f2cf4d24bf23d", - "labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"], - "snippet": "Hey User, Your personal access token (classic) "ArcadeAI" with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,", - "payload": { - "partId": "", - "mimeType": "text/plain", - "filename": "", - "headers": [ - {"name": "Delivered-To", "value": "example@arcade-ai.com"}, - {"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"}, - {"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"}, - {"name": "To", "value": "example@arcade-ai.com"}, - { - "name": "Subject", - "value": "[GitHub] Your personal access token (classic) has expired", - }, - ], - "body": { - "size": 605, - "data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=", - }, - }, - "sizeEstimate": 4512, - "historyId": "5508", - "internalDate": "1726355557000", - } - - # Setup mocking - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the response from the Gmail list messages API - mock_service.users().messages().list().execute.return_value = mock_messages_list_response - - # Mock the response from the Gmail get messages API - mock_service.users().messages().get().execute.return_value = mock_messages_get_response - - # Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock - mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response) - - # Test happy path - result = await list_emails_by_header( - context=mock_context, sender="noreply@github.com", max_results=2 - ) - - assert isinstance(result, dict) - assert "emails" in result - assert len(result["emails"]) == 2 - assert all("id" in email and "subject" in email for email in result["emails"]) - - # Test http error - mock_service.users().messages().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_emails_by_header( - context=mock_context, sender="noreply@github.com", max_results=2 - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -@patch("arcade_google.tools.gmail.parse_plain_text_email") -async def test_get_emails(mock_parse_plain_text_email, mock_build, mock_context): - # Setup test data - mock_messages_list_response = { - "messages": [ - {"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"}, - ], - "nextPageToken": "00755945214480102915", - "resultSizeEstimate": 1, - } - mock_messages_get_response = { - "id": "191f2cf4d24bf23d", - "threadId": "191f2cf4d24bf23d", - "labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"], - "snippet": "Hey User, Your personal access token (classic) "ArcadeAI" with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,", - "payload": { - "partId": "", - "mimeType": "text/plain", - "filename": "", - "headers": [ - {"name": "Delivered-To", "value": "example@arcade-ai.com"}, - {"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"}, - {"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"}, - {"name": "To", "value": "example@arcade-ai.com"}, - { - "name": "Subject", - "value": "[GitHub] Your personal access token (classic) has expired", - }, - ], - "body": { - "size": 605, - "data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=", - }, - }, - "sizeEstimate": 4512, - "historyId": "5508", - "internalDate": "1726355557000", - } - - # Setup mocking - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the response from the Gmail list messages API - mock_service.users().messages().list().execute.return_value = mock_messages_list_response - - # Mock the Gmail get messages API - mock_service.users().messages().get().execute.return_value = mock_messages_get_response - - # Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock - mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response) - - # Test happy path - result = await list_emails(context=mock_context, n_emails=1) - - assert isinstance(result, dict) - assert "emails" in result - assert len(result["emails"]) == 1 - assert "id" in result["emails"][0] - assert "subject" in result["emails"][0] - assert "date" in result["emails"][0] - assert "body" in result["emails"][0] - - # Test http error - mock_service.users().messages().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_emails(context=mock_context, n_emails=1) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_trash_email(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Test happy path - email_id = "123456" - result = await trash_email(context=mock_context, email_id=email_id) - - assert isinstance(result, dict) - assert "id" in result - assert "thread_id" in result - assert "subject" in result - assert "body" in result - - # Test http error - mock_service.users().messages().trash().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Email not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await trash_email(context=mock_context, email_id="nonexistent_email") - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_search_threads(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Setup mock response data - mock_threads_list_response = { - "threads": [ - { - "id": "thread1", - "snippet": "Thread snippet 1", - }, - { - "id": "thread2", - "snippet": "Thread snippet 2", - }, - ], - "nextPageToken": "next_token_123", - "resultSizeEstimate": 2, - } - - # Mock the Gmail API threads().list() method - mock_service.users().threads().list().execute.return_value = mock_threads_list_response - - # Test happy path - result = await search_threads( - context=mock_context, - sender="test@example.com", - max_results=2, - ) - - assert isinstance(result, dict) - assert "threads" in result - assert len(result["threads"]) == 2 - assert result["threads"][0]["id"] == "thread1" - assert "next_page_token" in result - - # Test error handling - mock_service.users().threads().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await search_threads( - context=mock_context, - sender="test@example.com", - max_results=2, - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_list_threads(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Setup mock response data - mock_threads_list_response = { - "threads": [ - { - "id": "thread1", - "snippet": "Thread snippet 1", - }, - { - "id": "thread2", - "snippet": "Thread snippet 2", - }, - ], - "nextPageToken": "next_token_123", - "resultSizeEstimate": 2, - } - - # Mock the Gmail API threads().list() method - mock_service.users().threads().list().execute.return_value = mock_threads_list_response - - # Test happy path - result = await list_threads( - context=mock_context, - max_results=2, - ) - - assert isinstance(result, dict) - assert "threads" in result - assert len(result["threads"]) == 2 - assert result["threads"][0]["id"] == "thread1" - assert "next_page_token" in result - - # Test error handling - mock_service.users().threads().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_threads( - context=mock_context, - max_results=2, - ) - - -@pytest.mark.asyncio -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_get_thread(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Setup mock response data - mock_thread_get_response = { - "id": "thread1", - "messages": [ - { - "id": "message1", - "snippet": "Message snippet 1", - }, - { - "id": "message2", - "snippet": "Message snippet 2", - }, - ], - } - - # Mock the Gmail API threads().get() method - mock_service.users().threads().get().execute.return_value = mock_thread_get_response - - # Test happy path - result = await get_thread( - context=mock_context, - thread_id="thread1", - ) - - assert isinstance(result, dict) - assert "id" in result - assert result["id"] == "thread1" - assert "messages" in result - assert len(result["messages"]) == 2 - assert result["messages"][0]["id"] == "message1" - - # Test error handling - mock_service.users().threads().get().execute.side_effect = HttpError( - resp=MagicMock(status=404), - content=b'{"error": {"message": "Thread not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await get_thread( - context=mock_context, - thread_id="invalid_thread", - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "reply_to_whom, expected_to, expected_cc", - [ - ( - GmailReplyToWhom.EVERY_RECIPIENT, - "sender@example.com, to1@example.com, to2@example.com", - "cc1@example.com, cc2@example.com", - ), - ( - GmailReplyToWhom.ONLY_THE_SENDER, - "sender@example.com", - "", - ), - ], -) -@patch("arcade_google.tools.gmail._build_gmail_service") -async def test_reply_to_email(mock_build, reply_to_whom, expected_to, expected_cc, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - original_message = { - "id": "id123456", - "threadId": "thread123456", - "payload": { - "headers": [ - {"name": "Message-ID", "value": "id123456"}, - {"name": "Subject", "value": "test"}, - {"name": "From", "value": "sender@example.com"}, - {"name": "To", "value": "to1@example.com, to2@example.com, test@example.com"}, - {"name": "Cc", "value": "cc1@example.com, cc2@example.com"}, - {"name": "References", "value": "thread123456"}, - ], - }, - } - - mock_service.users().getProfile().execute.return_value = {"emailAddress": "test@example.com"} - mock_service.users().messages().get().execute.return_value = original_message - - result = await reply_to_email( - context=mock_context, - body="test", - reply_to_message_id="id123456", - reply_to_whom=reply_to_whom, - ) - - assert isinstance(result, dict) - assert "url" in result - - replying_to = parse_multipart_email(original_message) - expected_body = build_reply_body("test", replying_to) - - expected_message = EmailMessage() - expected_message.set_content(expected_body) - expected_message["To"] = expected_to - expected_message["Subject"] = "Re: test" - if expected_cc: - expected_message["Cc"] = expected_cc - expected_message["In-Reply-To"] = "id123456" - expected_message["References"] = "id123456, thread123456" - - mock_service.users().messages().send.assert_called_once_with( - userId="me", - body={ - "raw": urlsafe_b64encode(expected_message.as_bytes()).decode(), - "threadId": "thread123456", - }, - ) - - -def test_parse_multipart_email_full(): - """ - Test parsing a multipart email with both plain text and HTML bodies. - """ - email_data = { - "id": "email123", - "threadId": "thread123", - "labelIds": ["INBOX", "UNREAD"], - "historyId": "history123", - "snippet": "This is a test email.", - "payload": { - "headers": [ - {"name": "To", "value": "recipient@example.com"}, - {"name": "From", "value": "sender@example.com"}, - {"name": "Subject", "value": "Test Email"}, - {"name": "Date", "value": "Mon, 1 Jan 2024 10:00:00 -0000"}, - ], - "body": {"size": 100, "data": "VGhpcyBpcyBhIHRlc3QgZW1haWwu"}, - }, - } - - with ( - patch("arcade_google.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_google.utils._get_email_html_body") as mock_html, - patch("arcade_google.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "This is a test email." - mock_html.return_value = "

This is a test email.

" - mock_clean.return_value = "This is a test email." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email123" - assert result["thread_id"] == "thread123" - assert result["label_ids"] == ["INBOX", "UNREAD"] - assert result["snippet"] == "This is a test email." - assert result["to"] == "recipient@example.com" - assert result["from"] == "sender@example.com" - assert result["subject"] == "Test Email" - assert result["date"] == "Mon, 1 Jan 2024 10:00:00 -0000" - assert result["plain_text_body"] == "This is a test email." - assert result["html_body"] == "

This is a test email.

" - - -def test_parse_multipart_email_plain_only(): - """ - Test parsing an email with only a plain text body. - """ - email_data = { - "id": "email456", - "threadId": "thread456", - "labelIds": ["INBOX"], - "historyId": "history456", - "snippet": "Plain text only email.", - "payload": { - "headers": [ - {"name": "To", "value": "recipient2@example.com"}, - {"name": "From", "value": "sender2@example.com"}, - {"name": "Subject", "value": "Plain Text Email"}, - {"name": "Date", "value": "Tue, 2 Feb 2024 11:00:00 -0000"}, - ], - "body": {"size": 150, "data": "UGxhaW4gdGV4dCBvbmx5IGVtYWlsLg=="}, - }, - } - - with ( - patch("arcade_google.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_google.utils._get_email_html_body") as mock_html, - patch("arcade_google.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "Plain text only email." - mock_html.return_value = None - mock_clean.return_value = "Plain text only email." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email456" - assert result["thread_id"] == "thread456" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Plain text only email." - assert result["to"] == "recipient2@example.com" - assert result["from"] == "sender2@example.com" - assert result["subject"] == "Plain Text Email" - assert result["date"] == "Tue, 2 Feb 2024 11:00:00 -0000" - assert result["plain_text_body"] == "Plain text only email." - assert result["html_body"] == "" - - -def test_parse_multipart_email_html_only(): - """ - Test parsing an email with only an HTML body. - """ - email_data = { - "id": "email789", - "threadId": "thread789", - "labelIds": ["SENT"], - "historyId": "history789", - "snippet": "HTML only email.", - "payload": { - "headers": [ - {"name": "To", "value": "recipient3@example.com"}, - {"name": "From", "value": "sender3@example.com"}, - {"name": "Subject", "value": "HTML Email"}, - {"name": "Date", "value": "Wed, 3 Mar 2024 12:00:00 -0000"}, - ], - "body": {"size": 200, "data": "PGh0bWw+VGhpcyBpcyBIVE1MIGVtYWlsLjwvaHRtbD4="}, - }, - } - - with ( - patch("arcade_google.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_google.utils._get_email_html_body") as mock_html, - patch("arcade_google.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = None - mock_html.return_value = "This is HTML email." - mock_clean.return_value = "This is HTML email." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email789" - assert result["thread_id"] == "thread789" - assert result["label_ids"] == ["SENT"] - assert result["snippet"] == "HTML only email." - assert result["to"] == "recipient3@example.com" - assert result["from"] == "sender3@example.com" - assert result["subject"] == "HTML Email" - assert result["date"] == "Wed, 3 Mar 2024 12:00:00 -0000" - assert result["plain_text_body"] == "This is HTML email." - assert result["html_body"] == "This is HTML email." - - -def test_parse_multipart_email_missing_payload(): - """ - Test parsing an email with missing payload. - """ - email_data = { - "id": "email000", - "threadId": "thread000", - "labelIds": ["INBOX"], - "historyId": "history000", - "snippet": "Missing payload email.", - # 'payload' key is missing - } - - result = parse_multipart_email(email_data) - - # Since payload is missing, headers and bodies should be default or empty - assert result["id"] == "email000" - assert result["thread_id"] == "thread000" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Missing payload email." - assert result["to"] == "" - assert result["from"] == "" - assert result["subject"] == "" - assert result["date"] == "" - assert result["plain_text_body"] == "" - assert result["html_body"] == "" - - -def test_parse_multipart_email_missing_headers(): - """ - Test parsing an email with missing headers in the payload. - """ - email_data = { - "id": "email111", - "threadId": "thread111", - "labelIds": ["INBOX"], - "historyId": "history111", - "snippet": "Missing headers email.", - "payload": { - # 'headers' key is missing - "body": {"size": 100, "data": "VGltZWw="} - }, - } - - with ( - patch("arcade_google.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_google.utils._get_email_html_body") as mock_html, - patch("arcade_google.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "Timeel" - mock_html.return_value = "

Timeel

" - mock_clean.return_value = "Timeel" - - result = parse_multipart_email(email_data) - - assert result["id"] == "email111" - assert result["thread_id"] == "thread111" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Missing headers email." - assert result["to"] == "" - assert result["from"] == "" - assert result["subject"] == "" - assert result["date"] == "" - assert result["plain_text_body"] == "Timeel" - assert result["html_body"] == "

Timeel

" - - -def test_parse_multipart_email_missing_fields(): - """ - Test parsing an email with some missing fields in headers. - """ - email_data = { - "id": "email222", - "threadId": "thread222", - "labelIds": ["INBOX"], - "historyId": "history222", - "snippet": "Missing some headers.", - "payload": { - "headers": [ - {"name": "From", "value": "sender4@example.com"}, - {"name": "Subject", "value": "Partial Headers"}, - # 'To' and 'Date' headers are missing - ], - "body": {"size": 100, "data": "TWlzc2luZyBzb21lIGhlYWRlcnMu"}, - }, - } - - with ( - patch("arcade_google.utils._get_email_plain_text_body") as mock_plain, - patch("arcade_google.utils._get_email_html_body") as mock_html, - patch("arcade_google.utils._clean_email_body") as mock_clean, - ): - # Mock the helper functions - mock_plain.return_value = "Missing some headers." - mock_html.return_value = None - mock_clean.return_value = "Missing some headers." - - result = parse_multipart_email(email_data) - - assert result["id"] == "email222" - assert result["thread_id"] == "thread222" - assert result["label_ids"] == ["INBOX"] - assert result["snippet"] == "Missing some headers." - assert result["to"] == "" - assert result["from"] == "sender4@example.com" - assert result["subject"] == "Partial Headers" - assert result["date"] == "" - assert result["plain_text_body"] == "Missing some headers." - assert result["html_body"] == "" - - -def test_parse_multipart_email_empty(): - """ - Test parsing an empty email data. - """ - email_data = {} - - result = parse_multipart_email(email_data) - - assert result["id"] == "" - assert result["thread_id"] == "" - assert result["label_ids"] == [] - assert result["snippet"] == "" - assert result["to"] == "" - assert result["from"] == "" - assert result["subject"] == "" - assert result["date"] == "" - assert result["plain_text_body"] == "" - assert result["html_body"] == "" - - -def test_parse_multipart_email_invalid_payload_structure(): - """ - Test parsing an email with an invalid payload structure. - """ - email_data = { - "id": "email333", - "threadId": "thread333", - "labelIds": ["INBOX"], - "historyId": "history333", - "snippet": "Invalid payload structure.", - "payload": { - "headers": "This should be a list, not a string", - "body": {"size": 100, "data": "SW52YWxpZCBwYXlsb2Fk"}, - }, - } - - with pytest.raises(TypeError): - parse_multipart_email(email_data) diff --git a/toolkits/google/tests/test_sheets_models.py b/toolkits/google/tests/test_sheets_models.py deleted file mode 100644 index 2415cffa..00000000 --- a/toolkits/google/tests/test_sheets_models.py +++ /dev/null @@ -1,84 +0,0 @@ -from arcade_google.models import SheetDataInput - - -def test_sheet_input_data_init(): - data = '{"1":{"A":"name","B":"age","C":"email","D":"score","E":"gender","F":"city","G":"country","H":"registration_date"},"34":{"A":"Isla Green","B":24,"C":"islag@example.com","D":79,"E":"Female","F":"Chicago","G":"USA","H":"2024-01-10"},"38":{"A":"Mia Black","B":27,"C":"miab@example.com","D":80,"E":"Female","F":"Denver","G":"USA","H":"2024-01-30"},"39":{"A":"Nate Green","B":30,"C":"nateg@example.com","D":88,"E":"Male","F":"Orlando","G":"USA","H":"2024-02-01"},"43":{"A":100,"B":300,"C":234,"D":399,"E":5039,"F":2345,"G":23526,"H":123,"I":54,"J":234,"K":54,"L":23,"M":12,"N":57,"O":1324},"47":{"A":456,"B":234,"C":234,"D":399,"E":234,"F":1234,"G":23526,"H":123,"I":54,"J":234,"K":4567,"L":23,"M":12,"N":234,"O":1324}}' - expected_data = { - 1: { - "A": "name", - "B": "age", - "C": "email", - "D": "score", - "E": "gender", - "F": "city", - "G": "country", - "H": "registration_date", - }, - 34: { - "A": "Isla Green", - "B": 24, - "C": "islag@example.com", - "D": 79, - "E": "Female", - "F": "Chicago", - "G": "USA", - "H": "2024-01-10", - }, - 38: { - "A": "Mia Black", - "B": 27, - "C": "miab@example.com", - "D": 80, - "E": "Female", - "F": "Denver", - "G": "USA", - "H": "2024-01-30", - }, - 39: { - "A": "Nate Green", - "B": 30, - "C": "nateg@example.com", - "D": 88, - "E": "Male", - "F": "Orlando", - "G": "USA", - "H": "2024-02-01", - }, - 43: { - "A": 100, - "B": 300, - "C": 234, - "D": 399, - "E": 5039, - "F": 2345, - "G": 23526, - "H": 123, - "I": 54, - "J": 234, - "K": 54, - "L": 23, - "M": 12, - "N": 57, - "O": 1324, - }, - 47: { - "A": 456, - "B": 234, - "C": 234, - "D": 399, - "E": 234, - "F": 1234, - "G": 23526, - "H": 123, - "I": 54, - "J": 234, - "K": 4567, - "L": 23, - "M": 12, - "N": 234, - "O": 1324, - }, - } - - sheet_input_data = SheetDataInput(data=data) - assert sheet_input_data.data == expected_data diff --git a/toolkits/google/tests/test_sheets_utils.py b/toolkits/google/tests/test_sheets_utils.py deleted file mode 100644 index 26cb1c0a..00000000 --- a/toolkits/google/tests/test_sheets_utils.py +++ /dev/null @@ -1,542 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_google.models import ( - CellData, - CellExtendedValue, - NumberFormatType, - RowData, - SheetDataInput, -) -from arcade_google.utils import ( - col_to_index, - compute_sheet_data_dimensions, - convert_api_grid_data_to_dict, - create_cell_data, - create_row_data, - create_sheet_data, - create_sheet_properties, - extract_user_entered_cell_value, - group_contiguous_rows, - index_to_col, - is_col_greater, - process_row, - validate_write_to_cell_params, -) - - -@pytest.fixture -def sheet_data_input_fixture(): - data = { - 1: { - "A": "name", - "B": "age", - "C": "email", - "D": "score", - "E": "gender", - "F": "city", - "G": "country", - "H": "registration_date", - }, - 2: { - "A": "John Doe", - "B": 28, - "C": "johndoe@example.com", - "D": 85.4, - "E": "Male", - "F": "New York", - "G": "USA", - "H": "2023-01-15", - }, - 10: { - "A": "Nate Green", - "B": 30, - "C": "nateg@example.com", - "D": 88, - "E": "Male", - "F": "Orlando", - "G": "USA", - "H": "2024-02-01", - }, - 43: { - "A": 100, - "B": 300, - "H": 123, - "I": "=SUM(SEQUENCE(10))", - }, - 44: { - "A": 456, - "B": 234, - "H": 123, - "I": "=SUM(SEQUENCE(10))", - }, - } - return SheetDataInput(data=data) - - -@pytest.mark.parametrize( - "col, expected_index", - [ - ("A", 0), - ("B", 1), - ("Z", 25), - ("AA", 26 + 0), - ("AZ", (1 * 26) + 25), - ("BA", (2 * 26) + 0), - ("ZZ", (26 * 26) + 25), - ("AAA", (1 * 26 * 26) + (1 * 26) + 0), - ("AAB", (1 * 26 * 26) + (1 * 26) + 1), - ("QED", (17 * 26 * 26) + (5 * 26) + 3), - ], -) -def test_col_to_index(col, expected_index): - assert col_to_index(col) == expected_index - - -@pytest.mark.parametrize( - "index, expected_col", - [ - (0, "A"), - (1, "B"), - (25, "Z"), - (26 + 0, "AA"), - ((1 * 26) + 25, "AZ"), - ((2 * 26) + 0, "BA"), - ((26 * 26) + 25, "ZZ"), - ((1 * 26 * 26) + (1 * 26) + 0, "AAA"), - ((1 * 26 * 26) + (1 * 26) + 1, "AAB"), - ((17 * 26 * 26) + (5 * 26) + 3, "QED"), - ], -) -def test_index_to_col(index, expected_col): - assert index_to_col(index) == expected_col - - -@pytest.mark.parametrize( - "col1, col2, expected_result", - [ - ("A", "B", False), - ("B", "A", True), - ("AA", "AB", False), - ("AB", "AA", True), - ("A", "AA", False), - ("AA", "A", True), - ("Z", "AA", False), - ("AA", "Z", True), - ("AAA", "AAB", False), - ("AAB", "AAA", True), - ("QED", "QEE", False), - ("QEE", "QED", True), - ], -) -def test_is_col_greater(col1, col2, expected_result): - assert is_col_greater(col1, col2) == expected_result - - -def test_compute_sheet_data_dimensions(sheet_data_input_fixture): - (min_row, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions( - sheet_data_input_fixture - ) - - expected_min_row = 1 - expected_max_row = 44 - expected_min_col_index = 0 # Column "A" - expected_max_col_index = 8 # Column "I" - - assert min_row == expected_min_row - assert max_row == expected_max_row - assert min_col_index == expected_min_col_index - assert max_col_index == expected_max_col_index - - -def test_create_sheet_properties(): - sheet_properties = create_sheet_properties( - sheet_id=1, - title="Sheet1", - row_count=10000, - column_count=260, - ) - - assert sheet_properties.sheetId == 1 - assert sheet_properties.title == "Sheet1" - assert sheet_properties.gridProperties.rowCount == 10000 - assert sheet_properties.gridProperties.columnCount == 260 - - -@pytest.mark.parametrize( - "row_numbers, expected_groups", - [ - ([], []), - ([5, 6, 7], [[5, 6, 7]]), - ( - [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 18, 19, 20], - [[1, 2, 3], [5, 6, 7, 8, 9, 10, 11], [18, 19, 20]], - ), - ], -) -def test_group_contiguous_rows(row_numbers, expected_groups): - grouped_rows = group_contiguous_rows(row_numbers) - assert grouped_rows == expected_groups - - -@pytest.mark.parametrize( - "input_value, expected_key, expected_value, expected_type, expected_pattern", - [ - (1234, "numberValue", 1234, NumberFormatType.NUMBER, "#,##0"), - (1.234, "numberValue", 1.234, NumberFormatType.NUMBER, "#,##0.00"), - ("$100", "numberValue", 100, NumberFormatType.CURRENCY, "$#,##0"), - ("$100.50", "numberValue", 100.50, NumberFormatType.CURRENCY, "$#,##0.00"), - ("75%", "numberValue", 75.00, NumberFormatType.PERCENT, "0.00%"), - ("75.34%", "numberValue", 75.34, NumberFormatType.PERCENT, "0.00%"), - ("$1abc", "stringValue", "$1abc", None, None), - ("abc7%", "stringValue", "abc7%", None, None), - ("=SUM(A1:B1)", "formulaValue", "=SUM(A1:B1)", None, None), - (True, "boolValue", True, None, None), - ], -) -def test_create_cell_data( - input_value, expected_key, expected_value, expected_type, expected_pattern -): - cell_data = create_cell_data(input_value) - expected_cell_value = CellExtendedValue(**{expected_key: expected_value}) - assert cell_data.userEnteredValue == expected_cell_value - if expected_type is None: - assert cell_data.userEnteredFormat is None - else: - assert cell_data.userEnteredFormat is not None - assert cell_data.userEnteredFormat.numberFormat.type == expected_type - assert cell_data.userEnteredFormat.numberFormat.pattern == expected_pattern - - -def test_create_row_data(): - row_data = { - "A": 1, # Column index 0 - "B": 2.5, # Column index 1 - "AA": "test", # Column index 26 - "BA": True, # Column index 52 - "BB": "=SUM(A1:B1)", # Column index 53 - } - min_col_index = 0 # Column "A" - max_col_index = 53 # Column "BB" - - expected_row_data = RowData( - values=[ - CellData(userEnteredValue=CellExtendedValue(stringValue="")) - for _ in range(max_col_index + 1) - ] - ) - expected_row_data.values[0].userEnteredValue = CellExtendedValue(numberValue=1) - expected_row_data.values[1].userEnteredValue = CellExtendedValue(numberValue=2.5) - expected_row_data.values[26].userEnteredValue = CellExtendedValue(stringValue="test") - expected_row_data.values[52].userEnteredValue = CellExtendedValue(boolValue=True) - expected_row_data.values[53].userEnteredValue = CellExtendedValue(formulaValue="=SUM(A1:B1)") - - row_data = create_row_data(row_data, min_col_index, max_col_index) - - assert len(row_data.values) == len(expected_row_data.values) - for cell, expected in zip(row_data.values, expected_row_data.values, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - -def test_create_sheet_data(): - from arcade_google.models import CellData, CellExtendedValue, SheetDataInput - from arcade_google.utils import create_cell_data - - test_data = { - 2: {"B": "row2B", "C": 200}, - 3: {"B": "row3B"}, - 5: {"A": "=SUM(A1:A1)", "C": "row5C"}, - } - sheet_data_input = SheetDataInput(data=test_data) - min_col_index = 0 # Column "A" - max_col_index = 2 # Column "C" - - grid_data_list = create_sheet_data(sheet_data_input, min_col_index, max_col_index) - - assert len(grid_data_list) == 2, "Should have two groups of contiguous rows" - - group1 = grid_data_list[0] - assert group1.startRow == 1 - assert group1.startColumn == min_col_index - assert len(group1.rowData) == 2 - - row2_cells = group1.rowData[0].values - expected_row2 = [ - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - create_cell_data("row2B"), - create_cell_data(200), - ] - for cell, expected in zip(row2_cells, expected_row2, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - row3_cells = group1.rowData[1].values - expected_row3 = [ - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - create_cell_data("row3B"), - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - ] - for cell, expected in zip(row3_cells, expected_row3, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - group2 = grid_data_list[1] - assert group2.startRow == 4 - assert group2.startColumn == min_col_index - assert len(group2.rowData) == 1 - - row5_cells = group2.rowData[0].values - expected_row5 = [ - create_cell_data("=SUM(A1:A1)"), - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - create_cell_data("row5C"), - ] - for cell, expected in zip(row5_cells, expected_row5, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - -@pytest.mark.parametrize( - "cell, expected", - [ - ({}, ""), - ({"userEnteredValue": {}}, ""), - ({"userEnteredValue": {"stringValue": "hello"}}, "hello"), - ({"userEnteredValue": {"numberValue": 123}}, 123), - ({"userEnteredValue": {"boolValue": True}}, True), - ({"userEnteredValue": {"formulaValue": "=SUM(A1:A2)"}}, "=SUM(A1:A2)"), - ], -) -def test_extract_user_entered_cell_value(cell, expected): - result = extract_user_entered_cell_value(cell) - assert result == expected - - -def test_process_row_empty(): - row = {} - assert process_row(row, 0) == {} - - -def test_process_row_non_empty(): - row = { - "values": [ - {"userEnteredValue": {"stringValue": "cell1"}, "formattedValue": "cell1"}, - {"userEnteredValue": {}}, # should be ignored - {"userEnteredValue": {"formulaValue": "=C1+D4"}, "formattedValue": 42}, - {"userEnteredValue": {"stringValue": ""}, "formattedValue": ""}, # should be ignored - {"userEnteredValue": {"boolValue": False}, "formattedValue": False}, - ] - } - expected = { - "A": {"userEnteredValue": "cell1", "formattedValue": "cell1"}, - "C": {"userEnteredValue": "=C1+D4", "formattedValue": 42}, - "E": {"userEnteredValue": False, "formattedValue": False}, - } - - assert process_row(row, 0) == expected - - -def test_process_row_with_start_index(): - row = { - "values": [ - {"userEnteredValue": {"stringValue": "x"}, "formattedValue": "x"}, - {"userEnteredValue": {"formulaValue": "=C1+D4"}, "formattedValue": "$10.00"}, - ] - } - expected = { - "C": {"userEnteredValue": "x", "formattedValue": "x"}, - "D": {"userEnteredValue": "=C1+D4", "formattedValue": "$10.00"}, - } - - assert process_row(row, 2) == expected - - -def test_convert_api_grid_data_to_dict_single_grid(): - data = [ - { - "startRow": 0, - "startColumn": 0, - "rowData": [ - { - "values": [ - {"userEnteredValue": {"stringValue": "A1"}, "formattedValue": "A1"}, - {"userEnteredValue": {"numberValue": 1}, "formattedValue": 1}, - ] - }, - { - "values": [ - {"userEnteredValue": {"stringValue": "A2"}, "formattedValue": "A2"}, - {"userEnteredValue": {"numberValue": 2}, "formattedValue": 2}, - ] - }, - { - "values": [ - {"userEnteredValue": {}}, - { - "userEnteredValue": {"stringValue": "ignored"}, - "formattedValue": "ignored", - }, - {"userEnteredValue": {"numberValue": 3}, "formattedValue": 3}, - ] - }, - ], - } - ] - result = convert_api_grid_data_to_dict(data) - expected = { - 1: { - "A": {"userEnteredValue": "A1", "formattedValue": "A1"}, - "B": {"userEnteredValue": 1, "formattedValue": 1}, - }, - 2: { - "A": {"userEnteredValue": "A2", "formattedValue": "A2"}, - "B": {"userEnteredValue": 2, "formattedValue": 2}, - }, - 3: { - "B": {"userEnteredValue": "ignored", "formattedValue": "ignored"}, - "C": {"userEnteredValue": 3, "formattedValue": 3}, - }, - } - - assert result == expected - - -def test_convert_api_grid_data_to_dict_multiple_grids(): - data = [ - { - "startRow": 5, - "startColumn": 1, - "rowData": [ - { - "values": [ - {"userEnteredValue": {"numberValue": 100}, "formattedValue": 100}, - {"userEnteredValue": {"stringValue": "=SUM(A1:A2)"}, "formattedValue": 23}, - ] - } - ], - }, - { - "startRow": 0, - "startColumn": 0, - "rowData": [ - { - "values": [ - {"userEnteredValue": {"stringValue": "First"}, "formattedValue": "First"}, - {"userEnteredValue": {"numberValue": 10}, "formattedValue": 10}, - ] - } - ], - }, - ] - result = convert_api_grid_data_to_dict(data) - expected = { - 1: { - "A": {"userEnteredValue": "First", "formattedValue": "First"}, - "B": {"userEnteredValue": 10, "formattedValue": 10}, - }, - 6: { - "B": {"userEnteredValue": 100, "formattedValue": 100}, - "C": {"userEnteredValue": "=SUM(A1:A2)", "formattedValue": 23}, - }, - } - - assert result == expected - - -def test_convert_api_grid_data_to_dict_empty_rows(): - data = [ - { - "startRow": 10, - "startColumn": 0, - "rowData": [ - {"values": [{"userEnteredValue": {}, "formattedValue": ""}]}, - {"values": []}, - ], - } - ] - result = convert_api_grid_data_to_dict(data) - expected = {} - - assert result == expected - - -FAKE_SHEET_RESPONSE = { - "sheets": [ - {"properties": {"title": "Sheet1", "gridProperties": {"rowCount": 10, "columnCount": 6}}} - ] -} - - -@patch("arcade_google.utils.build_sheets_service") -def test_validate_write_to_cell_params_valid(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="Sheet1", - column="B", - row=5, - ) - - -@patch("arcade_google.utils.build_sheets_service") -def test_validate_write_to_cell_params_invalid_sheet_name(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - with pytest.raises(RetryableToolError) as excinfo: - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="NonExistentSheet", - column="A", - row=5, - ) - assert "Sheet name NonExistentSheet not found" in str(excinfo.value) - - -@patch("arcade_google.utils.build_sheets_service") -def test_validate_write_to_cell_params_row_out_of_bounds(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - out_of_bounds_row = 15 - with pytest.raises(ToolExecutionError) as excinfo: - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="Sheet1", - column="A", - row=out_of_bounds_row, - ) - assert f"Row {out_of_bounds_row} is out of bounds" in str(excinfo.value) - - -@patch("arcade_google.utils.build_sheets_service") -def test_validate_write_to_cell_params_column_out_of_bounds(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - out_of_bounds_column = "Z" - with pytest.raises(ToolExecutionError) as excinfo: - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="Sheet1", - column=out_of_bounds_column, - row=5, - ) - assert f"Column {out_of_bounds_column} is out of bounds" in str(excinfo.value) diff --git a/toolkits/google_calendar/.pre-commit-config.yaml b/toolkits/google_calendar/.pre-commit-config.yaml deleted file mode 100644 index f714abbc..00000000 --- a/toolkits/google_calendar/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_calendar/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_calendar/.ruff.toml b/toolkits/google_calendar/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_calendar/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_calendar/Makefile b/toolkits/google_calendar/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_calendar/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_calendar/arcade_google_calendar/__init__.py b/toolkits/google_calendar/arcade_google_calendar/__init__.py deleted file mode 100644 index d77091d3..00000000 --- a/toolkits/google_calendar/arcade_google_calendar/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from arcade_google_calendar.tools import ( - create_event, - delete_event, - find_time_slots_when_everyone_is_free, - list_calendars, - list_events, - update_event, -) - -__all__ = [ - "create_event", - "delete_event", - "find_time_slots_when_everyone_is_free", - "list_calendars", - "list_events", - "update_event", -] diff --git a/toolkits/google_calendar/arcade_google_calendar/enums.py b/toolkits/google_calendar/arcade_google_calendar/enums.py deleted file mode 100644 index 5002df52..00000000 --- a/toolkits/google_calendar/arcade_google_calendar/enums.py +++ /dev/null @@ -1,14 +0,0 @@ -from enum import Enum - - -class EventVisibility(Enum): - DEFAULT = "default" - PUBLIC = "public" - PRIVATE = "private" - CONFIDENTIAL = "confidential" - - -class SendUpdatesOptions(Enum): - NONE = "none" # No notifications are sent - ALL = "all" # Notifications are sent to all guests - EXTERNAL_ONLY = "externalOnly" # Notifications are sent to non-Google Calendar guests only. diff --git a/toolkits/google_calendar/arcade_google_calendar/tools/__init__.py b/toolkits/google_calendar/arcade_google_calendar/tools/__init__.py deleted file mode 100644 index bd347920..00000000 --- a/toolkits/google_calendar/arcade_google_calendar/tools/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from arcade_google_calendar.tools.calendar import ( - create_event, - delete_event, - find_time_slots_when_everyone_is_free, - list_calendars, - list_events, - update_event, -) - -__all__ = [ - "create_event", - "delete_event", - "find_time_slots_when_everyone_is_free", - "list_calendars", - "list_events", - "update_event", -] diff --git a/toolkits/google_calendar/arcade_google_calendar/tools/calendar.py b/toolkits/google_calendar/arcade_google_calendar/tools/calendar.py deleted file mode 100644 index e6ddb9df..00000000 --- a/toolkits/google_calendar/arcade_google_calendar/tools/calendar.py +++ /dev/null @@ -1,510 +0,0 @@ -import json -from datetime import datetime, timedelta -from typing import Annotated, Any -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import RetryableToolError -from googleapiclient.errors import HttpError - -from arcade_google_calendar.enums import EventVisibility, SendUpdatesOptions -from arcade_google_calendar.utils import ( - build_calendar_service, - build_oauth_service, - compute_free_time_intersection, - parse_datetime, -) - - -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - ] - ) -) -async def list_calendars( - context: ToolContext, - max_results: Annotated[ - int, "The maximum number of calendars to return. Up to 250 calendars, defaults to 10." - ] = 10, - show_deleted: Annotated[bool, "Whether to show deleted calendars. Defaults to False"] = False, - show_hidden: Annotated[bool, "Whether to show hidden calendars. Defaults to False"] = False, - next_page_token: Annotated[ - str | None, "The token to retrieve the next page of calendars. Optional." - ] = None, -) -> Annotated[dict, "A dictionary containing the calendars accessible by the end user"]: - """ - List all calendars accessible by the user. - """ - max_results = max(1, min(max_results, 250)) - service = build_calendar_service(context.get_auth_token_or_empty()) - calendars = ( - service.calendarList() - .list( - pageToken=next_page_token, - showDeleted=show_deleted, - showHidden=show_hidden, - maxResults=max_results, - ) - .execute() - ) - - items = calendars.get("items", []) - keys = ["description", "id", "summary", "timeZone"] - relevant_items = [{k: i.get(k) for k in keys if i.get(k)} for i in items] - return { - "next_page_token": calendars.get("nextPageToken"), - "num_calendars": len(relevant_items), - "calendars": relevant_items, - } - - -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - ], - ) -) -async def create_event( - context: ToolContext, - summary: Annotated[str, "The title of the event"], - start_datetime: Annotated[ - str, - "The datetime when the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.", - ], - end_datetime: Annotated[ - str, - "The datetime when the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.", - ], - calendar_id: Annotated[ - str, "The ID of the calendar to create the event in, usually 'primary'." - ] = "primary", - description: Annotated[str | None, "The description of the event"] = None, - location: Annotated[str | None, "The location of the event"] = None, - visibility: Annotated[EventVisibility, "The visibility of the event"] = EventVisibility.DEFAULT, - attendee_emails: Annotated[ - list[str] | None, - "The list of attendee emails. Must be valid email addresses e.g., username@domain.com.", - ] = None, -) -> Annotated[dict, "A dictionary containing the created event details"]: - """Create a new event/meeting/sync/meetup in the specified calendar.""" - - service = build_calendar_service(context.get_auth_token_or_empty()) - - # Get the calendar's time zone - calendar = service.calendars().get(calendarId=calendar_id).execute() - time_zone = calendar["timeZone"] - - # Parse datetime strings - start_dt = parse_datetime(start_datetime, time_zone) - end_dt = parse_datetime(end_datetime, time_zone) - - event: dict[str, Any] = { - "summary": summary, - "description": description, - "location": location, - "start": {"dateTime": start_dt.isoformat(), "timeZone": time_zone}, - "end": {"dateTime": end_dt.isoformat(), "timeZone": time_zone}, - "visibility": visibility.value, - } - - if attendee_emails: - event["attendees"] = [{"email": email} for email in attendee_emails] - - created_event = service.events().insert(calendarId=calendar_id, body=event).execute() - return {"event": created_event} - - -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - ], - ) -) -async def list_events( - context: ToolContext, - min_end_datetime: Annotated[ - str, - "Filter by events that end on or after this datetime in ISO 8601 format, " - "e.g., '2024-09-15T09:00:00'.", - ], - max_start_datetime: Annotated[ - str, - "Filter by events that start before this datetime in ISO 8601 format, " - "e.g., '2024-09-16T17:00:00'.", - ], - calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary", - max_results: Annotated[int, "The maximum number of events to return"] = 10, -) -> Annotated[dict, "A dictionary containing the list of events"]: - """ - List events from the specified calendar within the given datetime range. - - min_end_datetime serves as the lower bound (exclusive) for an event's end time. - max_start_datetime serves as the upper bound (exclusive) for an event's start time. - - For example: - If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime - is set to 2024-09-16T17:00:00, the function will return events that: - 1. End after 09:00 on September 15, 2024 (exclusive) - 2. Start before 17:00 on September 16, 2024 (exclusive) - This means an event starting at 08:00 on September 15 and - ending at 10:00 on September 15 would be included, but an - event starting at 17:00 on September 16 would not be included. - """ - service = build_calendar_service(context.get_auth_token_or_empty()) - - # Get the calendar's time zone - calendar = service.calendars().get(calendarId=calendar_id).execute() - time_zone = calendar["timeZone"] - - # Parse datetime strings - min_end_dt = parse_datetime(min_end_datetime, time_zone) - max_start_dt = parse_datetime(max_start_datetime, time_zone) - - if min_end_dt > max_start_dt: - min_end_dt, max_start_dt = max_start_dt, min_end_dt - - events_result = ( - service.events() - .list( - calendarId=calendar_id, - timeMin=min_end_dt.isoformat(), - timeMax=max_start_dt.isoformat(), - maxResults=max_results, - singleEvents=True, - orderBy="startTime", - ) - .execute() - ) - - items_keys = [ - "attachments", - "attendees", - "creator", - "description", - "end", - "eventType", - "htmlLink", - "id", - "location", - "organizer", - "start", - "summary", - "visibility", - ] - - events = [ - {key: event[key] for key in items_keys if key in event} - for event in events_result.get("items", []) - ] - - return {"events_count": len(events), "events": events} - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/calendar.events"], - ) -) -async def update_event( - context: ToolContext, - event_id: Annotated[str, "The ID of the event to update"], - updated_start_datetime: Annotated[ - str | None, - "The updated datetime that the event starts in ISO 8601 format, " - "e.g., '2024-12-31T15:30:00'.", - ] = None, - updated_end_datetime: Annotated[ - str | None, - "The updated datetime that the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.", - ] = None, - updated_calendar_id: Annotated[ - str | None, "The updated ID of the calendar containing the event." - ] = None, - updated_summary: Annotated[str | None, "The updated title of the event"] = None, - updated_description: Annotated[str | None, "The updated description of the event"] = None, - updated_location: Annotated[str | None, "The updated location of the event"] = None, - updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None, - attendee_emails_to_add: Annotated[ - list[str] | None, - "The list of attendee emails to add. Must be valid email addresses " - "e.g., username@domain.com.", - ] = None, - attendee_emails_to_remove: Annotated[ - list[str] | None, - "The list of attendee emails to remove. Must be valid email addresses " - "e.g., username@domain.com.", - ] = None, - send_updates: Annotated[ - SendUpdatesOptions, - "Should attendees be notified of the update? (none, all, external_only)", - ] = SendUpdatesOptions.ALL, -) -> Annotated[ - str, - "A string containing the updated event details, including the event ID, update timestamp, " - "and a link to view the updated event.", -]: - """ - Update an existing event in the specified calendar with the provided details. - Only the provided fields will be updated; others will remain unchanged. - - `updated_start_datetime` and `updated_end_datetime` are - independent and can be provided separately. - """ - service = build_calendar_service(context.get_auth_token_or_empty()) - - calendar = service.calendars().get(calendarId="primary").execute() - time_zone = calendar["timeZone"] - - try: - event = service.events().get(calendarId="primary", eventId=event_id).execute() - except HttpError: - valid_events_with_id = ( - service.events() - .list( - calendarId="primary", - timeMin=(datetime.now() - timedelta(days=2)).isoformat(), - timeMax=(datetime.now() + timedelta(days=365)).isoformat(), - maxResults=50, - singleEvents=True, - orderBy="startTime", - ) - .execute() - ) - raise RetryableToolError( - f"Event with ID {event_id} not found.", - additional_prompt_content=( - f"Here is a list of valid events. The event_id parameter must match one of these: " - f"{valid_events_with_id}" - ), - retry_after_ms=1000, - developer_message=( - f"Event with ID {event_id} not found. Please try again with a valid event ID." - ), - ) - - update_fields = { - "start": {"dateTime": updated_start_datetime, "timeZone": time_zone} - if updated_start_datetime - else None, - "end": {"dateTime": updated_end_datetime, "timeZone": time_zone} - if updated_end_datetime - else None, - "calendarId": updated_calendar_id, - "sendUpdates": send_updates.value if send_updates else None, - "summary": updated_summary, - "description": updated_description, - "location": updated_location, - "visibility": updated_visibility.value if updated_visibility else None, - } - - event.update({k: v for k, v in update_fields.items() if v is not None}) - - if attendee_emails_to_remove: - event["attendees"] = [ - attendee - for attendee in event.get("attendees", []) - if attendee.get("email", "").lower() - not in [email.lower() for email in attendee_emails_to_remove] - ] - - if attendee_emails_to_add: - existing_emails = { - attendee.get("email", "").lower() for attendee in event.get("attendees", []) - } - new_attendees = [ - {"email": email} - for email in attendee_emails_to_add - if email.lower() not in existing_emails - ] - event["attendees"] = event.get("attendees", []) + new_attendees - - updated_event = ( - service.events() - .update( - calendarId="primary", - eventId=event_id, - sendUpdates=send_updates.value, - body=event, - ) - .execute() - ) - return ( - f"Event with ID {event_id} successfully updated at {updated_event['updated']}. " - f"View updated event at {updated_event['htmlLink']}" - ) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/calendar.events"], - ) -) -async def delete_event( - context: ToolContext, - event_id: Annotated[str, "The ID of the event to delete"], - calendar_id: Annotated[str, "The ID of the calendar containing the event"] = "primary", - send_updates: Annotated[ - SendUpdatesOptions, "Specifies which attendees to notify about the deletion" - ] = SendUpdatesOptions.ALL, -) -> Annotated[str, "A string containing the deletion confirmation message"]: - """Delete an event from Google Calendar.""" - service = build_calendar_service(context.get_auth_token_or_empty()) - - service.events().delete( - calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value - ).execute() - - notification_message = "" - if send_updates == SendUpdatesOptions.ALL: - notification_message = "Notifications were sent to all attendees." - elif send_updates == SendUpdatesOptions.EXTERNAL_ONLY: - notification_message = "Notifications were sent to external attendees only." - elif send_updates == SendUpdatesOptions.NONE: - notification_message = "No notifications were sent to attendees." - - return ( - f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. " - f"{notification_message}" - ) - - -# TODO: would be nice to have a "min_slot_duration" parameter -# TODO: find a way to have "include_weekends" parameter without confusing LLMs -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/calendar.readonly"], - ), -) -async def find_time_slots_when_everyone_is_free( - context: ToolContext, - email_addresses: Annotated[ - list[str] | None, - "The list of email addresses from people in the same organization domain (apart from the " - "currently logged in user) to search for free time slots. Defaults to None, which will " - "return free time slots for the current user only.", - ] = None, - start_date: Annotated[ - str | None, - "The start date to search for time slots in the format 'YYYY-MM-DD'. Defaults to today's " - "date. It will search starting from this date at the time 00:00:00.", - ] = None, - end_date: Annotated[ - str | None, - "The end date to search for time slots in the format 'YYYY-MM-DD'. Defaults to seven days " - "from the start date. It will search until this date at the time 23:59:59.", - ] = None, - start_time_boundary: Annotated[ - str, - "Will return free slots in any given day starting from this time in the format 'HH:MM'. " - "Defaults to '08:00', which is a usual business hour start time.", - ] = "08:00", - end_time_boundary: Annotated[ - str, - "Will return free slots in any given day until this time in the format 'HH:MM'. " - "Defaults to '18:00', which is a usual business hour end time.", - ] = "18:00", -) -> Annotated[ - dict, - "A dictionary with the free slots and the timezone in which time slots are represented.", -]: - """ - Provides time slots when everyone is free within a given date range and time boundaries. - """ - - # Build google api services - oauth_service = build_oauth_service(context.get_auth_token_or_empty()) - calendar_service = build_calendar_service(context.get_auth_token_or_empty()) - - email_addresses = email_addresses or [] - - if isinstance(email_addresses, str): - email_addresses = [email_addresses] - - # Add the currently logged in user to the list of email addresses - user_info = oauth_service.userinfo().get().execute() - if user_info["email"] not in email_addresses: - email_addresses.append(user_info["email"]) - - # Get the timezone of the currently logged in user - calendar = calendar_service.calendars().get(calendarId="primary").execute() - timezone_name = calendar.get("timeZone") - - try: - tz = ZoneInfo(timezone_name) - # If the calendar timezone name is not supported by Python's zoneinfo, use UTC - except ZoneInfoNotFoundError: - timezone_name = "UTC" - tz = ZoneInfo("UTC") - - # Set default start and end dates, if not provided by the caller - start_date = start_date or datetime.now(tz=tz).date().isoformat() - end_date = end_date or (datetime.now(tz=tz).date() + timedelta(days=7)).isoformat() - - # Parse start and end dates to datetime objects - start_datetime = datetime.strptime(start_date, "%Y-%m-%d").replace( - hour=0, minute=0, second=0, microsecond=0, tzinfo=tz - ) - end_datetime = datetime.strptime(end_date, "%Y-%m-%d").replace( - hour=23, minute=59, second=59, microsecond=0, tzinfo=tz - ) - - # Get the busy slots from the calendars of the users - freebusy_response = ( - calendar_service.freebusy() - .query( - body={ - "timeMin": start_datetime.isoformat(), - "timeMax": end_datetime.isoformat(), - "timeZone": timezone_name, - "items": [{"id": email_address} for email_address in email_addresses], - } - ) - .execute() - ) - busy_slots = freebusy_response["calendars"] - - response_errors = [] - - for email in email_addresses: - if "errors" not in busy_slots[email]: - continue - errors = busy_slots[email]["errors"] - for error in errors: - response_errors.append( - f"Error retrieving free slots from calendar of '{email}': " - f"{error.get('reason', 'not determined')}" - ) - - if response_errors: - raise RetryableToolError( - "Error retrieving free slots from calendars of one or more users.", - additional_prompt_content=json.dumps(response_errors), - retry_after_ms=1000, - developer_message="Error retrieving free slots from calendars of one or more users.", - ) - - # Compute the free slots - free_slots = compute_free_time_intersection( - busy_data=busy_slots, - global_start=start_datetime, - global_end=end_datetime, - start_time_boundary=datetime.strptime(start_time_boundary, "%H:%M") - .time() - .replace(tzinfo=tz), - end_time_boundary=datetime.strptime(end_time_boundary, "%H:%M").time().replace(tzinfo=tz), - include_weekends=True, - tz=tz, - ) - - return { - "free_slots": free_slots, - "timezone": timezone_name, - } diff --git a/toolkits/google_calendar/arcade_google_calendar/utils.py b/toolkits/google_calendar/arcade_google_calendar/utils.py deleted file mode 100644 index d70da2bb..00000000 --- a/toolkits/google_calendar/arcade_google_calendar/utils.py +++ /dev/null @@ -1,249 +0,0 @@ -import logging -from datetime import date, datetime, time, timedelta, timezone -from typing import Any -from zoneinfo import ZoneInfo - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def parse_datetime(datetime_str: str, time_zone: str) -> datetime: - """ - Parse a datetime string in ISO 8601 format and ensure it is timezone-aware. - - Args: - datetime_str (str): The datetime string to parse. Expected format: 'YYYY-MM-DDTHH:MM:SS'. - time_zone (str): The timezone to apply if the datetime string is naive. - - Returns: - datetime: A timezone-aware datetime object. - - Raises: - ValueError: If the datetime string is not in the correct format. - """ - datetime_str = datetime_str.upper().strip().rstrip("Z") - try: - dt = datetime.fromisoformat(datetime_str) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo(time_zone)) - except ValueError as e: - raise ValueError( - f"Invalid datetime format: '{datetime_str}'. " - "Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'." - ) from e - return dt - - -def build_oauth_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build an OAuth2 service object. - """ - auth_token = auth_token or "" - return build("oauth2", "v2", credentials=Credentials(auth_token)) - - -def build_calendar_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Calendar service object. - """ - auth_token = auth_token or "" - return build("calendar", "v3", credentials=Credentials(auth_token)) - - -def weekday_to_name(weekday: int) -> str: - return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday] - - -def get_time_boundaries_for_date( - current_date: date, - global_start: datetime, - global_end: datetime, - start_time_boundary: time, - end_time_boundary: time, - tz: ZoneInfo, -) -> tuple[datetime, datetime]: - """Compute the allowed start and end times for the given day, adjusting for global bounds.""" - day_start_time = datetime.combine(current_date, start_time_boundary).replace(tzinfo=tz) - day_end_time = datetime.combine(current_date, end_time_boundary).replace(tzinfo=tz) - - if current_date == global_start.date(): - day_start_time = max(day_start_time, global_start) - - if current_date == global_end.date(): - day_end_time = min(day_end_time, global_end) - - return day_start_time, day_end_time - - -def gather_busy_intervals( - busy_data: dict[str, Any], - day_start: datetime, - day_end: datetime, - business_tz: ZoneInfo, -) -> list[tuple[datetime, datetime]]: - """ - Collect busy intervals from all calendars that intersect with the day's business hours. - Busy intervals are clipped to lie within [day_start, day_end]. - """ - busy_intervals = [] - for calendar in busy_data: - for slot in busy_data[calendar].get("busy", []): - slot_start = parse_rfc3339_datetime_str(slot["start"]).astimezone(business_tz) - slot_end = parse_rfc3339_datetime_str(slot["end"]).astimezone(business_tz) - if slot_end > day_start and slot_start < day_end: - busy_intervals.append((max(slot_start, day_start), min(slot_end, day_end))) - return busy_intervals - - -def subtract_busy_intervals( - business_start: datetime, - business_end: datetime, - busy_intervals: list[tuple[datetime, datetime]], -) -> list[dict[str, Any]]: - """ - Subtract the merged busy intervals from the business hours and return free time slots. - """ - free_slots = [] - merged_busy = merge_intervals(busy_intervals) - - # If there are no busy intervals, return the entire business window as free. - if not merged_busy: - return [ - { - "start": { - "datetime": business_start.isoformat(), - "weekday": weekday_to_name(business_start.weekday()), - }, - "end": { - "datetime": business_end.isoformat(), - "weekday": weekday_to_name(business_end.weekday()), - }, - } - ] - - current_free_start = business_start - for busy_start, busy_end in merged_busy: - if current_free_start < busy_start: - free_slots.append({ - "start": { - "datetime": current_free_start.isoformat(), - "weekday": weekday_to_name(current_free_start.weekday()), - }, - "end": { - "datetime": busy_start.isoformat(), - "weekday": weekday_to_name(busy_start.weekday()), - }, - }) - current_free_start = max(current_free_start, busy_end) - if current_free_start < business_end: - free_slots.append({ - "start": { - "datetime": current_free_start.isoformat(), - "weekday": weekday_to_name(current_free_start.weekday()), - }, - "end": { - "datetime": business_end.isoformat(), - "weekday": weekday_to_name(business_end.weekday()), - }, - }) - return free_slots - - -def compute_free_time_intersection( - busy_data: dict[str, Any], - global_start: datetime, - global_end: datetime, - start_time_boundary: time, - end_time_boundary: time, - include_weekends: bool, - tz: ZoneInfo, -) -> list[dict[str, Any]]: - """ - Returns the free time slots across all calendars within the global bounds, - ensuring that the global start is not in the past. - - Only considers business days (Monday to Friday) and business hours (08:00-19:00) - in the provided timezone. - """ - # Ensure global_start is never in the past relative to now. - now = get_now(tz) - - if now > global_start: - global_start = now - - # If after adjusting the start, there's no interval left, return empty. - if global_start >= global_end: - return [] - - free_slots = [] - current_date = global_start.date() - - while current_date <= global_end.date(): - if not include_weekends and current_date.weekday() >= 5: - current_date += timedelta(days=1) - continue - - day_start, day_end = get_time_boundaries_for_date( - current_date=current_date, - global_start=global_start, - global_end=global_end, - start_time_boundary=start_time_boundary, - end_time_boundary=end_time_boundary, - tz=tz, - ) - - # Skip if the day's allowed time window is empty. - if day_start >= day_end: - current_date += timedelta(days=1) - continue - - busy_intervals = gather_busy_intervals(busy_data, day_start, day_end, tz) - free_slots.extend(subtract_busy_intervals(day_start, day_end, busy_intervals)) - - current_date += timedelta(days=1) - - return free_slots - - -def get_now(tz: ZoneInfo | None = None) -> datetime: - if not tz: - tz = ZoneInfo("UTC") - return datetime.now(tz) - - -def parse_rfc3339_datetime_str(dt_str: str, tz: timezone = timezone.utc) -> datetime: - """ - Parse an RFC3339 datetime string into a timezone-aware datetime. - Converts a trailing 'Z' (UTC) into +00:00. - If the parsed datetime is naive, assume it is in the provided timezone. - """ - if dt_str.endswith("Z"): - dt_str = dt_str[:-1] + "+00:00" - dt = datetime.fromisoformat(dt_str) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=tz) - return dt - - -def merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]: - """ - Given a list of (start, end) tuples, merge overlapping or adjacent intervals. - """ - merged: list[tuple[datetime, datetime]] = [] - for start, end in sorted(intervals, key=lambda x: x[0]): - if not merged: - merged.append((start, end)) - else: - last_start, last_end = merged[-1] - if start <= last_end: - merged[-1] = (last_start, max(last_end, end)) - else: - merged.append((start, end)) - return merged diff --git a/toolkits/google_calendar/evals/eval_google_calendar.py b/toolkits/google_calendar/evals/eval_google_calendar.py deleted file mode 100644 index 665b8adf..00000000 --- a/toolkits/google_calendar/evals/eval_google_calendar.py +++ /dev/null @@ -1,215 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_calendar -from arcade_google_calendar.enums import EventVisibility, SendUpdatesOptions -from arcade_google_calendar.tools import ( - create_event, - delete_event, - list_calendars, - list_events, - update_event, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google_calendar) - -history_after_list_events = [ - {"role": "user", "content": "do i have any events on my calendar for today?"}, - { - "role": "assistant", - "content": "Please go to this URL and authorize the action: \n[Link](https://accounts.google.com/o/oauth2/v2/auth?)", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_uHdRlr4z7sFm39ZrPsE5wcdT", - "type": "function", - "function": { - "name": "GoogleCalendar_ListEvents", - "arguments": '{"min_end_datetime":"2024-09-26T00:00:00-07:00","max_start_datetime":"2024-09-27T00:00:00-07:00"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"events_count": 3, "events": [{"creator": {"email": "john@example.com", "self": true}, "description": "1:1 meeting with Joe", "end": {"dateTime": "2024-09-26T00:15:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=01234", "id": "10009199283838467", "location": "622 Rainbow Ave, South San Francisco, CA 94080, USA", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-25T23:15:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "1:1 meeting"}, {"attendees": [{"email": "joe@example.com", "responseStatus": "accepted"}], "creator": {"email": "john@example.com", "self": true}, "description": "This is just a test", "end": {"dateTime": "2024-09-26T14:00:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=OXB2OGFwcmZraWk1N234324", "id": "00099992228181818181", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-26T12:00:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "API test"}, {"attendees": [{"email": "henry@example.com", "responseStatus": "needsAction"}], "creator": {"email": "john@example.com", "self": true}, "end": {"dateTime": "2024-09-26T17:00:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=Z3I1ZzE4b324534556", "id": "gr5g18lf88tfpp3vkareukkc7g", "location": "611 Rainbow Road", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-26T15:00:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "Focus Time", "visibility": "public"}]}', - "tool_call_id": "call_uHdRlr4z7sFm39ZrPsE5wcdT", - "name": "GoogleCalendar_ListEvents", - }, - { - "role": "assistant", - "content": "Yes, you have three events on your calendar for today:\n\n1. **Event:** Test2\n - **Time:** 23:15 - 00:15 (PST)\n - **Location:** 611 Gateway Blvd, South San Francisco, CA 94080, USA\n - **Description:** 1:1 meeting with Joe\n 2. **Event:** API Test\n - **Time:** 12:00 - 14:00 (PST)\n **Description:** This is just a test\n - [View Event](https://www.google.com/calendar/event?eid=OXB2OGFwcmZraWk1NGUwa24xaTNya2lvZDggZXJpY0BhcmNhZGUtYWkuY29t)\n\n3. **Event:** Focus Time\n - **Time:** 15:00 - 17:00 (PST)\n - **Location:** 611 Rainbow Road\n - **Visibility:** Public\n - [View Event](https://www.google.com/calendar/event?eid=Z3I1ZzE4bGY4OHRmcHAzdmthcmV1a2tjN2cgZXJpY0BhcmNhZGUtYWkuY29t)\n\nIf you need more details or help with anything else, feel free to ask!", - }, -] - - -@tool_eval() -def calendar_eval_suite() -> EvalSuite: - """Create an evaluation suite for Calendar tools.""" - suite = EvalSuite( - name="Calendar Tools Evaluation", - system_message=( - "You are an AI assistant that can create, list, update, and delete events using the provided tools. Today is 2024-09-26" - ), - catalog=catalog, - rubric=rubric, - ) - - # Cases for list_calendars - suite.add_case( - name="List Calendars", - user_message=("What calendars do I have?"), - expected_tool_calls=[ - ExpectedToolCall( - func=list_calendars, - args={}, - ) - ], - critics=[], - ) - - # Cases for create_event - suite.add_case( - name="Create calendar event", - user_message=( - "Create a meeting for 'Team Meeting' starting on September 26, 2024, from 11:45pm to 12:15am. Invite johndoe@example.com" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_event, - args={ - "summary": "Team Meeting", - "start_datetime": "2024-09-26T23:45:00", - "end_datetime": "2024-09-27T00:15:00", - "calendar_id": "primary", - "attendee_emails": ["johndoe@example.com"], - "visibility": EventVisibility.DEFAULT, - "description": "Team Meeting", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="summary", weight=0.2), - DatetimeCritic( - critic_field="start_datetime", weight=0.2, tolerance=timedelta(seconds=10) - ), - DatetimeCritic( - critic_field="end_datetime", weight=0.2, tolerance=timedelta(seconds=10) - ), - BinaryCritic(critic_field="attendee_emails", weight=0.2), - BinaryCritic(critic_field="description", weight=0.1), - BinaryCritic(critic_field="location", weight=0.1), - ], - ) - - # Cases for list_events - suite.add_case( - name="List calendar events", - user_message="Do I have any events on my calendar today?", - expected_tool_calls=[ - ExpectedToolCall( - func=list_events, - args={ - "min_end_datetime": "2024-09-26T00:00:00", - "max_start_datetime": "2024-09-27T00:00:00", - "calendar_id": "primary", - "max_results": 10, - }, - ) - ], - critics=[ - DatetimeCritic( - critic_field="min_end_datetime", weight=0.3, tolerance=timedelta(hours=1) - ), - DatetimeCritic( - critic_field="max_start_datetime", weight=0.3, tolerance=timedelta(hours=1) - ), - BinaryCritic(critic_field="calendar_id", weight=0.2), - BinaryCritic(critic_field="max_results", weight=0.2), - ], - ) - - # Cases for update_event - suite.add_case( - name="Update a calendar event", - user_message=( - "Oh no! I can't make it to the API Test since I have lunch with an old friend at that time. " - "Change my meeting tomorrow at 3pm to 4pm. Let everyone know." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=update_event, - args={ - "event_id": "00099992228181818181", - "updated_start_datetime": "2024-09-27T16:00:00", - "updated_end_datetime": "2024-09-27T18:00:00", - "updated_calendar_id": "primary", - "updated_summary": "API Test", - "updated_description": "API Test", - "updated_location": "611 Gateway Blvd", - "updated_visibility": EventVisibility.DEFAULT, - "attendee_emails_to_add": None, - "attendee_emails_to_remove": None, - "send_updates": SendUpdatesOptions.ALL, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="event_id", weight=0.4), - DatetimeCritic( - critic_field="updated_start_datetime", weight=0.2, tolerance=timedelta(minutes=15) - ), - DatetimeCritic( - critic_field="updated_end_datetime", - weight=0.2, - tolerance=timedelta(minutes=15), - ), - BinaryCritic(critic_field="send_updates", weight=0.2), - ], - additional_messages=history_after_list_events, - ) - - # Cases for delete_event - suite.add_case( - name="Delete a calendar event", - user_message=( - "I don't need to have focus time today. Please delete it from my calendar. Don't send any notifications." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=delete_event, - args={ - "event_id": "gr5g18lf88tfpp3vkareukkc7g", - "calendar_id": "primary", - "send_updates": SendUpdatesOptions.NONE, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="event_id", weight=0.6), - BinaryCritic(critic_field="calendar_id", weight=0.2), - BinaryCritic(critic_field="send_updates", weight=0.2), - ], - additional_messages=history_after_list_events, - ) - - return suite diff --git a/toolkits/google_calendar/pyproject.toml b/toolkits/google_calendar/pyproject.toml deleted file mode 100644 index 55f2f2e2..00000000 --- a/toolkits/google_calendar/pyproject.toml +++ /dev/null @@ -1,63 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_calendar" -version = "2.0.0" -description = "Arcade.dev LLM tools for Google Calendar" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_calendar/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_calendar",] diff --git a/toolkits/google_calendar/tests/__init__.py b/toolkits/google_calendar/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_calendar/tests/test_calendar.py b/toolkits/google_calendar/tests/test_calendar.py deleted file mode 100644 index 82d1be1c..00000000 --- a/toolkits/google_calendar/tests/test_calendar.py +++ /dev/null @@ -1,582 +0,0 @@ -from datetime import datetime -from unittest.mock import MagicMock, patch -from zoneinfo import ZoneInfo - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google_calendar.enums import EventVisibility, SendUpdatesOptions -from arcade_google_calendar.tools import ( - create_event, - delete_event, - find_time_slots_when_everyone_is_free, - list_calendars, - list_events, - update_event, -) - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_list_calendars(mock_build_calendar_service, mock_context): - mock_service = MagicMock() - mock_build_calendar_service.return_value = mock_service - - expected_api_response = { - "etag": '"p33for2n0pvc8o0o"', - "items": [ - { - "accessRole": "reader", - "backgroundColor": "#16a765", - "colorId": "8", - "conferenceProperties": {"allowedConferenceSolutionTypes": ["hangoutsMeet"]}, - "defaultReminders": [], - "description": "Holidays and Observances in Brazil", - "etag": '"2347287866334000"', - "foregroundColor": "#000000", - "id": "en.brazilian#holiday@group.v.calendar.google.com", - "kind": "calendar#calendarListEntry", - "selected": True, - "summary": "Holidays in Brazil", - "timeZone": "America/Sao_Paulo", - }, - { - "accessRole": "owner", - "backgroundColor": "#9fe1e7", - "colorId": "14", - "conferenceProperties": {"allowedConferenceSolutionTypes": ["hangoutsMeet"]}, - "defaultReminders": [{"method": "popup", "minutes": 10}], - "etag": '"1743169667849567"', - "foregroundColor": "#000000", - "id": "example@arcade.dev", - "kind": "calendar#calendarListEntry", - "notificationSettings": { - "notifications": [ - {"method": "email", "type": "eventCreation"}, - {"method": "email", "type": "eventChange"}, - {"method": "email", "type": "eventCancellation"}, - {"method": "email", "type": "eventResponse"}, - ] - }, - "primary": True, - "selected": True, - "summary": "example@arcade.dev", - "timeZone": "America/Sao_Paulo", - }, - ], - "kind": "calendar#calendarList", - "nextSyncToken": "XkJ8Hy5mN2pQvL9sR4tW7cA3fE1iU6nB", - } - - expected_tool_response = { - "num_calendars": 2, - "calendars": [ - { - "description": "Holidays and Observances in Brazil", - "id": "en.brazilian#holiday@group.v.calendar.google.com", - "summary": "Holidays in Brazil", - "timeZone": "America/Sao_Paulo", - }, - { - "id": "example@arcade.dev", - "summary": "example@arcade.dev", - "timeZone": "America/Sao_Paulo", - }, - ], - "next_page_token": None, - } - - mock_service.calendarList().list().execute.return_value = expected_api_response - - response = await list_calendars(context=mock_context) - assert response == expected_tool_response - - # Case: HttpError during calendars listing - mock_service.calendarList().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_calendars(context=mock_context) - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_create_event(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock the calendar's time zone - mock_service.calendars().get().execute.return_value = {"timeZone": "America/Los_Angeles"} - - # Case: HttpError during event creation - mock_service.events().insert().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await create_event( - context=mock_context, - summary="Test Event", - start_datetime="2024-12-31T15:30:00", - end_datetime="2024-12-31T17:30:00", - description="Test Description", - location="Test Location", - visibility=EventVisibility.PRIVATE, - attendee_emails=["test@example.com"], - ) - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_list_events(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - # Mock the calendar's time zone - mock_service.calendars().get().execute.return_value = {"timeZone": "America/Los_Angeles"} - - # Mock the events list response - mock_events_list_response = { - "items": [ - { - "creator": {"email": "example@arcade-ai.com", "self": True}, - "end": {"dateTime": "2024-09-27T01:00:00-07:00", "timeZone": "America/Los_Angeles"}, - "eventType": "default", - "htmlLink": "https://www.google.com/calendar/event?eid=event1", - "id": "event1", - "organizer": {"email": "example@arcade-ai.com", "self": True}, - "start": { - "dateTime": "2024-09-27T00:00:00-07:00", - "timeZone": "America/Los_Angeles", - }, - "summary": "Event 1", - }, - { - "creator": {"email": "example@arcade-ai.com", "self": True}, - "end": {"dateTime": "2024-09-27T17:00:00-07:00", "timeZone": "America/Los_Angeles"}, - "eventType": "default", - "htmlLink": "https://www.google.com/calendar/event?eid=event2", - "id": "event2", - "organizer": {"email": "example@arcade-ai.com", "self": True}, - "start": { - "dateTime": "2024-09-27T14:00:00-07:00", - "timeZone": "America/Los_Angeles", - }, - "summary": "Event 2", - }, - ] - } - expected_tool_response = { - "events_count": len(mock_events_list_response["items"]), - "events": mock_events_list_response["items"], - } - mock_service.events().list().execute.return_value = mock_events_list_response - response = await list_events( - context=mock_context, - min_end_datetime="2024-09-15T09:00:00", - max_start_datetime="2024-09-16T17:00:00", - ) - assert response == expected_tool_response - - # Case: HttpError during events listing - mock_service.events().list().execute.side_effect = HttpError( - resp=MagicMock(status=400), - content=b'{"error": {"message": "Invalid request"}}', - ) - - with pytest.raises(ToolExecutionError): - await list_events( - context=mock_context, - min_end_datetime="2024-09-15T09:00:00", - max_start_datetime="2024-09-16T17:00:00", - ) - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_update_event(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - - # Mock retrieval of the event - mock_service.events().get().execute.side_effect = HttpError( - resp=MagicMock(status=404), - content=b'{"error": {"message": "Event not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await update_event( - context=mock_context, - event_id="1234567890", - updated_start_datetime="2024-12-31T00:15:00", - updated_end_datetime="2024-12-31T01:15:00", - updated_summary="Updated Event", - updated_description="Updated Description", - updated_location="Updated Location", - updated_visibility=EventVisibility.PRIVATE, - attendee_emails_to_add=["test@example.com"], - attendee_emails_to_remove=["test@example2.com"], - send_updates=SendUpdatesOptions.ALL, - ) - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_delete_event(mock_build, mock_context): - mock_service = MagicMock() - mock_build.return_value = mock_service - mock_service.events().delete().execute.side_effect = HttpError( - resp=MagicMock(status=404), - content=b'{"error": {"message": "Event not found"}}', - ) - - with pytest.raises(ToolExecutionError): - await delete_event( - context=mock_context, - event_id="nonexistent_event", - send_updates=SendUpdatesOptions.ALL, - ) - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.utils.get_now") -@patch("arcade_google_calendar.tools.calendar.build_oauth_service") -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_find_free_slots_happiest_path_single_user( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": {"busy": []}, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - response = await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) - - assert response == { - "free_slots": [ - { - "start": { - "datetime": "2025-03-10T09:25:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T18:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-11T08:00:00-07:00", - "weekday": "Tuesday", - }, - "end": { - "datetime": "2025-03-11T18:00:00-07:00", - "weekday": "Tuesday", - }, - }, - ], - "timezone": "America/Los_Angeles", - } - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.utils.get_now") -@patch("arcade_google_calendar.tools.calendar.build_oauth_service") -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_find_free_slots_happiest_path_single_user_with_busy_times( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:00:00-07:00", - "end": "2025-03-10T12:00:00-07:00", - }, - { - "start": "2025-03-10T14:15:00-07:00", - "end": "2025-03-10T14:30:00-07:00", - }, - ] - }, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - response = await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) - - assert response == { - "free_slots": [ - { - "start": { - "datetime": "2025-03-10T09:25:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T11:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T12:00:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T14:15:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T14:30:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T18:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-11T08:00:00-07:00", - "weekday": "Tuesday", - }, - "end": { - "datetime": "2025-03-11T18:00:00-07:00", - "weekday": "Tuesday", - }, - }, - ], - "timezone": "America/Los_Angeles", - } - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.utils.get_now") -@patch("arcade_google_calendar.tools.calendar.build_oauth_service") -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_find_free_slots_happiest_path_multiple_users_with_busy_times( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:00:00-07:00", - "end": "2025-03-10T12:00:00-07:00", - }, - { - "start": "2025-03-10T14:15:00-07:00", - "end": "2025-03-10T14:30:00-07:00", - }, - ] - }, - "example2@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:30:00-07:00", - "end": "2025-03-10T12:45:00-07:00", - }, - { - "start": "2025-03-11T06:00:00-07:00", - "end": "2025-03-11T07:00:00-07:00", - }, - ] - }, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - response = await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev", "example2@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) - - assert response == { - "free_slots": [ - { - "start": { - "datetime": "2025-03-10T09:25:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T11:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T12:45:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T14:15:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-10T14:30:00-07:00", - "weekday": "Monday", - }, - "end": { - "datetime": "2025-03-10T18:00:00-07:00", - "weekday": "Monday", - }, - }, - { - "start": { - "datetime": "2025-03-11T08:00:00-07:00", - "weekday": "Tuesday", - }, - "end": { - "datetime": "2025-03-11T18:00:00-07:00", - "weekday": "Tuesday", - }, - }, - ], - "timezone": "America/Los_Angeles", - } - - -@pytest.mark.asyncio -@patch("arcade_google_calendar.utils.get_now") -@patch("arcade_google_calendar.tools.calendar.build_oauth_service") -@patch("arcade_google_calendar.tools.calendar.build_calendar_service") -async def test_find_free_slots_with_google_calendar_error_not_found( - mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context -): - calendar_service = MagicMock() - oauth_service = MagicMock() - - mock_get_now.return_value = datetime( - 2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles") - ) - mock_build_oauth_service.return_value = oauth_service - mock_build_calendar_service.return_value = calendar_service - - oauth_service.userinfo().get().execute.return_value = { - "email": "example@arcade.dev", - } - - calendar_service.freebusy().query().execute.return_value = { - "calendars": { - "example@arcade.dev": { - "busy": [ - { - "start": "2025-03-10T11:00:00-07:00", - "end": "2025-03-10T12:00:00-07:00", - }, - { - "start": "2025-03-10T14:15:00-07:00", - "end": "2025-03-10T14:30:00-07:00", - }, - ] - }, - "example2@arcade.dev": { - "errors": [ - { - "reason": "notFound", - "domain": "calendar", - } - ] - }, - } - } - - calendar_service.calendars().get().execute.return_value = { - "timeZone": "America/Los_Angeles", - } - - with pytest.raises(RetryableToolError): - await find_time_slots_when_everyone_is_free( - context=mock_context, - email_addresses=["example@arcade.dev", "example2@arcade.dev"], - start_date="2025-03-10", - end_date="2025-03-11", - start_time_boundary="08:00", - end_time_boundary="18:00", - ) diff --git a/toolkits/google_contacts/.pre-commit-config.yaml b/toolkits/google_contacts/.pre-commit-config.yaml deleted file mode 100644 index 0cf8d087..00000000 --- a/toolkits/google_contacts/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_contacts/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_contacts/.ruff.toml b/toolkits/google_contacts/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_contacts/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_contacts/Makefile b/toolkits/google_contacts/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_contacts/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_contacts/arcade_google_contacts/__init__.py b/toolkits/google_contacts/arcade_google_contacts/__init__.py deleted file mode 100644 index 32b78c1c..00000000 --- a/toolkits/google_contacts/arcade_google_contacts/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_google_contacts.tools import ( - create_contact, - search_contacts_by_email, - search_contacts_by_name, -) - -__all__ = ["create_contact", "search_contacts_by_email", "search_contacts_by_name"] diff --git a/toolkits/google_contacts/arcade_google_contacts/constants.py b/toolkits/google_contacts/arcade_google_contacts/constants.py deleted file mode 100644 index 580d7f09..00000000 --- a/toolkits/google_contacts/arcade_google_contacts/constants.py +++ /dev/null @@ -1 +0,0 @@ -DEFAULT_SEARCH_CONTACTS_LIMIT = 30 diff --git a/toolkits/google_contacts/arcade_google_contacts/tools/__init__.py b/toolkits/google_contacts/arcade_google_contacts/tools/__init__.py deleted file mode 100644 index e38af883..00000000 --- a/toolkits/google_contacts/arcade_google_contacts/tools/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_google_contacts.tools.contacts import ( - create_contact, - search_contacts_by_email, - search_contacts_by_name, -) - -__all__ = ["create_contact", "search_contacts_by_email", "search_contacts_by_name"] diff --git a/toolkits/google_contacts/arcade_google_contacts/tools/contacts.py b/toolkits/google_contacts/arcade_google_contacts/tools/contacts.py deleted file mode 100644 index 6a0791dd..00000000 --- a/toolkits/google_contacts/arcade_google_contacts/tools/contacts.py +++ /dev/null @@ -1,96 +0,0 @@ -import asyncio -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google - -from arcade_google_contacts.constants import DEFAULT_SEARCH_CONTACTS_LIMIT -from arcade_google_contacts.utils import build_people_service, search_contacts - - -async def _warmup_cache(service) -> None: # type: ignore[no-untyped-def] - """ - Warm-up the search cache for contacts by sending a request with an empty query. - This ensures that the lazy cache is updated for both primary contacts and other contacts. - This is unfortunately a real thing: https://developers.google.com/people/v1/contacts#search_the_users_contacts - """ - service.people().searchContacts(query="", pageSize=1, readMask="names,emailAddresses").execute() - await asyncio.sleep(3) # TODO experiment with this value - - -@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"])) -async def search_contacts_by_email( - context: ToolContext, - email: Annotated[str, "The email address to search for"], - limit: Annotated[ - int | None, - "The maximum number of contacts to return (30 is the max allowed by Google API)", - ] = DEFAULT_SEARCH_CONTACTS_LIMIT, -) -> Annotated[dict, "A dictionary containing the list of matching contacts"]: - """ - Search the user's contacts in Google Contacts by email address. - """ - service = build_people_service(context.get_auth_token_or_empty()) - # Warm-up the cache before performing search. - # TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently - await _warmup_cache(service) - - return {"contacts": search_contacts(service, email, limit)} - - -@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"])) -async def search_contacts_by_name( - context: ToolContext, - name: Annotated[str, "The full name to search for"], - limit: Annotated[ - int | None, - "The maximum number of contacts to return (30 is the max allowed by Google API)", - ] = DEFAULT_SEARCH_CONTACTS_LIMIT, -) -> Annotated[dict, "A dictionary containing the list of matching contacts"]: - """ - Search the user's contacts in Google Contacts by name. - """ - service = build_people_service(context.get_auth_token_or_empty()) - # Warm-up the cache before performing search. - # TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently - await _warmup_cache(service) - return {"contacts": search_contacts(service, name, limit)} - - -@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts"])) -async def create_contact( - context: ToolContext, - given_name: Annotated[str, "The given name of the contact"], - family_name: Annotated[str | None, "The optional family name of the contact"], - email: Annotated[str | None, "The optional email address of the contact"], -) -> Annotated[dict, "A dictionary containing the details of the created contact"]: - """ - Create a new contact record in Google Contacts. - - Examples: - ``` - create_contact(given_name="Alice") - create_contact(given_name="Alice", family_name="Smith") - create_contact(given_name="Alice", email="alice@example.com") - ``` - """ - # Build the People API service - service = build_people_service(context.get_auth_token_or_empty()) - - # Construct the person payload with the specified names - name_body = {"givenName": given_name} - if family_name: - name_body["familyName"] = family_name - contact_body = {"names": [name_body]} - if email: - contact_body["emailAddresses"] = [{"value": email, "type": "work"}] - - # Create the contact. The personFields parameter specifies what information - # should be returned. Here, we return names and emailAddresses. - created_contact = ( - service.people() - .createContact(body=contact_body, personFields="names,emailAddresses") - .execute() - ) - - return {"contact": created_contact} diff --git a/toolkits/google_contacts/arcade_google_contacts/utils.py b/toolkits/google_contacts/arcade_google_contacts/utils.py deleted file mode 100644 index 159eb0af..00000000 --- a/toolkits/google_contacts/arcade_google_contacts/utils.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging -from typing import Any, cast - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build - -from arcade_google_contacts.constants import DEFAULT_SEARCH_CONTACTS_LIMIT - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def build_people_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a People service object. - """ - auth_token = auth_token or "" - return build("people", "v1", credentials=Credentials(auth_token)) - - -def search_contacts(service: Any, query: str, limit: int | None) -> list[dict[str, Any]]: - """ - Search the user's contacts in Google Contacts. - """ - response = ( - service.people() - .searchContacts( - query=query, - pageSize=limit or DEFAULT_SEARCH_CONTACTS_LIMIT, - readMask=",".join([ - "names", - "nicknames", - "emailAddresses", - "phoneNumbers", - "addresses", - "organizations", - "biographies", - "urls", - "userDefined", - ]), - ) - .execute() - ) - - return cast(list[dict[str, Any]], response.get("results", [])) diff --git a/toolkits/google_contacts/evals/eval_google_contacts.py b/toolkits/google_contacts/evals/eval_google_contacts.py deleted file mode 100644 index 83c7d294..00000000 --- a/toolkits/google_contacts/evals/eval_google_contacts.py +++ /dev/null @@ -1,135 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_contacts -from arcade_google_contacts.tools import ( - create_contact, - search_contacts_by_email, - search_contacts_by_name, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google_contacts) - - -@tool_eval() -def contacts_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Contacts tools.""" - suite = EvalSuite( - name="Google Contacts Tools Evaluation", - system_message="You are an AI assistant that can manage Google Contacts using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search contacts by name", - user_message="Find my contact Bob", - expected_tool_calls=[ - ExpectedToolCall( - func=search_contacts_by_name, - args={ - "name": "Bob", - }, - ) - ], - ) - - suite.add_case( - name="Search contacts by email", - user_message="Find my contact alice@example.com", - expected_tool_calls=[ - ExpectedToolCall( - func=search_contacts_by_email, - args={ - "email": "alice@example.com", - }, - ) - ], - ) - - suite.add_case( - name="Search contacts with query and limit", - user_message="Find 5 contacts whose names include 'Alice'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_contacts_by_name, - args={ - "name": "Alice", - "limit": 5, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="Create new contact with only given name", - user_message="Create a new contact for Alice", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "given_name": "Alice", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="given_name", weight=1.0), - ], - ) - - suite.add_case( - name="Create new contact with only email (infer name from email)", - user_message="Create a new contact for alice@example.com", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "given_name": "Alice", - "email": "alice@example.com", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="email", weight=0.5), - BinaryCritic(critic_field="given_name", weight=0.5), - ], - ) - - suite.add_case( - name="Create new contact with full name and email", - user_message="Create a contact for Bob Smith (bob.smith@example.com)", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "given_name": "Bob", - "family_name": "Smith", - "email": "bob.smith@example.com", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="given_name", weight=0.33), - BinaryCritic(critic_field="family_name", weight=0.33), - BinaryCritic(critic_field="email", weight=0.34), - ], - ) - - return suite diff --git a/toolkits/google_contacts/pyproject.toml b/toolkits/google_contacts/pyproject.toml deleted file mode 100644 index f3c9169a..00000000 --- a/toolkits/google_contacts/pyproject.toml +++ /dev/null @@ -1,63 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_contacts" -version = "2.0.0" -description = "Arcade.dev LLM tools for Google Contacts" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_contacts/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_contacts",] diff --git a/toolkits/google_contacts/tests/__init__.py b/toolkits/google_contacts/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_contacts/tests/test_contacts.py b/toolkits/google_contacts/tests/test_contacts.py deleted file mode 100644 index 04a4d865..00000000 --- a/toolkits/google_contacts/tests/test_contacts.py +++ /dev/null @@ -1,100 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from arcade_tdk import ToolContext - -from arcade_google_contacts.tools import create_contact - - -@pytest.fixture -def mock_context(): - context = AsyncMock(spec=ToolContext) - context.authorization = MagicMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.mark.asyncio -async def test_create_contact_success(mock_context): - # Test create_contact with all parameters (given, family names and email) - created_contact_data = {"resourceName": "people/123", "etag": "abc"} - - create_contact_call = MagicMock() - create_contact_call.execute.return_value = created_contact_data - - people_mock = MagicMock() - people_mock.createContact.return_value = create_contact_call - - service_mock = MagicMock() - service_mock.people.return_value = people_mock - - with patch( - "arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock - ) as mock_build: - result = await create_contact( - mock_context, - given_name="Alice", - family_name="Smith", - email="alice@example.com", - ) - assert "contact" in result - assert result["contact"] == created_contact_data - - # Verify that the createContact API was called with the correct body contents. - expected_body = { - "names": [{"givenName": "Alice", "familyName": "Smith"}], - "emailAddresses": [{"value": "alice@example.com", "type": "work"}], - } - people_mock.createContact.assert_called_once_with( - body=expected_body, personFields="names,emailAddresses" - ) - mock_build.assert_called_once() - - -@pytest.mark.asyncio -async def test_create_contact_success_without_optional(mock_context): - # Test create_contact without optional parameters family_name and email. - created_contact_data = {"resourceName": "people/456", "etag": "def"} - - create_contact_call = MagicMock() - create_contact_call.execute.return_value = created_contact_data - - people_mock = MagicMock() - people_mock.createContact.return_value = create_contact_call - - service_mock = MagicMock() - service_mock.people.return_value = people_mock - - with patch( - "arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock - ): - result = await create_contact(mock_context, given_name="Bob", family_name=None, email=None) - assert "contact" in result - assert result["contact"] == created_contact_data - - # Expected body should only include the givenName when family_name and email are omitted. - expected_body = {"names": [{"givenName": "Bob"}]} - people_mock.createContact.assert_called_once_with( - body=expected_body, personFields="names,emailAddresses" - ) - - -@pytest.mark.asyncio -async def test_create_contact_error(mock_context): - # Simulate an error thrown by createContact - error_call = MagicMock() - error_call.execute.side_effect = Exception("Create error") - - people_mock = MagicMock() - people_mock.createContact.return_value = error_call - - service_mock = MagicMock() - service_mock.people.return_value = people_mock - - with ( - patch( - "arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock - ), - pytest.raises(Exception, match="Error in execution of CreateContact"), - ): - await create_contact(mock_context, given_name="Alice", family_name="Doe", email=None) diff --git a/toolkits/google_docs/.pre-commit-config.yaml b/toolkits/google_docs/.pre-commit-config.yaml deleted file mode 100644 index 8e74cd01..00000000 --- a/toolkits/google_docs/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_docs/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_docs/.ruff.toml b/toolkits/google_docs/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_docs/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_docs/Makefile b/toolkits/google_docs/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_docs/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_docs/arcade_google_docs/__init__.py b/toolkits/google_docs/arcade_google_docs/__init__.py deleted file mode 100644 index 2ef99351..00000000 --- a/toolkits/google_docs/arcade_google_docs/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from arcade_google_docs.tools import ( - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, - search_and_retrieve_documents, - search_documents, -) - -__all__ = [ - "create_blank_document", - "create_document_from_text", - "get_document_by_id", - "insert_text_at_end_of_document", - "search_and_retrieve_documents", - "search_documents", -] diff --git a/toolkits/google_docs/arcade_google_docs/decorators.py b/toolkits/google_docs/arcade_google_docs/decorators.py deleted file mode 100644 index ffeb6aa7..00000000 --- a/toolkits/google_docs/arcade_google_docs/decorators.py +++ /dev/null @@ -1,24 +0,0 @@ -import functools -from collections.abc import Callable -from typing import Any - -from arcade_tdk import ToolContext -from googleapiclient.errors import HttpError - -from arcade_google_docs.file_picker import generate_google_file_picker_url - - -def with_filepicker_fallback(func: Callable[..., Any]) -> Callable[..., Any]: - """ """ - - @functools.wraps(func) - async def async_wrapper(context: ToolContext, *args: Any, **kwargs: Any) -> Any: - try: - return await func(context, *args, **kwargs) - except HttpError as e: - if e.status_code in [403, 404]: - file_picker_response = generate_google_file_picker_url(context) - return file_picker_response - raise - - return async_wrapper diff --git a/toolkits/google_docs/arcade_google_docs/doc_to_html.py b/toolkits/google_docs/arcade_google_docs/doc_to_html.py deleted file mode 100644 index d54fcef0..00000000 --- a/toolkits/google_docs/arcade_google_docs/doc_to_html.py +++ /dev/null @@ -1,99 +0,0 @@ -def convert_document_to_html(document: dict) -> str: - html = ( - "" - f"{document['title']}" - f'' - "" - ) - for element in document["body"]["content"]: - html += convert_structural_element(element) - html += "" - return html - - -def convert_structural_element(element: dict, wrap_paragraphs: bool = True) -> str: - if "sectionBreak" in element or "tableOfContents" in element: - return "" - - elif "paragraph" in element: - paragraph_content = "" - - prepend, append = get_paragraph_style_tags( - style=element["paragraph"]["paragraphStyle"], - wrap_paragraphs=wrap_paragraphs, - ) - - for item in element["paragraph"]["elements"]: - if "textRun" not in item: - continue - paragraph_content += extract_paragraph_content(item["textRun"]) - - if not paragraph_content: - return "" - - return f"{prepend}{paragraph_content.strip()}{append}" - - elif "table" in element: - table = [ - [ - "".join([ - convert_structural_element(element=cell_element, wrap_paragraphs=False) - for cell_element in cell["content"] - ]) - for cell in row["tableCells"] - ] - for row in element["table"]["tableRows"] - ] - return table_list_to_html(table) - - else: - raise ValueError(f"Unknown document body element type: {element}") - - -def extract_paragraph_content(text_run: dict) -> str: - content = text_run["content"] - style = text_run["textStyle"] - return apply_text_style(content, style) - - -def apply_text_style(content: str, style: dict) -> str: - content = content.rstrip("\n") - content = content.replace("\n", "
") - italic = style.get("italic", False) - bold = style.get("bold", False) - if italic: - content = f"{content}" - if bold: - content = f"{content}" - return content - - -def get_paragraph_style_tags(style: dict, wrap_paragraphs: bool = True) -> tuple[str, str]: - named_style = style["namedStyleType"] - if named_style == "NORMAL_TEXT": - return ("

", "

") if wrap_paragraphs else ("", "") - elif named_style == "TITLE": - return "

", "

" - elif named_style == "SUBTITLE": - return "

", "

" - elif named_style.startswith("HEADING_"): - try: - heading_level = int(named_style.split("_")[1]) - except ValueError: - return ("

", "

") if wrap_paragraphs else ("", "") - else: - return f"", f"" - return ("

", "

") if wrap_paragraphs else ("", "") - - -def table_list_to_html(table: list[list[str]]) -> str: - html = "" - for row in table: - html += "" - for cell in row: - if cell.endswith("
"): - cell = cell[:-4] - html += f"" - html += "" - html += "
{cell}
" - return html diff --git a/toolkits/google_docs/arcade_google_docs/doc_to_markdown.py b/toolkits/google_docs/arcade_google_docs/doc_to_markdown.py deleted file mode 100644 index b7be21f8..00000000 --- a/toolkits/google_docs/arcade_google_docs/doc_to_markdown.py +++ /dev/null @@ -1,64 +0,0 @@ -import arcade_google_docs.doc_to_html as doc_to_html - - -def convert_document_to_markdown(document: dict) -> str: - md = f"---\ntitle: {document['title']}\ndocumentId: {document['documentId']}\n---\n" - for element in document["body"]["content"]: - md += convert_structural_element(element) - return md - - -def convert_structural_element(element: dict) -> str: - if "sectionBreak" in element or "tableOfContents" in element: - return "" - - elif "paragraph" in element: - md = "" - prepend = get_paragraph_style_prepend_str(element["paragraph"]["paragraphStyle"]) - for item in element["paragraph"]["elements"]: - if "textRun" not in item: - continue - content = extract_paragraph_content(item["textRun"]) - md += f"{prepend}{content}" - return md - - elif "table" in element: - return doc_to_html.convert_structural_element(element) - - else: - raise ValueError(f"Unknown document body element type: {element}") - - -def extract_paragraph_content(text_run: dict) -> str: - content = text_run["content"] - style = text_run["textStyle"] - return apply_text_style(content, style) - - -def apply_text_style(content: str, style: dict) -> str: - append = "\n" if content.endswith("\n") else "" - content = content.rstrip("\n") - italic = style.get("italic", False) - bold = style.get("bold", False) - if italic: - content = f"_{content}_" - if bold: - content = f"**{content}**" - return f"{content}{append}" - - -def get_paragraph_style_prepend_str(style: dict) -> str: - named_style = style["namedStyleType"] - if named_style == "NORMAL_TEXT": - return "" - elif named_style == "TITLE": - return "# " - elif named_style == "SUBTITLE": - return "## " - elif named_style.startswith("HEADING_"): - try: - heading_level = int(named_style.split("_")[1]) - return f"{'#' * heading_level} " - except ValueError: - return "" - return "" diff --git a/toolkits/google_docs/arcade_google_docs/enum.py b/toolkits/google_docs/arcade_google_docs/enum.py deleted file mode 100644 index 50202e79..00000000 --- a/toolkits/google_docs/arcade_google_docs/enum.py +++ /dev/null @@ -1,116 +0,0 @@ -from enum import Enum - - -class Corpora(str, Enum): - """ - Bodies of items (files/documents) to which the query applies. - Prefer 'user' or 'drive' to 'allDrives' for efficiency. - By default, corpora is set to 'user'. - """ - - USER = "user" - DOMAIN = "domain" - DRIVE = "drive" - ALL_DRIVES = "allDrives" - - -class DocumentFormat(str, Enum): - MARKDOWN = "markdown" - HTML = "html" - GOOGLE_API_JSON = "google_api_json" - - -class OrderBy(str, Enum): - """ - Sort keys for ordering files in Google Drive. - Each key has both ascending and descending options. - """ - - CREATED_TIME = ( - # When the file was created (ascending) - "createdTime" - ) - CREATED_TIME_DESC = ( - # When the file was created (descending) - "createdTime desc" - ) - FOLDER = ( - # The folder ID, sorted using alphabetical ordering (ascending) - "folder" - ) - FOLDER_DESC = ( - # The folder ID, sorted using alphabetical ordering (descending) - "folder desc" - ) - MODIFIED_BY_ME_TIME = ( - # The last time the file was modified by the user (ascending) - "modifiedByMeTime" - ) - MODIFIED_BY_ME_TIME_DESC = ( - # The last time the file was modified by the user (descending) - "modifiedByMeTime desc" - ) - MODIFIED_TIME = ( - # The last time the file was modified by anyone (ascending) - "modifiedTime" - ) - MODIFIED_TIME_DESC = ( - # The last time the file was modified by anyone (descending) - "modifiedTime desc" - ) - NAME = ( - # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending) - "name" - ) - NAME_DESC = ( - # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending) - "name desc" - ) - NAME_NATURAL = ( - # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending) - "name_natural" - ) - NAME_NATURAL_DESC = ( - # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending) - "name_natural desc" - ) - QUOTA_BYTES_USED = ( - # The number of storage quota bytes used by the file (ascending) - "quotaBytesUsed" - ) - QUOTA_BYTES_USED_DESC = ( - # The number of storage quota bytes used by the file (descending) - "quotaBytesUsed desc" - ) - RECENCY = ( - # The most recent timestamp from the file's date-time fields (ascending) - "recency" - ) - RECENCY_DESC = ( - # The most recent timestamp from the file's date-time fields (descending) - "recency desc" - ) - SHARED_WITH_ME_TIME = ( - # When the file was shared with the user, if applicable (ascending) - "sharedWithMeTime" - ) - SHARED_WITH_ME_TIME_DESC = ( - # When the file was shared with the user, if applicable (descending) - "sharedWithMeTime desc" - ) - STARRED = ( - # Whether the user has starred the file (ascending) - "starred" - ) - STARRED_DESC = ( - # Whether the user has starred the file (descending) - "starred desc" - ) - VIEWED_BY_ME_TIME = ( - # The last time the file was viewed by the user (ascending) - "viewedByMeTime" - ) - VIEWED_BY_ME_TIME_DESC = ( - # The last time the file was viewed by the user (descending) - "viewedByMeTime desc" - ) diff --git a/toolkits/google_docs/arcade_google_docs/file_picker.py b/toolkits/google_docs/arcade_google_docs/file_picker.py deleted file mode 100644 index 193690ef..00000000 --- a/toolkits/google_docs/arcade_google_docs/file_picker.py +++ /dev/null @@ -1,49 +0,0 @@ -import base64 -import json - -from arcade_tdk import ToolContext, ToolMetadataKey -from arcade_tdk.errors import ToolExecutionError - - -def generate_google_file_picker_url(context: ToolContext) -> dict: - """Generate a Google File Picker URL for user-driven file selection and authorization. - - Generates a URL that directs the end-user to a Google File Picker interface where - where they can select or upload Google Drive files. Users can grant permission to access their - Drive files, providing a secure and authorized way to interact with their files. - - This is particularly useful when prior tools (e.g., those accessing or modifying - Google Docs, Google Sheets, etc.) encountered failures due to file non-existence - (Requested entity was not found) or permission errors. Once the user completes the file - picker flow, the prior tool can be retried. - - Returns: - A dictionary containing the URL and instructions for the llm to instruct the user. - """ - client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID) - client_id_parts = client_id.split("-") - if not client_id_parts: - raise ToolExecutionError( - message="Invalid Google Client ID", - developer_message=f"Google Client ID '{client_id}' is not valid", - ) - app_id = client_id_parts[0] - cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/") - - config = { - "auth": { - "client_id": client_id, - "app_id": app_id, - }, - } - config_json = json.dumps(config) - config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8") - url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}" - - return { - "url": url, - "llm_instructions": ( - "Instruct the user to click the following link to open the Google Drive File Picker. " - f"This will allow them to select files and grant access permissions: {url}" - ), - } diff --git a/toolkits/google_docs/arcade_google_docs/templates.py b/toolkits/google_docs/arcade_google_docs/templates.py deleted file mode 100644 index 97d9d2d1..00000000 --- a/toolkits/google_docs/arcade_google_docs/templates.py +++ /dev/null @@ -1,5 +0,0 @@ -optional_file_picker_instructions_template = ( - "Ensure the user knows that they have the option to select and grant access permissions to " - "additional documents via the Google Drive File Picker. " - "The user can pick additional documents via the following link: {url}" -) diff --git a/toolkits/google_docs/arcade_google_docs/tools/__init__.py b/toolkits/google_docs/arcade_google_docs/tools/__init__.py deleted file mode 100644 index 48437ffa..00000000 --- a/toolkits/google_docs/arcade_google_docs/tools/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from arcade_google_docs.tools.create import ( - create_blank_document, - create_document_from_text, -) -from arcade_google_docs.tools.get import get_document_by_id -from arcade_google_docs.tools.search import ( - search_and_retrieve_documents, - search_documents, -) -from arcade_google_docs.tools.update import insert_text_at_end_of_document - -__all__ = [ - "create_blank_document", - "create_document_from_text", - "get_document_by_id", - "insert_text_at_end_of_document", - "search_and_retrieve_documents", - "search_documents", -] diff --git a/toolkits/google_docs/arcade_google_docs/tools/create.py b/toolkits/google_docs/arcade_google_docs/tools/create.py deleted file mode 100644 index 22a6c010..00000000 --- a/toolkits/google_docs/arcade_google_docs/tools/create.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google - -from arcade_google_docs.utils import build_docs_service - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create -# Example `arcade chat` query: `create blank document with title "My New Document"` -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ) -) -async def create_blank_document( - context: ToolContext, title: Annotated[str, "The title of the blank document to create"] -) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]: - """ - Create a blank Google Docs document with the specified title. - """ - service = build_docs_service(context.get_auth_token_or_empty()) - - body = {"title": title} - - # Execute the documents().create() method. Returns a Document object https://developers.google.com/docs/api/reference/rest/v1/documents#Document - request = service.documents().create(body=body) - response = request.execute() - - return { - "title": response["title"], - "documentId": response["documentId"], - "documentUrl": f"https://docs.google.com/document/d/{response['documentId']}/edit", - } - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate -# Example `arcade chat` query: -# `create document with title "My New Document" and text content "Hello, World!"` -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ) -) -async def create_document_from_text( - context: ToolContext, - title: Annotated[str, "The title of the document to create"], - text_content: Annotated[str, "The text content to insert into the document"], -) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]: - """ - Create a Google Docs document with the specified title and text content. - """ - # First, create a blank document - document = await create_blank_document(context, title) - - service = build_docs_service(context.get_auth_token_or_empty()) - - requests = [ - { - "insertText": { - "location": { - "index": 1, - }, - "text": text_content, - } - } - ] - - # Execute the batchUpdate method to insert text - service.documents().batchUpdate( - documentId=document["documentId"], body={"requests": requests} - ).execute() - - return { - "title": document["title"], - "documentId": document["documentId"], - "documentUrl": f"https://docs.google.com/document/d/{document['documentId']}/edit", - } diff --git a/toolkits/google_docs/arcade_google_docs/tools/get.py b/toolkits/google_docs/arcade_google_docs/tools/get.py deleted file mode 100644 index 72027aea..00000000 --- a/toolkits/google_docs/arcade_google_docs/tools/get.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, ToolMetadataKey, tool -from arcade_tdk.auth import Google - -from arcade_google_docs.decorators import with_filepicker_fallback -from arcade_google_docs.utils import build_docs_service - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/get -# Example `arcade chat` query: `get document with ID 1234567890` -# Note: Document IDs are returned in the response of the Google Drive's `list_documents` tool -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -@with_filepicker_fallback -async def get_document_by_id( - context: ToolContext, - document_id: Annotated[str, "The ID of the document to retrieve."], -) -> Annotated[dict, "The document contents as a dictionary"]: - """ - Get the latest version of the specified Google Docs document. - """ - service = build_docs_service(context.get_auth_token_or_empty()) - - # Execute the documents().get() method. Returns a Document object - # https://developers.google.com/docs/api/reference/rest/v1/documents#Document - request = service.documents().get(documentId=document_id) - response = request.execute() - return dict(response) diff --git a/toolkits/google_docs/arcade_google_docs/tools/search.py b/toolkits/google_docs/arcade_google_docs/tools/search.py deleted file mode 100644 index 4221cdf1..00000000 --- a/toolkits/google_docs/arcade_google_docs/tools/search.py +++ /dev/null @@ -1,219 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, ToolMetadataKey, tool -from arcade_tdk.auth import Google - -from arcade_google_docs.doc_to_html import convert_document_to_html -from arcade_google_docs.doc_to_markdown import convert_document_to_markdown -from arcade_google_docs.enum import DocumentFormat, OrderBy -from arcade_google_docs.file_picker import generate_google_file_picker_url -from arcade_google_docs.templates import optional_file_picker_instructions_template -from arcade_google_docs.tools import get_document_by_id -from arcade_google_docs.utils import ( - build_drive_service, - build_files_list_params, -) - - -# Implements: https://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.files.html#list -# Example `arcade chat` query: `list my 5 most recently modified documents` -# TODO: Support query with natural language. Currently, the tool expects a fully formed query -# string as input with the syntax defined here: https://developers.google.com/drive/api/guides/search-files -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -async def search_documents( - context: ToolContext, - document_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - document_not_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must NOT be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - search_only_in_shared_drive_id: Annotated[ - str | None, - "The ID of the shared drive to restrict the search to. If provided, the search will only " - "return documents from this drive. Defaults to None, which searches across all drives.", - ] = None, - include_shared_drives: Annotated[ - bool, - "Whether to include documents from shared drives. Defaults to False (searches only in " - "the user's 'My Drive').", - ] = False, - include_organization_domain_documents: Annotated[ - bool, - "Whether to include documents from the organization's domain. This is applicable to admin " - "users who have permissions to view organization-wide documents in a Google Workspace " - "account. Defaults to False.", - ] = False, - order_by: Annotated[ - list[OrderBy] | None, - "Sort order. Defaults to listing the most recently modified documents first", - ] = None, - limit: Annotated[int, "The number of documents to list"] = 50, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[ - dict, - "A dictionary containing 'documents_count' (number of documents returned) and 'documents' " - "(a list of document details including 'kind', 'mimeType', 'id', and 'name' for each document)", -]: - """ - Searches for documents in the user's Google Drive. Excludes documents that are in the trash. - """ - if order_by is None: - order_by = [OrderBy.MODIFIED_TIME_DESC] - elif isinstance(order_by, OrderBy): - order_by = [order_by] - - page_size = min(10, limit) - files: list[dict[str, Any]] = [] - - service = build_drive_service(context.get_auth_token_or_empty()) - - params = build_files_list_params( - mime_type="application/vnd.google-apps.document", - document_contains=document_contains, - document_not_contains=document_not_contains, - page_size=page_size, - order_by=order_by, - pagination_token=pagination_token, - include_shared_drives=include_shared_drives, - search_only_in_shared_drive_id=search_only_in_shared_drive_id, - include_organization_domain_documents=include_organization_domain_documents, - ) - - while len(files) < limit: - if pagination_token: - params["pageToken"] = pagination_token - else: - params.pop("pageToken", None) - - results = service.files().list(**params).execute() - batch = results.get("files", []) - files.extend(batch[: limit - len(files)]) - - pagination_token = results.get("nextPageToken") - if not pagination_token or len(batch) < page_size: - break - - file_picker_response = generate_google_file_picker_url( - context, - ) - - return { - "documents_count": len(files), - "documents": files, - "file_picker": { - "url": file_picker_response["url"], - "llm_instructions": optional_file_picker_instructions_template.format( - url=file_picker_response["url"] - ), - }, - } - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -async def search_and_retrieve_documents( - context: ToolContext, - return_format: Annotated[ - DocumentFormat, - "The format of the document to return. Defaults to Markdown.", - ] = DocumentFormat.MARKDOWN, - document_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - document_not_contains: Annotated[ - list[str] | None, - "Keywords or phrases that must NOT be in the document title or body. Provide a list of " - "keywords or phrases if needed.", - ] = None, - search_only_in_shared_drive_id: Annotated[ - str | None, - "The ID of the shared drive to restrict the search to. If provided, the search will only " - "return documents from this drive. Defaults to None, which searches across all drives.", - ] = None, - include_shared_drives: Annotated[ - bool, - "Whether to include documents from shared drives. Defaults to False (searches only in " - "the user's 'My Drive').", - ] = False, - include_organization_domain_documents: Annotated[ - bool, - "Whether to include documents from the organization's domain. This is applicable to admin " - "users who have permissions to view organization-wide documents in a Google Workspace " - "account. Defaults to False.", - ] = False, - order_by: Annotated[ - list[OrderBy] | None, - "Sort order. Defaults to listing the most recently modified documents first", - ] = None, - limit: Annotated[int, "The number of documents to list"] = 50, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[ - dict, - "A dictionary containing 'documents_count' (number of documents returned) and 'documents' " - "(a list of documents with their content).", -]: - """ - Searches for documents in the user's Google Drive and returns a list of documents (with text - content) matching the search criteria. Excludes documents that are in the trash. - - Note: use this tool only when the user prompt requires the documents' content. If the user only - needs a list of documents, use the `search_documents` tool instead. - """ - response = await search_documents( - context=context, - document_contains=document_contains, - document_not_contains=document_not_contains, - search_only_in_shared_drive_id=search_only_in_shared_drive_id, - include_shared_drives=include_shared_drives, - include_organization_domain_documents=include_organization_domain_documents, - order_by=order_by, - limit=limit, - pagination_token=pagination_token, - ) - - documents = [] - - for item in response["documents"]: - document = await get_document_by_id(context, document_id=item["id"]) - - if return_format == DocumentFormat.MARKDOWN: - document = convert_document_to_markdown(document) - elif return_format == DocumentFormat.HTML: - document = convert_document_to_html(document) - - documents.append(document) - - file_picker_response = generate_google_file_picker_url( - context, - ) - - return { - "documents_count": len(documents), - "documents": documents, - "file_picker": { - "url": file_picker_response["url"], - "llm_instructions": optional_file_picker_instructions_template.format( - url=file_picker_response["url"] - ), - }, - } diff --git a/toolkits/google_docs/arcade_google_docs/tools/update.py b/toolkits/google_docs/arcade_google_docs/tools/update.py deleted file mode 100644 index 1c3d1714..00000000 --- a/toolkits/google_docs/arcade_google_docs/tools/update.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, ToolMetadataKey, tool -from arcade_tdk.auth import Google - -from arcade_google_docs.decorators import with_filepicker_fallback -from arcade_google_docs.tools.get import get_document_by_id -from arcade_google_docs.utils import build_docs_service - - -# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate -# Example `arcade chat` query: `insert "The END" at the end of document with ID 1234567890` -@tool( - requires_auth=Google( - scopes=[ - "https://www.googleapis.com/auth/drive.file", - ], - ), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -@with_filepicker_fallback -async def insert_text_at_end_of_document( - context: ToolContext, - document_id: Annotated[str, "The ID of the document to update."], - text_content: Annotated[str, "The text content to insert into the document"], -) -> Annotated[dict, "The response from the batchUpdate API as a dict."]: - """ - Updates an existing Google Docs document using the batchUpdate API endpoint. - """ - document_or_file_picker_response = await get_document_by_id(context, document_id) - - # If the document was not found, return the file picker response - if "body" not in document_or_file_picker_response: - return document_or_file_picker_response # type: ignore[no-any-return] - - document = document_or_file_picker_response - - end_index = document["body"]["content"][-1]["endIndex"] - - service = build_docs_service(context.get_auth_token_or_empty()) - - requests = [ - { - "insertText": { - "location": { - "index": int(end_index) - 1, - }, - "text": text_content, - } - } - ] - - # Execute the documents().batchUpdate() method - response = ( - service.documents() - .batchUpdate(documentId=document_id, body={"requests": requests}) - .execute() - ) - - return dict(response) diff --git a/toolkits/google_docs/arcade_google_docs/utils.py b/toolkits/google_docs/arcade_google_docs/utils.py deleted file mode 100644 index 6780b7ae..00000000 --- a/toolkits/google_docs/arcade_google_docs/utils.py +++ /dev/null @@ -1,119 +0,0 @@ -import logging -from typing import Any - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build - -from arcade_google_docs.enum import Corpora, OrderBy - -## Set up basic configuration for logging to the console with DEBUG level and a specific format. -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def build_docs_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Drive service object. - """ - auth_token = auth_token or "" - return build("docs", "v1", credentials=Credentials(auth_token)) - - -def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Drive service object. - """ - auth_token = auth_token or "" - return build("drive", "v3", credentials=Credentials(auth_token)) - - -def build_files_list_params( - mime_type: str, - page_size: int, - order_by: list[OrderBy], - pagination_token: str | None, - include_shared_drives: bool, - search_only_in_shared_drive_id: str | None, - include_organization_domain_documents: bool, - document_contains: list[str] | None = None, - document_not_contains: list[str] | None = None, -) -> dict[str, Any]: - query = build_files_list_query( - mime_type=mime_type, - document_contains=document_contains, - document_not_contains=document_not_contains, - ) - - params = { - "q": query, - "pageSize": page_size, - "orderBy": ",".join([item.value for item in order_by]), - "pageToken": pagination_token, - } - - if ( - include_shared_drives - or search_only_in_shared_drive_id - or include_organization_domain_documents - ): - params["includeItemsFromAllDrives"] = "true" - params["supportsAllDrives"] = "true" - - if search_only_in_shared_drive_id: - params["driveId"] = search_only_in_shared_drive_id - params["corpora"] = Corpora.DRIVE.value - - if include_organization_domain_documents: - params["corpora"] = Corpora.DOMAIN.value - - params = remove_none_values(params) - - return params - - -def build_files_list_query( - mime_type: str, - document_contains: list[str] | None = None, - document_not_contains: list[str] | None = None, -) -> str: - query = [f"(mimeType = '{mime_type}' and trashed = false)"] - - if isinstance(document_contains, str): - document_contains = [document_contains] - - if isinstance(document_not_contains, str): - document_not_contains = [document_not_contains] - - if document_contains: - for keyword in document_contains: - name_contains = keyword.replace("'", "\\'") - full_text_contains = keyword.replace("'", "\\'") - keyword_query = ( - f"(name contains '{name_contains}' or fullText contains '{full_text_contains}')" - ) - query.append(keyword_query) - - if document_not_contains: - for keyword in document_not_contains: - name_not_contains = keyword.replace("'", "\\'") - full_text_not_contains = keyword.replace("'", "\\'") - keyword_query = ( - f"(name not contains '{name_not_contains}' and " - f"fullText not contains '{full_text_not_contains}')" - ) - query.append(keyword_query) - - return " and ".join(query) - - -def remove_none_values(params: dict) -> dict: - """ - Remove None values from a dictionary. - :param params: The dictionary to clean - :return: A new dictionary with None values removed - """ - return {k: v for k, v in params.items() if v is not None} diff --git a/toolkits/google_docs/conftest.py b/toolkits/google_docs/conftest.py deleted file mode 100644 index ef47c5b5..00000000 --- a/toolkits/google_docs/conftest.py +++ /dev/null @@ -1,967 +0,0 @@ -import pytest - - -@pytest.fixture -def sample_document_and_expected_formats(): - document = { - "title": "The Birth of Machine Experience Engineering", - "documentId": "1234567890", - "body": { - "content": [ - { - "endIndex": 1, - "sectionBreak": { - "sectionStyle": { - "columnSeparatorStyle": "NONE", - "contentDirection": "LEFT_TO_RIGHT", - "sectionType": "CONTINUOUS", - } - }, - }, - { - "startIndex": 1, - "endIndex": 45, - "paragraph": { - "elements": [ - { - "endIndex": 45, - "startIndex": 1, - "textRun": { - "content": "The Birth of Machine Experience Engineering\n", - "textStyle": { - "bold": True, - "fontSize": {"magnitude": 23, "unit": "PT"}, - }, - }, - } - ], - "paragraphStyle": { - "direction": "LEFT_TO_RIGHT", - "headingId": "h.wwd7ec37bh6k", - "keepLinesTogether": False, - "keepWithNext": False, - "namedStyleType": "HEADING_1", - "spaceAbove": {"magnitude": 24, "unit": "PT"}, - }, - }, - }, - { - "startIndex": 45, - "endIndex": 46, - "paragraph": { - "elements": [ - { - "startIndex": 304, - "endIndex": 305, - "inlineObjectElement": { - "inlineObjectId": "kix.2s5wy5oiaf79", - "textStyle": {}, - }, - }, - { - "endIndex": 46, - "startIndex": 45, - "textRun": {"content": "\n", "textStyle": {}}, - }, - ], - "paragraphStyle": { - "direction": "LEFT_TO_RIGHT", - "namedStyleType": "NORMAL_TEXT", - "spaceAbove": {"magnitude": 12, "unit": "PT"}, - "spaceBelow": {"magnitude": 12, "unit": "PT"}, - }, - }, - }, - { - "startIndex": 46, - "endIndex": 297, - "paragraph": { - "elements": [ - { - "startIndex": 46, - "endIndex": 146, - "textRun": { - "content": ( - "LLMs acting on behalf of humans and interacting with real-" - "world systems isn't theoretical anymore - " - ), - "textStyle": {}, - }, - }, - { - "startIndex": 146, - "endIndex": 175, - "textRun": { - "content": "Arcade has made it a reality.", - "textStyle": { - "bold": True, - "italic": True, - }, - }, - }, - { - "startIndex": 175, - "endIndex": 248, - "textRun": { - "content": ( - " With this shift, we're seeing the emergence of a new " - "software practice: " - ), - "textStyle": {}, - }, - }, - { - "startIndex": 248, - "endIndex": 295, - "textRun": { - "content": "Machine Experience Engineering (MX Engineering)", - "textStyle": { - "italic": True, - }, - }, - }, - { - "startIndex": 295, - "endIndex": 297, - "textRun": { - "content": ".\n", - "textStyle": {}, - }, - }, - ], - "paragraphStyle": { - "direction": "LEFT_TO_RIGHT", - "namedStyleType": "NORMAL_TEXT", - "spaceAbove": {"magnitude": 12, "unit": "PT"}, - "spaceBelow": {"magnitude": 12, "unit": "PT"}, - }, - }, - }, - { - "endIndex": 407, - "startIndex": 297, - "table": { - "columns": 3, - "rows": 3, - "tableRows": [ - { - "endIndex": 338, - "startIndex": 297, - "tableCells": [ - { - "content": [ - { - "endIndex": 318, - "paragraph": { - "elements": [ - { - "endIndex": 318, - "startIndex": 309, - "textRun": { - "content": "Column 1\n", - "textStyle": {"bold": True}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 309, - } - ], - "endIndex": 318, - "startIndex": 308, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 334, - "paragraph": { - "elements": [ - { - "endIndex": 326, - "startIndex": 319, - "textRun": { - "content": "Another", - "textStyle": {"italic": True}, - }, - }, - { - "endIndex": 334, - "startIndex": 326, - "textRun": { - "content": " column\n", - "textStyle": {}, - }, - }, - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 319, - } - ], - "endIndex": 334, - "startIndex": 318, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 348, - "paragraph": { - "elements": [ - { - "endIndex": 348, - "startIndex": 335, - "textRun": { - "content": "Third column\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 335, - } - ], - "endIndex": 348, - "startIndex": 334, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - ], - "tableRowStyle": {"minRowHeight": {"unit": "PT"}}, - }, - { - "endIndex": 366, - "startIndex": 348, - "tableCells": [ - { - "content": [ - { - "endIndex": 356, - "paragraph": { - "elements": [ - { - "endIndex": 356, - "startIndex": 350, - "textRun": { - "content": "Hello\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 350, - } - ], - "endIndex": 356, - "startIndex": 349, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 364, - "paragraph": { - "elements": [ - { - "endIndex": 364, - "startIndex": 357, - "textRun": { - "content": "world!\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 357, - } - ], - "endIndex": 364, - "startIndex": 356, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 366, - "paragraph": { - "elements": [ - { - "endIndex": 366, - "startIndex": 365, - "textRun": { - "content": "\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 365, - } - ], - "endIndex": 366, - "startIndex": 364, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - ], - "tableRowStyle": {"minRowHeight": {"unit": "PT"}}, - }, - { - "endIndex": 415, - "startIndex": 366, - "tableCells": [ - { - "content": [ - { - "endIndex": 388, - "paragraph": { - "elements": [ - { - "endIndex": 388, - "startIndex": 368, - "textRun": { - "content": "The quick brown fox\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 368, - } - ], - "endIndex": 388, - "startIndex": 367, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 401, - "paragraph": { - "elements": [ - { - "endIndex": 395, - "startIndex": 389, - "textRun": { - "content": "jumped", - "textStyle": {"italic": True}, - }, - }, - { - "endIndex": 401, - "startIndex": 395, - "textRun": { - "content": " over\n", - "textStyle": {}, - }, - }, - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 389, - } - ], - "endIndex": 401, - "startIndex": 388, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - { - "content": [ - { - "endIndex": 415, - "paragraph": { - "elements": [ - { - "endIndex": 415, - "startIndex": 402, - "textRun": { - "content": "the lazy dog\n", - "textStyle": {}, - }, - } - ], - "paragraphStyle": { - "alignment": "START", - "avoidWidowAndOrphan": False, - "borderBetween": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderBottom": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderLeft": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderRight": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "borderTop": { - "color": {}, - "dashStyle": "SOLID", - "padding": {"unit": "PT"}, - "width": {"unit": "PT"}, - }, - "direction": "LEFT_TO_RIGHT", - "indentEnd": {"unit": "PT"}, - "indentFirstLine": {"unit": "PT"}, - "indentStart": {"unit": "PT"}, - "keepLinesTogether": False, - "keepWithNext": False, - "lineSpacing": 100, - "namedStyleType": "NORMAL_TEXT", - "pageBreakBefore": False, - "shading": {"backgroundColor": {}}, - "spaceAbove": {"unit": "PT"}, - "spaceBelow": {"unit": "PT"}, - "spacingMode": "COLLAPSE_LISTS", - }, - }, - "startIndex": 402, - } - ], - "endIndex": 415, - "startIndex": 401, - "tableCellStyle": { - "backgroundColor": {}, - "columnSpan": 1, - "contentAlignment": "TOP", - "paddingBottom": {"magnitude": 5, "unit": "PT"}, - "paddingLeft": {"magnitude": 5, "unit": "PT"}, - "paddingRight": {"magnitude": 5, "unit": "PT"}, - "paddingTop": {"magnitude": 5, "unit": "PT"}, - "rowSpan": 1, - }, - }, - ], - "tableRowStyle": {"minRowHeight": {"unit": "PT"}}, - }, - ], - "tableStyle": { - "tableColumnProperties": [ - {"widthType": "EVENLY_DISTRIBUTED"}, - {"widthType": "EVENLY_DISTRIBUTED"}, - {"widthType": "EVENLY_DISTRIBUTED"}, - ] - }, - }, - }, - ] - }, - } - - expected_markdown = ( - "---\ntitle: The Birth of Machine Experience Engineering\ndocumentId: 1234567890\n---\n" - "# **The Birth of Machine Experience Engineering**\n" - "\n" - "LLMs acting on behalf of humans and interacting with real-world systems isn't theoretical " - "anymore - " - "**_Arcade has made it a reality._** With this shift, we're seeing the emergence of a new " - "software practice: " - "_Machine Experience Engineering (MX Engineering)_.\n" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
Column 1Another columnThird column
Helloworld!
The quick brown foxjumped overthe lazy dog
" - ) - - expected_html = ( - "" - "The Birth of Machine Experience Engineering" - '' - "" - "

The Birth of Machine Experience Engineering

" - "

LLMs acting on behalf of humans and interacting with real-world systems isn't " - "theoretical anymore - " - "Arcade has made it a reality. With this shift, we're seeing the emergence " - "of a new software practice: Machine Experience Engineering (MX Engineering).

" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
Column 1Another columnThird column
Helloworld!
The quick brown foxjumped overthe lazy dog
" - "" - ) - - return document, expected_markdown, expected_html diff --git a/toolkits/google_docs/evals/eval_google_docs.py b/toolkits/google_docs/evals/eval_google_docs.py deleted file mode 100644 index 834eb904..00000000 --- a/toolkits/google_docs/evals/eval_google_docs.py +++ /dev/null @@ -1,384 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_docs -from arcade_google_docs.enum import DocumentFormat, OrderBy -from arcade_google_docs.tools import ( - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, - search_and_retrieve_documents, - search_documents, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google_docs) - - -@tool_eval() -def docs_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Docs tools.""" - suite = EvalSuite( - name="Google Docs Tools Evaluation", - system_message="You are an AI assistant that can create and manage Google Docs using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # A previous tool call to list_documents - additional_messages = [ - {"role": "user", "content": "list my 10 most recently created docs"}, - { - "role": "assistant", - "content": "Please go to this URL and authorize the action: [Link](https://accounts.google.com/)", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_gegK723W2hXsORjBmq1Oexqk", - "type": "function", - "function": { - "name": "Google_ListDocuments", - "arguments": '{"limit":10,"order_by":"createdTime desc"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"documents":[{"id":"1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst10"},{"id":"1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst9"},{"id":"19Dqugn0rVi89K0C__lpg1HbhQOTenccyZOhPgivTHMs","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst8"},{"id":"1RCibzx14eqP3vS9yI4nD13OKf8Vee56RiszS53OkR7I","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst7"},{"id":"1imFb04JQuBn8SiSsRFf6fEuYCyXkbII4KX8fsmnT0jo","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst6"},{"id":"1ZC3oypdfLWFgBd-emeSykJf9tZOae6USsFboygRCr-w","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst5"},{"id":"1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst4"},{"id":"1eQ8UBO_PY3Lem4R8OVdIc9ODXt0MrSUAnEu994Qz8P8","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst3"},{"id":"1TOxB0MLry-JzntDWDT1LFywTLdr3XDWPT5L5UsHMs5c","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst2"},{"id":"1a1UQ7C90s8kGfnO8k6wfAZz_Cy5nGN2MkCoRB5y2j3w","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst1"}],"documents_count":10}', - "tool_call_id": "call_gegK723W2hXsORjBmq1Oexqk", - "name": "Google_ListDocuments", - }, - { - "role": "assistant", - "content": "Here are your 10 most recently created Google Docs:\n\n1. [Tst10](https://docs.google.com/document/d/1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc)\n2. [Tst9](https://docs.google.com/document/d/1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts)\n3. [Tst8](https://docs.google.com/document/d/19Dqugn0rVi89K0C__lpg1HbhQOTenccyZOhPgivTHMs)\n4. [Tst7](https://docs.google.com/document/d/1RCibzx14eqP3vS9yI4nD13OKf8Vee56RiszS53OkR7I)\n5. [Tst6](https://docs.google.com/document/d/1imFb04JQuBn8SiSsRFf6fEuYCyXkbII4KX8fsmnT0jo)\n6. [Tst5](https://docs.google.com/document/d/1ZC3oypdfLWFgBd-emeSykJf9tZOae6USsFboygRCr-w)\n7. [Tst4](https://docs.google.com/document/d/1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc)\n8. [Tst3](https://docs.google.com/document/d/1eQ8UBO_PY3Lem4R8OVdIc9ODXt0MrSUAnEu994Qz8P8)\n9. [Tst2](https://docs.google.com/document/d/1TOxB0MLry-JzntDWDT1LFywTLdr3XDWPT5L5UsHMs5c)\n10. [Tst1](https://docs.google.com/document/d/1a1UQ7C90s8kGfnO8k6wfAZz_Cy5nGN2MkCoRB5y2j3w)\n\nYou can click the links to open each document.", - }, - ] - - suite.add_case( - name="Get document content", - user_message="Can you read me the contents of Tst9 doc and also Tst10 doc please", - expected_tool_calls=[ - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts", - }, - ), - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="document_id", weight=0.6), - ], - additional_messages=additional_messages, - ) - - suite.add_case( - name="Insert text at end of document", - user_message="Please add the text 'This is a new paragraph.' to the end of Tst4.", - expected_tool_calls=[ - ExpectedToolCall( - func=insert_text_at_end_of_document, - args={ - "document_id": "1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc", - "text_content": "This is a new paragraph.", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_id", weight=0.5), - SimilarityCritic(critic_field="text_content", weight=0.5), - ], - additional_messages=additional_messages, - ) - - suite.add_case( - name="Read the contents of two documents and then insert text at end of a different document.", - user_message="Can you read me the contents of Tst9 doc and also Tst10 doc please. Also, please add the text 'This is a new paragraph.' to the end of Tst4.", - expected_tool_calls=[ - ExpectedToolCall( - func=insert_text_at_end_of_document, - args={ - "document_id": "1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc", - "text_content": "This is a new paragraph.", - }, - ), - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts", - }, - ), - ExpectedToolCall( - func=get_document_by_id, - args={ - "document_id": "1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="document_id", weight=0.3), - SimilarityCritic(critic_field="text_content", weight=0.3), - ], - additional_messages=additional_messages, - ) - - suite.add_case( - name="Create blank document", - user_message="Create a new Doc titled 'Meeting Notes'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_blank_document, - args={ - "title": "Meeting Notes", - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="title", weight=1.0), - ], - ) - - suite.add_case( - name="Create document from text", - user_message="Create a new doc called To-Do List with the content 'Buy groceries, Call mom, Finish report'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_document_from_text, - args={ - "title": "To-Do List", - "text_content": "Buy groceries\nCall mom\nFinish report", - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="title", weight=0.5), - SimilarityCritic(critic_field="text_content", weight=0.5), - ], - ) - - suite.add_case( - name="No tool call case", - user_message="Create a new microsoft word document titled 'My Resume'.", - expected_tool_calls=[], - critics=[], - ) - - return suite - - -@tool_eval() -def search_documents_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search documents in Google Drive", - user_message="get my 49 most recently created documents, list the ones created most recently first.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "order_by": [OrderBy.CREATED_TIME_DESC.value], - "limit": 49, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="order_by", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="Search documents in Google Drive based on document keywords", - user_message="Search the documents that contain the word 'greedy' and the phrase 'hello, world'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "document_contains": ["greedy", "hello, world"], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=1.0), - ], - ) - - suite.add_case( - name="Search documents in a specific Google Drive based on document keywords", - user_message="Search the documents that contain the word 'greedy' and the phrase 'hello, world' in the drive with id 'abc123'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "document_contains": ["greedy", "hello, world"], - "search_only_in_shared_drive_id": "abc123", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="search_only_in_shared_drive_id", weight=0.5), - BinaryCritic(critic_field="document_contains", weight=0.5), - ], - ) - - suite.add_case( - name="Search documents in a Google Drive Workspace organization domain based on document keywords", - user_message="Search the documents that contain the phrase 'hello, world' in the organization domain", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "document_contains": ["hello, world"], - "include_organization_domain_documents": True, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5), - BinaryCritic(critic_field="document_contains", weight=0.5), - ], - ) - - suite.add_case( - name="Search documents in shared drives", - user_message="Search the 5 documents from all drives corpora that nobody has touched in forever, excluding shared drives.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_documents, - args={ - "limit": 5, - "include_shared_drives": False, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="No tool call case", - user_message="List my 10 most recently modified documents that are stored in my Microsoft OneDrive.", - expected_tool_calls=[], - critics=[], - ) - - return suite - - -@tool_eval() -def search_and_retrieve_documents_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive search and retrieve tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search and retrieve (write summary)", - user_message="Write a summary of the documents in my Google Drive about 'MX Engineering'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["MX Engineering"], - "return_format": DocumentFormat.MARKDOWN, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=0.5), - BinaryCritic(critic_field="return_format", weight=0.5), - ], - ) - - suite.add_case( - name="Search and retrieve (project proposal)", - user_message="Display the document contents in HTML format from my Google Drive that contain the phrase 'project proposal'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["project proposal"], - "return_format": DocumentFormat.HTML, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=0.5), - BinaryCritic(critic_field="return_format", weight=0.5), - ], - ) - - suite.add_case( - name="Search and retrieve (meeting notes)", - user_message="Retrieve documents that contain both 'meeting notes' and 'budget' in JSON format.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["meeting notes", "budget"], - "return_format": DocumentFormat.GOOGLE_API_JSON, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=0.5), - BinaryCritic(critic_field="return_format", weight=0.5), - ], - ) - - suite.add_case( - name="Search and retrieve (Q1 report)", - user_message="Show me the content of the documents that mention 'Q1 report' but do not include the expression 'Project XYZ'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_and_retrieve_documents, - args={ - "document_contains": ["Q1 report"], - "document_not_contains": ["Project XYZ"], - "return_format": DocumentFormat.MARKDOWN, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="document_contains", weight=1 / 3), - BinaryCritic(critic_field="document_not_contains", weight=1 / 3), - BinaryCritic(critic_field="return_format", weight=1 / 3), - ], - ) - - return suite diff --git a/toolkits/google_docs/pyproject.toml b/toolkits/google_docs/pyproject.toml deleted file mode 100644 index 58e3f128..00000000 --- a/toolkits/google_docs/pyproject.toml +++ /dev/null @@ -1,62 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_docs" -version = "2.0.0" -description = "Arcade.dev LLM tools for Google Docs" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.4,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - -[tool.mypy] -files = [ "arcade_google_docs/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_docs",] diff --git a/toolkits/google_docs/tests/__init__.py b/toolkits/google_docs/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_docs/tests/test_doc_to_markdown.py b/toolkits/google_docs/tests/test_doc_to_markdown.py deleted file mode 100644 index 482ba4c9..00000000 --- a/toolkits/google_docs/tests/test_doc_to_markdown.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - -from arcade_google_docs.doc_to_markdown import convert_document_to_markdown - - -@pytest.mark.asyncio -async def test_convert_document_to_markdown(sample_document_and_expected_formats): - (sample_document, expected_markdown, _) = sample_document_and_expected_formats - markdown = convert_document_to_markdown(sample_document) - assert markdown == expected_markdown diff --git a/toolkits/google_docs/tests/test_google_docs.py b/toolkits/google_docs/tests/test_google_docs.py deleted file mode 100644 index 894ce183..00000000 --- a/toolkits/google_docs/tests/test_google_docs.py +++ /dev/null @@ -1,179 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google_docs.tools import ( - create_blank_document, - create_document_from_text, - get_document_by_id, - insert_text_at_end_of_document, -) -from arcade_google_docs.utils import build_docs_service - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_get_service(): - with patch("arcade_google_docs.tools.get." + build_docs_service.__name__) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.fixture -def mock_update_service(): - with patch( - "arcade_google_docs.tools.update." + build_docs_service.__name__ - ) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.fixture -def mock_create_service(): - with patch( - "arcade_google_docs.tools.create." + build_docs_service.__name__ - ) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.mark.asyncio -async def test_get_document_by_id_success(mock_context, mock_get_service): - # Mock the service.documents().get().execute() method - mock_get_service.documents.return_value.get.return_value.execute.return_value = { - "body": {"content": [{"endIndex": 1, "paragraph": {}}]}, - "documentId": "test_document_id", - "title": "Test Document", - } - - result = await get_document_by_id(mock_context, "test_document_id") - - assert result["documentId"] == "test_document_id" - assert result["title"] == "Test Document" - - -@pytest.mark.asyncio -async def test_get_document_by_id_http_error(mock_context, mock_get_service): - # Simulate HttpError - mock_get_service.documents.return_value.get.return_value.execute.side_effect = HttpError( - resp=AsyncMock(status=404), content=b'{"error": {"message": "Not Found"}}' - ) - - with pytest.raises(ToolExecutionError, match="Error in execution of GetDocumentById"): - await get_document_by_id(mock_context, "invalid_document_id") - - -@pytest.mark.asyncio -async def test_insert_text_at_end_of_document_success(mock_context, mock_update_service): - # Mock get_document_by_id to return a document with endIndex - with patch( - "arcade_google_docs.tools.update.get_document_by_id", - return_value={"body": {"content": [{"endIndex": 1, "paragraph": {}}]}}, - ): - # Mock the service.documents().batchUpdate().execute() method - mock_update_service.documents.return_value.batchUpdate.return_value.execute.return_value = { - "documentId": "test_document_id", - "replies": [], - } - - result = await insert_text_at_end_of_document( - mock_context, "test_document_id", "Sample text" - ) - - assert result["documentId"] == "test_document_id" - - -@pytest.mark.asyncio -async def test_insert_text_at_end_of_document_http_error(mock_context, mock_update_service): - with patch( - "arcade_google_docs.tools.update.get_document_by_id", - return_value={"body": {"content": [{"endIndex": 1, "paragraph": {}}]}}, - ): - # Simulate HttpError during batchUpdate - mock_update_service.documents.return_value.batchUpdate.return_value.execute.side_effect = ( - HttpError(resp=AsyncMock(status=400), content=b'{"error": {"message": "Bad Request"}}') - ) - - with pytest.raises( - ToolExecutionError, match="Error in execution of InsertTextAtEndOfDocument" - ): - await insert_text_at_end_of_document(mock_context, "test_document_id", "Sample text") - - -@pytest.mark.asyncio -async def test_create_blank_document_success(mock_context, mock_create_service): - # Mock the service.documents().create().execute() method - mock_create_service.documents.return_value.create.return_value.execute.return_value = { - "documentId": "new_document_id", - "title": "New Document", - } - - result = await create_blank_document(mock_context, "New Document") - - assert result["documentId"] == "new_document_id" - assert result["title"] == "New Document" - assert "documentUrl" in result - - -@pytest.mark.asyncio -async def test_create_blank_document_http_error(mock_context, mock_create_service): - # Simulate HttpError during create - mock_create_service.documents.return_value.create.return_value.execute.side_effect = HttpError( - resp=AsyncMock(status=403), content=b'{"error": {"message": "Forbidden"}}' - ) - - with pytest.raises(ToolExecutionError, match="Error in execution of CreateBlankDocument"): - await create_blank_document(mock_context, "New Document") - - -@pytest.mark.asyncio -async def test_create_document_from_text_success(mock_context, mock_create_service): - with patch( - "arcade_google_docs.tools.create." + create_blank_document.__name__ - ) as mock_create_blank_document: - # Mock create_blank_document - mock_create_blank_document.return_value = { - "documentId": "new_document_id", - "title": "New Document", - } - - # Mock the service.documents().batchUpdate().execute() method - mock_create_service.documents.return_value.batchUpdate.return_value.execute.return_value = { - "documentId": "new_document_id", - "replies": [], - } - - result = await create_document_from_text(mock_context, "New Document", "Hello, World!") - - assert result["documentId"] == "new_document_id" - assert result["title"] == "New Document" - assert "documentUrl" in result - - -@pytest.mark.asyncio -async def test_create_document_from_text_http_error(mock_context, mock_create_service): - with patch( - "arcade_google_docs.tools.create." + create_blank_document.__name__ - ) as mock_create_blank_document: - # Mock create_blank_document - mock_create_blank_document.return_value = { - "documentId": "new_document_id", - "title": "New Document", - } - - # Simulate HttpError during batchUpdate - mock_create_service.documents.return_value.batchUpdate.return_value.execute.side_effect = ( - HttpError( - resp=AsyncMock(status=500), content=b'{"error": {"message": "Internal Error"}}' - ) - ) - - with pytest.raises( - ToolExecutionError, match="Error in execution of CreateDocumentFromText" - ): - await create_document_from_text(mock_context, "New Document", "Hello, World!") diff --git a/toolkits/google_docs/tests/test_search.py b/toolkits/google_docs/tests/test_search.py deleted file mode 100644 index 38ad1a81..00000000 --- a/toolkits/google_docs/tests/test_search.py +++ /dev/null @@ -1,276 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google_docs.enum import Corpora, DocumentFormat, OrderBy -from arcade_google_docs.templates import optional_file_picker_instructions_template -from arcade_google_docs.tools import ( - search_and_retrieve_documents, - search_documents, -) -from arcade_google_docs.utils import build_drive_service - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - context.get_metadata.side_effect = lambda key: { - "client_id": "123456789-abcdefg.apps.googleusercontent.com", - "coordinator_url": "https://coordinator.example.com", - }.get(key.value if hasattr(key, "value") else key) - return context - - -@pytest.fixture -def mock_service(): - with patch( - "arcade_google_docs.tools.search." + build_drive_service.__name__ - ) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.mark.asyncio -async def test_search_documents_success(mock_context, mock_service): - # Mock the service.files().list().execute() method - mock_service.files.return_value.list.return_value.execute.side_effect = [ - { - "files": [ - {"id": "file1", "name": "Document 1"}, - {"id": "file2", "name": "Document 2"}, - ], - "nextPageToken": None, - } - ] - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_docs.tools.search.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await search_documents(mock_context, limit=2) - - assert result["documents_count"] == 2 - assert len(result["documents"]) == 2 - assert result["documents"][0]["id"] == "file1" - assert result["documents"][1]["id"] == "file2" - - -@pytest.mark.asyncio -async def test_search_documents_pagination(mock_context, mock_service): - # Simulate multiple pages - mock_service.files.return_value.list.return_value.execute.side_effect = [ - { - "files": [{"id": f"file{i}", "name": f"Document {i}"} for i in range(1, 11)], - "nextPageToken": "token1", - }, - { - "files": [{"id": f"file{i}", "name": f"Document {i}"} for i in range(11, 21)], - "nextPageToken": None, - }, - ] - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_docs.tools.search.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await search_documents(mock_context, limit=15) - - assert result["documents_count"] == 15 - assert len(result["documents"]) == 15 - assert result["documents"][0]["id"] == "file1" - assert result["documents"][-1]["id"] == "file15" - - -@pytest.mark.asyncio -async def test_search_documents_http_error(mock_context, mock_service): - # Simulate HttpError - mock_service.files.return_value.list.return_value.execute.side_effect = HttpError( - resp=AsyncMock(status=403), content=b'{"error": {"message": "Forbidden"}}' - ) - - with pytest.raises( - ToolExecutionError, match=f"Error in execution of {search_documents.__tool_name__}" - ): - await search_documents(mock_context) - - -@pytest.mark.asyncio -async def test_search_documents_unexpected_error(mock_context, mock_service): - # Simulate unexpected exception - mock_service.files.return_value.list.return_value.execute.side_effect = Exception( - "Unexpected error" - ) - - with pytest.raises( - ToolExecutionError, match=f"Error in execution of {search_documents.__tool_name__}" - ): - await search_documents(mock_context) - - -@pytest.mark.asyncio -async def test_search_documents_in_organization_domains(mock_context, mock_service): - # Mock the service.files().list().execute() method - mock_service.files.return_value.list.return_value.execute.side_effect = [ - { - "files": [ - {"id": "file1", "name": "Document 1"}, - ], - "nextPageToken": None, - } - ] - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_docs.tools.search.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await search_documents( - mock_context, - order_by=OrderBy.MODIFIED_TIME_DESC, - include_shared_drives=False, - include_organization_domain_documents=True, - limit=1, - ) - - assert result["documents_count"] == 1 - mock_service.files.return_value.list.assert_called_with( - q="(mimeType = 'application/vnd.google-apps.document' and trashed = false)", - corpora=Corpora.DOMAIN.value, - pageSize=1, - orderBy=OrderBy.MODIFIED_TIME_DESC.value, - includeItemsFromAllDrives="true", - supportsAllDrives="true", - ) - - -@pytest.mark.asyncio -@patch("arcade_google_docs.tools.search.search_documents") -@patch("arcade_google_docs.tools.search.get_document_by_id") -async def test_search_and_retrieve_documents_in_markdown_format( - mock_get_document_by_id, - mock_search_documents, - mock_context, - sample_document_and_expected_formats, -): - (sample_document, expected_markdown, _) = sample_document_and_expected_formats - mock_search_documents.return_value = { - "documents_count": 1, - "documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}], - } - mock_get_document_by_id.return_value = sample_document - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_docs.tools.search.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await search_and_retrieve_documents( - mock_context, - document_contains=[sample_document["title"]], - return_format=DocumentFormat.MARKDOWN, - ) - - assert result["documents_count"] == 1 - assert result["documents"][0] == expected_markdown - - -@pytest.mark.asyncio -@patch("arcade_google_docs.tools.search.search_documents") -@patch("arcade_google_docs.tools.search.get_document_by_id") -async def test_search_and_retrieve_documents_in_html_format( - mock_get_document_by_id, - mock_search_documents, - mock_context, - sample_document_and_expected_formats, -): - (sample_document, _, expected_html) = sample_document_and_expected_formats - mock_search_documents.return_value = { - "documents_count": 1, - "documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}], - } - mock_get_document_by_id.return_value = sample_document - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_docs.tools.search.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await search_and_retrieve_documents( - mock_context, - document_contains=[sample_document["title"]], - return_format=DocumentFormat.HTML, - ) - - assert result["documents_count"] == 1 - assert result["documents"][0] == expected_html - - -@pytest.mark.asyncio -@patch("arcade_google_docs.tools.search.search_documents") -@patch("arcade_google_docs.tools.search.get_document_by_id") -async def test_search_and_retrieve_documents_in_google_json_format( - mock_get_document_by_id, - mock_search_documents, - mock_context, - sample_document_and_expected_formats, -): - (sample_document, _, _) = sample_document_and_expected_formats - mock_search_documents.return_value = { - "documents_count": 1, - "documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}], - } - mock_get_document_by_id.return_value = sample_document - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_docs.tools.search.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await search_and_retrieve_documents( - mock_context, - document_contains=[sample_document["title"]], - return_format=DocumentFormat.GOOGLE_API_JSON, - ) - - assert result["documents_count"] == 1 - assert result["documents"][0] == sample_document diff --git a/toolkits/google_drive/.pre-commit-config.yaml b/toolkits/google_drive/.pre-commit-config.yaml deleted file mode 100644 index 28d8a695..00000000 --- a/toolkits/google_drive/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_drive/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_drive/.ruff.toml b/toolkits/google_drive/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_drive/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_drive/Makefile b/toolkits/google_drive/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_drive/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_drive/arcade_google_drive/__init__.py b/toolkits/google_drive/arcade_google_drive/__init__.py deleted file mode 100644 index eff625d6..00000000 --- a/toolkits/google_drive/arcade_google_drive/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_drive.tools import generate_google_file_picker_url, get_file_tree_structure - -__all__ = ["generate_google_file_picker_url", "get_file_tree_structure"] diff --git a/toolkits/google_drive/arcade_google_drive/enums.py b/toolkits/google_drive/arcade_google_drive/enums.py deleted file mode 100644 index 54b1a22b..00000000 --- a/toolkits/google_drive/arcade_google_drive/enums.py +++ /dev/null @@ -1,116 +0,0 @@ -from enum import Enum - - -class Corpora(str, Enum): - """ - Bodies of items (files/documents) to which the query applies. - Prefer 'user' or 'drive' to 'allDrives' for efficiency. - By default, corpora is set to 'user'. - """ - - USER = "user" - DOMAIN = "domain" - DRIVE = "drive" - ALL_DRIVES = "allDrives" - - -class OrderBy(str, Enum): - """ - Sort keys for ordering files in Google Drive. - Each key has both ascending and descending options. - """ - - CREATED_TIME = ( - # When the file was created (ascending) - "createdTime" - ) - CREATED_TIME_DESC = ( - # When the file was created (descending) - "createdTime desc" - ) - FOLDER = ( - # The folder ID, sorted using alphabetical ordering (ascending) - "folder" - ) - FOLDER_DESC = ( - # The folder ID, sorted using alphabetical ordering (descending) - "folder desc" - ) - MODIFIED_BY_ME_TIME = ( - # The last time the file was modified by the user (ascending) - "modifiedByMeTime" - ) - MODIFIED_BY_ME_TIME_DESC = ( - # The last time the file was modified by the user (descending) - "modifiedByMeTime desc" - ) - MODIFIED_TIME = ( - # The last time the file was modified by anyone (ascending) - "modifiedTime" - ) - MODIFIED_TIME_DESC = ( - # The last time the file was modified by anyone (descending) - "modifiedTime desc" - ) - NAME = ( - # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending) - "name" - ) - NAME_DESC = ( - # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending) - "name desc" - ) - NAME_NATURAL = ( - # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending) - "name_natural" - ) - NAME_NATURAL_DESC = ( - # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending) - "name_natural desc" - ) - QUOTA_BYTES_USED = ( - # The number of storage quota bytes used by the file (ascending) - "quotaBytesUsed" - ) - QUOTA_BYTES_USED_DESC = ( - # The number of storage quota bytes used by the file (descending) - "quotaBytesUsed desc" - ) - RECENCY = ( - # The most recent timestamp from the file's date-time fields (ascending) - "recency" - ) - RECENCY_DESC = ( - # The most recent timestamp from the file's date-time fields (descending) - "recency desc" - ) - SHARED_WITH_ME_TIME = ( - # When the file was shared with the user, if applicable (ascending) - "sharedWithMeTime" - ) - SHARED_WITH_ME_TIME_DESC = ( - # When the file was shared with the user, if applicable (descending) - "sharedWithMeTime desc" - ) - STARRED = ( - # Whether the user has starred the file (ascending) - "starred" - ) - STARRED_DESC = ( - # Whether the user has starred the file (descending) - "starred desc" - ) - VIEWED_BY_ME_TIME = ( - # The last time the file was viewed by the user (ascending) - "viewedByMeTime" - ) - VIEWED_BY_ME_TIME_DESC = ( - # The last time the file was viewed by the user (descending) - "viewedByMeTime desc" - ) - - -class DocumentFormat(str, Enum): - MARKDOWN = "markdown" - HTML = "html" - GOOGLE_API_JSON = "google_api_json" diff --git a/toolkits/google_drive/arcade_google_drive/templates.py b/toolkits/google_drive/arcade_google_drive/templates.py deleted file mode 100644 index 55de8614..00000000 --- a/toolkits/google_drive/arcade_google_drive/templates.py +++ /dev/null @@ -1,5 +0,0 @@ -optional_file_picker_instructions_template = ( - "Ensure the user knows that they have the option to select and grant access permissions to " - "additional files and folders via the Google Drive File Picker. " - "The user can pick additional files and folders via the following link: {url}" -) diff --git a/toolkits/google_drive/arcade_google_drive/tools/__init__.py b/toolkits/google_drive/arcade_google_drive/tools/__init__.py deleted file mode 100644 index bc30b32e..00000000 --- a/toolkits/google_drive/arcade_google_drive/tools/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from arcade_google_drive.tools.drive import generate_google_file_picker_url, get_file_tree_structure - -__all__ = [ - "generate_google_file_picker_url", - "get_file_tree_structure", -] diff --git a/toolkits/google_drive/arcade_google_drive/tools/drive.py b/toolkits/google_drive/arcade_google_drive/tools/drive.py deleted file mode 100644 index 03a3fd80..00000000 --- a/toolkits/google_drive/arcade_google_drive/tools/drive.py +++ /dev/null @@ -1,167 +0,0 @@ -import base64 -import json -from typing import Annotated - -from arcade_tdk import ToolContext, ToolMetadataKey, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import ToolExecutionError -from googleapiclient.errors import HttpError - -from arcade_google_drive.enums import OrderBy -from arcade_google_drive.templates import optional_file_picker_instructions_template -from arcade_google_drive.utils import ( - build_drive_service, - build_file_tree, - build_file_tree_request_params, -) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -async def get_file_tree_structure( - context: ToolContext, - include_shared_drives: Annotated[ - bool, "Whether to include shared drives in the file tree structure. Defaults to False." - ] = False, - restrict_to_shared_drive_id: Annotated[ - str | None, - "If provided, only include files from this shared drive in the file tree structure. " - "Defaults to None, which will include files and folders from all drives.", - ] = None, - include_organization_domain_documents: Annotated[ - bool, - "Whether to include documents from the organization's domain. This is applicable to admin " - "users who have permissions to view organization-wide documents in a Google Workspace " - "account. Defaults to False.", - ] = False, - order_by: Annotated[ - list[OrderBy] | None, - "Sort order. Defaults to listing the most recently modified documents first", - ] = None, - limit: Annotated[ - int | None, - "The number of files and folders to list. Defaults to None, " - "which will list all files and folders.", - ] = None, -) -> Annotated[ - dict, - "A dictionary containing the file/folder tree structure in the user's Google Drive", -]: - """ - Get the file/folder tree structure of the user's Google Drive. - """ - service = build_drive_service(context.get_auth_token_or_empty()) - - keep_paginating = True - page_token = None - files = {} - file_tree: dict[str, list[dict]] = {"My Drive": []} - - params = build_file_tree_request_params( - order_by, - page_token, - limit, - include_shared_drives, - restrict_to_shared_drive_id, - include_organization_domain_documents, - ) - - while keep_paginating: - # Get a list of files - results = service.files().list(**params).execute() - - # Update page token - page_token = results.get("nextPageToken") - params["pageToken"] = page_token - keep_paginating = page_token is not None - - for file in results.get("files", []): - files[file["id"]] = file - - if not files: - return {"drives": []} - - file_tree = build_file_tree(files) - - drives = [] - - for drive_id, files in file_tree.items(): # type: ignore[assignment] - if drive_id == "My Drive": - drive = {"name": "My Drive", "children": files} - else: - try: - drive_details = service.drives().get(driveId=drive_id).execute() - drive_name = drive_details.get("name", "Shared Drive (name unavailable)") - except HttpError as e: - drive_name = ( - f"Shared Drive (name unavailable: 'HttpError {e.status_code}: {e.reason}')" - ) - - drive = {"name": drive_name, "id": drive_id, "children": files} - - drives.append(drive) - - file_picker_response = generate_google_file_picker_url( - context, - ) - - return { - "drives": drives, - "file_picker": { - "url": file_picker_response["url"], - "llm_instructions": optional_file_picker_instructions_template.format( - url=file_picker_response["url"] - ), - }, - } - - -@tool( - requires_auth=Google(), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -def generate_google_file_picker_url( - context: ToolContext, -) -> Annotated[dict, "Google File Picker URL for user file selection and permission granting"]: - """Generate a Google File Picker URL for user-driven file selection and authorization. - - This tool generates a URL that directs the end-user to a Google File Picker interface where - where they can select or upload Google Drive files. Users can grant permission to access their - Drive files, providing a secure and authorized way to interact with their files. - - This is particularly useful when prior tools (e.g., those accessing or modifying - Google Docs, Google Sheets, etc.) encountered failures due to file non-existence - (Requested entity was not found) or permission errors. Once the user completes the file - picker flow, the prior tool can be retried. - """ - client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID) - client_id_parts = client_id.split("-") - if not client_id_parts: - raise ToolExecutionError( - message="Invalid Google Client ID", - developer_message=f"Google Client ID '{client_id}' is not valid", - ) - app_id = client_id_parts[0] - cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/") - - config = { - "auth": { - "client_id": client_id, - "app_id": app_id, - }, - } - config_json = json.dumps(config) - config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8") - url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}" - - return { - "url": url, - "llm_instructions": ( - "Instruct the user to click the following link to open the Google Drive File Picker. " - "This will allow them to select files and grant access permissions: {url}" - ), - } diff --git a/toolkits/google_drive/arcade_google_drive/utils.py b/toolkits/google_drive/arcade_google_drive/utils.py deleted file mode 100644 index 2f3d3f7d..00000000 --- a/toolkits/google_drive/arcade_google_drive/utils.py +++ /dev/null @@ -1,114 +0,0 @@ -import logging -from typing import Any - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build - -from arcade_google_drive.enums import Corpora, OrderBy - -## Set up basic configuration for logging to the console with DEBUG level and a specific format. -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Drive service object. - """ - auth_token = auth_token or "" - return build("drive", "v3", credentials=Credentials(auth_token)) - - -def build_file_tree_request_params( - order_by: list[OrderBy] | None, - page_token: str | None, - limit: int | None, - include_shared_drives: bool, - restrict_to_shared_drive_id: str | None, - include_organization_domain_documents: bool, -) -> dict[str, Any]: - if order_by is None: - order_by = [OrderBy.MODIFIED_TIME_DESC] - elif isinstance(order_by, OrderBy): - order_by = [order_by] - - params = { - "q": "trashed = false", - "corpora": Corpora.USER.value, - "pageToken": page_token, - "fields": ( - "files(id, name, parents, mimeType, driveId, size, createdTime, modifiedTime, owners)" - ), - "orderBy": ",".join([item.value for item in order_by]), - } - - if limit: - params["pageSize"] = str(limit) - - if ( - include_shared_drives - or restrict_to_shared_drive_id - or include_organization_domain_documents - ): - params["includeItemsFromAllDrives"] = "true" - params["supportsAllDrives"] = "true" - - if restrict_to_shared_drive_id: - params["driveId"] = restrict_to_shared_drive_id - params["corpora"] = Corpora.DRIVE.value - - if include_organization_domain_documents: - params["corpora"] = Corpora.DOMAIN.value - - return params - - -def build_file_tree(files: dict[str, Any]) -> dict[str, Any]: - file_tree: dict[str, Any] = {} - - for file in files.values(): - owners = file.get("owners", []) - if owners: - owners = [ - {"name": owner.get("displayName", ""), "email": owner.get("emailAddress", "")} - for owner in owners - ] - file["owners"] = owners - - if "size" in file: - file["size"] = {"value": int(file["size"]), "unit": "bytes"} - - # Although "parents" is a list, a file can only have one parent - try: - parent_id = file["parents"][0] - del file["parents"] - except (KeyError, IndexError): - parent_id = None - - # Determine the file's Drive ID - if "driveId" in file: - drive_id = file["driveId"] - del file["driveId"] - # If a shared drive id is not present, the file is in "My Drive" - else: - drive_id = "My Drive" - - if drive_id not in file_tree: - file_tree[drive_id] = [] - - # Root files will have the Drive's id as the parent. If the parent id is not in the files - # list, the file must be at drive's root - if parent_id not in files: - file_tree[drive_id].append(file) - - # Associate the file with its parent - else: - if "children" not in files[parent_id]: - files[parent_id]["children"] = [] - files[parent_id]["children"].append(file) - - return file_tree diff --git a/toolkits/google_drive/conftest.py b/toolkits/google_drive/conftest.py deleted file mode 100644 index c579763d..00000000 --- a/toolkits/google_drive/conftest.py +++ /dev/null @@ -1,197 +0,0 @@ -import pytest - - -@pytest.fixture -def sample_drive_file_tree_request_responses() -> tuple[dict, list]: - files_list = { - "files": [ - # Shared Drive 1 files and folders - { - "id": "19WVyQndQsc0AxxfdrIt5CvDQd6r-BvpqnB8bWZoL7Xk", - "name": "shared-1-folder-1-doc-1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt"], - "createdTime": "2025-02-26T00:28:20.571Z", - "modifiedTime": "2025-02-26T00:28:30.773Z", - "driveId": "0AFqcR6obkydtUk9PVA", - "size": "1024", - }, - { - "id": "1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt", - "name": "shared-1-folder-1", - "mimeType": "application/vnd.google-apps.folder", - "parents": ["0AFqcR6obkydtUk9PVA"], - "createdTime": "2025-02-26T00:27:45.526Z", - "modifiedTime": "2025-02-26T00:27:45.526Z", - "driveId": "0AFqcR6obkydtUk9PVA", - }, - { - "id": "1didt_h-tDjuJ-dmYtHUSyOCPci30K_kSszvg0G3tKBM", - "name": "shared-1-doc-1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["0AFqcR6obkydtUk9PVA"], - "createdTime": "2025-02-26T00:27:19.287Z", - "modifiedTime": "2025-02-26T00:27:26.079Z", - "driveId": "0AFqcR6obkydtUk9PVA", - "size": "1024", - }, - # My Drive files and folders - { - "id": "1vB6sv0MD0hYSraYvWU_fcci3GN_-Jf4g-LfyXdG8ZMo", - "name": "The Birth of MX Engineering", - "mimeType": "application/vnd.google-apps.document", - "parents": ["0AIbBwO2hjeHqUk9PVA"], - "createdTime": "2025-01-24T06:34:22.305Z", - "modifiedTime": "2025-02-25T21:54:30.632Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "6634", - }, - { - "id": "1wv2dmYo0skJTI59ZIcwH9vm-wt7psMwXTvihuEGeHeI", - "name": "test document 1.1.1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO"], - "createdTime": "2025-02-25T17:59:03.325Z", - "modifiedTime": "2025-02-25T17:59:11.445Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "1024", - }, - { - "id": "1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO", - "name": "test folder 1.1", - "mimeType": "application/vnd.google-apps.folder", - "parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"], - "createdTime": "2025-02-25T17:58:58.987Z", - "modifiedTime": "2025-02-25T17:58:58.987Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - }, - { - "id": "1DSmL7d07kjT6b6L-t4JIT06ElUbZ1q0K6_gEpn_UGZ8", - "name": "test document 1.2", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"], - "createdTime": "2025-02-25T17:58:38.628Z", - "modifiedTime": "2025-02-25T17:58:46.713Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "1024", - }, - { - "id": "1Fcxz7HsyO2Zyc-5DTD3zBQnaVrZwD29BP9KD9rPnYfE", - "name": "test document 1.1", - "mimeType": "application/vnd.google-apps.document", - "parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"], - "createdTime": "2025-02-25T17:57:53.850Z", - "modifiedTime": "2025-02-25T17:58:28.745Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - "size": "1024", - }, - { - "id": "1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo", - "name": "test folder 1", - "mimeType": "application/vnd.google-apps.folder", - "parents": ["0AIbBwO2hjeHqUk9PVA"], - "createdTime": "2025-02-25T17:57:46.036Z", - "modifiedTime": "2025-02-25T17:57:46.036Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "one_new_tool_everyday", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": True, - "permissionId": "00356981722324419750", - "emailAddress": "one_new_tool_everyday@arcade.dev", - } - ], - }, - { - "id": "16PUe97yGQeOjQgrgd54iCoxzid4SEvu_J33P_ELd5r8", - "name": "Hello world presentation", - "mimeType": "application/vnd.google-apps.presentation", - "createdTime": "2025-02-18T20:48:52.786Z", - "modifiedTime": "2025-02-19T23:31:20.483Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "john.doe", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": False, - "permissionId": "06420661154928749996", - "emailAddress": "john.doe@arcade.dev", - } - ], - "size": "15774558", - }, - { - "id": "1nG7lSvIyK05N9METPczVJa4iGgE7uoo-A6zpqjpUsDY", - "name": "Shared doc 1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-19T18:51:44.622Z", - "modifiedTime": "2025-02-19T19:30:39.773Z", - "owners": [ - { - "kind": "drive#user", - "displayName": "theboss", - "photoLink": "https://lh3.googleusercontent.com/a-/photo.png", - "me": False, - "permissionId": "11571864250637401873", - "emailAddress": "theboss@arcade.dev", - } - ], - "size": "2700", - }, - ], - } - - drives_get = [ - { - "id": "0AFqcR6obkydtUk9PVA", - "name": "Shared Drive 1", - } - ] - - return files_list, drives_get diff --git a/toolkits/google_drive/evals/eval_google_drive.py b/toolkits/google_drive/evals/eval_google_drive.py deleted file mode 100644 index d49fdff5..00000000 --- a/toolkits/google_drive/evals/eval_google_drive.py +++ /dev/null @@ -1,131 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_drive -from arcade_google_drive.tools import ( - get_file_tree_structure, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google_drive) - - -@tool_eval() -def get_file_tree_structure_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="get my google drive's file tree structure including shared drives", - user_message="get my google drive's file tree structure including shared drives", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": True, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - suite.add_case( - name="get my google drive's file tree structure without shared drives", - user_message="get my google drive's file tree structure without shared drives", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": False, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - suite.add_case( - name="what are the files in the folder 'hello world' in my google drive?", - user_message="what are the files in the folder 'hello world' in my google drive?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": False, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - suite.add_case( - name="how many files are there in all my google drives, including shared ones?", - user_message="how many files are there in all my google drives, including shared ones?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={ - "restrict_to_shared_drive_id": None, - "include_shared_drives": True, - "include_organization_domain_documents": False, - "order_by": None, - "limit": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="include_shared_drives", weight=0.5), - BinaryCritic(critic_field="restrict_to_shared_drive_id", weight=0.5 / 4), - BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5 / 4), - BinaryCritic(critic_field="order_by", weight=0.5 / 4), - BinaryCritic(critic_field="limit", weight=0.5 / 4), - ], - ) - - return suite diff --git a/toolkits/google_drive/evals/eval_tools_understand_filepicker.py b/toolkits/google_drive/evals/eval_tools_understand_filepicker.py deleted file mode 100644 index be8c4536..00000000 --- a/toolkits/google_drive/evals/eval_tools_understand_filepicker.py +++ /dev/null @@ -1,70 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_google_drive.tools import ( - get_file_tree_structure, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(get_file_tree_structure, "GoogleDrive") - -get_file_tree_structure_history = [ - {"role": "system", "content": "Today is 2025-07-03, Thursday."}, - {"role": "user", "content": "get my file tree structure"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_EnqRPmIx3zrquDA7PuZ5NtK6", - "type": "function", - "function": {"name": "GoogleDrive_GetFileTreeStructure", "arguments": "{}"}, - } - ], - }, - { - "role": "tool", - "content": '{"drives":[]}', - "tool_call_id": "call_EnqRPmIx3zrquDA7PuZ5NtK6", - "name": "GoogleDrive_GetFileTreeStructure", - }, - { - "role": "assistant", - "content": "I could not find any files in your Google Drive. To select and grant access permissions to additional files and folders, use [this Google Drive File Picker link](https://cloud.bosslevel.dev/api/v1/google/drive_picker?config=eyXRoIjogM3NsdnFrMmtqODlldNuLmF0=).", - }, -] - - -@tool_eval() -def tools_understand_filepicker_eval_suite() -> EvalSuite: - """Create an evaluation suite for Google Drive tools.""" - suite = EvalSuite( - name="Google Drive Tools Evaluation", - system_message="You are an AI assistant that can manage Google Drive using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Ensure LLM understands that after using file picker, it should call the tool again", - user_message="ok i followed your suggestion and went to that url. how has it changed?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_file_tree_structure, - args={}, - ) - ], - additional_messages=get_file_tree_structure_history, - ) - return suite diff --git a/toolkits/google_drive/pyproject.toml b/toolkits/google_drive/pyproject.toml deleted file mode 100644 index 616c2d77..00000000 --- a/toolkits/google_drive/pyproject.toml +++ /dev/null @@ -1,62 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_drive" -version = "2.0.0" -description = "Arcade.dev LLM tools for Google Drive" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.4,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - -[tool.mypy] -files = [ "arcade_google_drive/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_drive",] diff --git a/toolkits/google_drive/tests/__init__.py b/toolkits/google_drive/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_drive/tests/test_drive.py b/toolkits/google_drive/tests/test_drive.py deleted file mode 100644 index fae9aed6..00000000 --- a/toolkits/google_drive/tests/test_drive.py +++ /dev/null @@ -1,238 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest - -from arcade_google_drive.templates import optional_file_picker_instructions_template -from arcade_google_drive.tools import ( - get_file_tree_structure, -) -from arcade_google_drive.utils import build_drive_service - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - context.get_metadata.side_effect = lambda key: { - "client_id": "123456789-abcdefg.apps.googleusercontent.com", - "coordinator_url": "https://coordinator.example.com", - }.get(key.value if hasattr(key, "value") else key) - return context - - -@pytest.fixture -def mock_service(): - with patch( - "arcade_google_drive.tools.drive." + build_drive_service.__name__ - ) as mock_build_service: - yield mock_build_service.return_value - - -@pytest.mark.asyncio -async def test_get_file_tree_structure( - mock_context, mock_service, sample_drive_file_tree_request_responses -): - files_list_sample, drives_get_sample = sample_drive_file_tree_request_responses - - mock_service.files.return_value.list.return_value.execute.side_effect = [files_list_sample] - mock_service.drives.return_value.get.return_value.execute.side_effect = drives_get_sample - - # Mock the generate_google_file_picker_url function - with patch( - "arcade_google_drive.tools.drive.generate_google_file_picker_url" - ) as mock_file_picker: - mock_file_picker.return_value = { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - } - - result = await get_file_tree_structure(mock_context, include_shared_drives=True) - - expected_file_tree = { - "drives": [ - { - "id": "0AFqcR6obkydtUk9PVA", - "name": "Shared Drive 1", - "children": [ - { - "createdTime": "2025-02-26T00:27:45.526Z", - "id": "1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt", - "mimeType": "application/vnd.google-apps.folder", - "modifiedTime": "2025-02-26T00:27:45.526Z", - "name": "shared-1-folder-1", - "children": [ - { - "createdTime": "2025-02-26T00:28:20.571Z", - "id": "19WVyQndQsc0AxxfdrIt5CvDQd6r-BvpqnB8bWZoL7Xk", - "mimeType": "application/vnd.google-apps.document", - "modifiedTime": "2025-02-26T00:28:30.773Z", - "name": "shared-1-folder-1-doc-1", - "size": { - "unit": "bytes", - "value": 1024, - }, - } - ], - }, - { - "createdTime": "2025-02-26T00:27:19.287Z", - "id": "1didt_h-tDjuJ-dmYtHUSyOCPci30K_kSszvg0G3tKBM", - "mimeType": "application/vnd.google-apps.document", - "modifiedTime": "2025-02-26T00:27:26.079Z", - "name": "shared-1-doc-1", - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - ], - }, - { - "name": "My Drive", - "children": [ - { - "createdTime": "2025-01-24T06:34:22.305Z", - "id": "1vB6sv0MD0hYSraYvWU_fcci3GN_-Jf4g-LfyXdG8ZMo", - "mimeType": "application/vnd.google-apps.document", - "modifiedTime": "2025-02-25T21:54:30.632Z", - "name": "The Birth of MX Engineering", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 6634, - }, - }, - { - "createdTime": "2025-02-25T17:57:46.036Z", - "id": "1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo", - "mimeType": "application/vnd.google-apps.folder", - "modifiedTime": "2025-02-25T17:57:46.036Z", - "name": "test folder 1", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "children": [ - { - "id": "1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO", - "name": "test folder 1.1", - "mimeType": "application/vnd.google-apps.folder", - "createdTime": "2025-02-25T17:58:58.987Z", - "modifiedTime": "2025-02-25T17:58:58.987Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "children": [ - { - "id": "1wv2dmYo0skJTI59ZIcwH9vm-wt7psMwXTvihuEGeHeI", - "name": "test document 1.1.1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-25T17:59:03.325Z", - "modifiedTime": "2025-02-25T17:59:11.445Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - ], - }, - { - "id": "1DSmL7d07kjT6b6L-t4JIT06ElUbZ1q0K6_gEpn_UGZ8", - "name": "test document 1.2", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-25T17:58:38.628Z", - "modifiedTime": "2025-02-25T17:58:46.713Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - { - "id": "1Fcxz7HsyO2Zyc-5DTD3zBQnaVrZwD29BP9KD9rPnYfE", - "name": "test document 1.1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-25T17:57:53.850Z", - "modifiedTime": "2025-02-25T17:58:28.745Z", - "owners": [ - { - "email": "one_new_tool_everyday@arcade.dev", - "name": "one_new_tool_everyday", - } - ], - "size": { - "unit": "bytes", - "value": 1024, - }, - }, - ], - }, - { - "createdTime": "2025-02-18T20:48:52.786Z", - "id": "16PUe97yGQeOjQgrgd54iCoxzid4SEvu_J33P_ELd5r8", - "mimeType": "application/vnd.google-apps.presentation", - "modifiedTime": "2025-02-19T23:31:20.483Z", - "name": "Hello world presentation", - "owners": [ - { - "email": "john.doe@arcade.dev", - "name": "john.doe", - } - ], - "size": { - "unit": "bytes", - "value": 15774558, - }, - }, - { - "id": "1nG7lSvIyK05N9METPczVJa4iGgE7uoo-A6zpqjpUsDY", - "name": "Shared doc 1", - "mimeType": "application/vnd.google-apps.document", - "createdTime": "2025-02-19T18:51:44.622Z", - "modifiedTime": "2025-02-19T19:30:39.773Z", - "owners": [ - { - "name": "theboss", - "email": "theboss@arcade.dev", - } - ], - "size": { - "unit": "bytes", - "value": 2700, - }, - }, - ], - }, - ], - "file_picker": { - "url": "https://coordinator.example.com/google/drive_picker?config=test_config", - "llm_instructions": optional_file_picker_instructions_template.format( - url="https://coordinator.example.com/google/drive_picker?config=test_config" - ), - }, - } - - assert result == expected_file_tree diff --git a/toolkits/google_finance/.pre-commit-config.yaml b/toolkits/google_finance/.pre-commit-config.yaml deleted file mode 100644 index d83c2356..00000000 --- a/toolkits/google_finance/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_finance/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_finance/.ruff.toml b/toolkits/google_finance/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/google_finance/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_finance/LICENSE b/toolkits/google_finance/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/google_finance/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_finance/Makefile b/toolkits/google_finance/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_finance/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_finance/arcade_google_finance/__init__.py b/toolkits/google_finance/arcade_google_finance/__init__.py deleted file mode 100644 index 69090cec..00000000 --- a/toolkits/google_finance/arcade_google_finance/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_finance.tools import get_stock_historical_data, get_stock_summary - -__all__ = ["get_stock_historical_data", "get_stock_summary"] diff --git a/toolkits/google_finance/arcade_google_finance/enums.py b/toolkits/google_finance/arcade_google_finance/enums.py deleted file mode 100644 index d64df398..00000000 --- a/toolkits/google_finance/arcade_google_finance/enums.py +++ /dev/null @@ -1,12 +0,0 @@ -from enum import Enum - - -class GoogleFinanceWindow(Enum): - ONE_DAY = "1D" - FIVE_DAYS = "5D" - ONE_MONTH = "1M" - SIX_MONTHS = "6M" - YEAR_TO_DATE = "YTD" - ONE_YEAR = "1Y" - FIVE_YEARS = "5Y" - MAX = "MAX" diff --git a/toolkits/google_finance/arcade_google_finance/tools/__init__.py b/toolkits/google_finance/arcade_google_finance/tools/__init__.py deleted file mode 100644 index 14446019..00000000 --- a/toolkits/google_finance/arcade_google_finance/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_finance.tools.google_finance import get_stock_historical_data, get_stock_summary - -__all__ = ["get_stock_historical_data", "get_stock_summary"] diff --git a/toolkits/google_finance/arcade_google_finance/tools/google_finance.py b/toolkits/google_finance/arcade_google_finance/tools/google_finance.py deleted file mode 100644 index 9a505065..00000000 --- a/toolkits/google_finance/arcade_google_finance/tools/google_finance.py +++ /dev/null @@ -1,86 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool - -from arcade_google_finance.enums import GoogleFinanceWindow -from arcade_google_finance.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_stock_summary( - context: ToolContext, - ticker_symbol: Annotated[ - str, - "The stock ticker to get summary for. For example, 'GOOG' is the ticker symbol for Google", - ], - exchange_identifier: Annotated[ - str, - "The exchange identifier. This part indicates the market where the " - "stock is traded. For example, 'NASDAQ', 'NYSE', 'TSE', 'LSE', etc.", - ], -) -> Annotated[dict[str, Any], "Summary of the stock's recent performance"]: - """Retrieve the summary information for a given stock ticker using the Google Finance API. - - Gets the stock's current price as well as price movement from the most recent trading day. - """ - # Prepare the request - query = ( - f"{ticker_symbol.upper()}:{exchange_identifier.upper()}" - if exchange_identifier - else ticker_symbol.upper() - ) - params = prepare_params("google_finance", q=query) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - summary: dict = results.get("summary", {}) - - return summary - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_stock_historical_data( - context: ToolContext, - ticker_symbol: Annotated[ - str, - "The stock ticker to get summary for. For example, 'GOOG' is the ticker symbol for Google", - ], - exchange_identifier: Annotated[ - str, - "The exchange identifier. This part indicates the market where the " - "stock is traded. For example, 'NASDAQ', 'NYSE', 'TSE', 'LSE', etc.", - ], - window: Annotated[ - GoogleFinanceWindow, "Time window for the graph data. Defaults to 1 month" - ] = GoogleFinanceWindow.ONE_MONTH, -) -> Annotated[ - dict[str, Any], - "A stock's price and volume data at a specific time interval over a specified time window", -]: - """Fetch historical stock price data over a specified time window - - Returns a stock's price and volume data over a specified time window - """ - # Prepare the request - query = ( - f"{ticker_symbol.upper()}:{exchange_identifier.upper()}" - if exchange_identifier - else ticker_symbol.upper() - ) - params = prepare_params("google_finance", q=query, window=window.value) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - data = { - "summary": results.get("summary", {}), - "graph": results.get("graph", []), - } - key_events = results.get("key_events") - if key_events: - data["key_events"] = key_events - - return data diff --git a/toolkits/google_finance/arcade_google_finance/utils.py b/toolkits/google_finance/arcade_google_finance/utils.py deleted file mode 100644 index 00c0dcba..00000000 --- a/toolkits/google_finance/arcade_google_finance/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) diff --git a/toolkits/google_finance/pyproject.toml b/toolkits/google_finance/pyproject.toml deleted file mode 100644 index 0f0f97c1..00000000 --- a/toolkits/google_finance/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_finance" -version = "2.0.0" -description = "Arcade.dev LLM tools for getting financial data via Google Finance" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_finance/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_finance",] diff --git a/toolkits/google_flights/.pre-commit-config.yaml b/toolkits/google_flights/.pre-commit-config.yaml deleted file mode 100644 index 0e99e3d4..00000000 --- a/toolkits/google_flights/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_flights/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_flights/.ruff.toml b/toolkits/google_flights/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/google_flights/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_flights/LICENSE b/toolkits/google_flights/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/google_flights/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_flights/Makefile b/toolkits/google_flights/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_flights/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_flights/arcade_google_flights/__init__.py b/toolkits/google_flights/arcade_google_flights/__init__.py deleted file mode 100644 index e4a99dac..00000000 --- a/toolkits/google_flights/arcade_google_flights/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_flights.tools import search_one_way_flights - -__all__ = ["search_one_way_flights"] diff --git a/toolkits/google_flights/arcade_google_flights/enums.py b/toolkits/google_flights/arcade_google_flights/enums.py deleted file mode 100644 index d7a514b4..00000000 --- a/toolkits/google_flights/arcade_google_flights/enums.py +++ /dev/null @@ -1,53 +0,0 @@ -from enum import Enum - - -class GoogleFlightsTravelClass(Enum): - ECONOMY = "ECONOMY" - PREMIUM_ECONOMY = "PREMIUM_ECONOMY" - BUSINESS = "BUSINESS" - FIRST = "FIRST" - - def to_api_value(self) -> int: - _map = { - "ECONOMY": 1, - "PREMIUM_ECONOMY": 2, - "BUSINESS": 3, - "FIRST": 4, - } - return _map[self.value] - - -class GoogleFlightsMaxStops(Enum): - ANY = "ANY" - NONSTOP = "NONSTOP" - ONE = "ONE" - TWO = "TWO" - - def to_api_value(self) -> int: - _map = { - "ANY": 0, - "NONSTOP": 1, - "ONE": 2, - "TWO": 3, - } - return _map[self.value] - - -class GoogleFlightsSortBy(Enum): - TOP_FLIGHTS = "TOP_FLIGHTS" - PRICE = "PRICE" - DEPARTURE_TIME = "DEPARTURE_TIME" - ARRIVAL_TIME = "ARRIVAL_TIME" - DURATION = "DURATION" - EMISSIONS = "EMISSIONS" - - def to_api_value(self) -> int: - _map = { - "TOP_FLIGHTS": 1, - "PRICE": 2, - "DEPARTURE_TIME": 3, - "ARRIVAL_TIME": 4, - "DURATION": 5, - "EMISSIONS": 6, - } - return _map[self.value] diff --git a/toolkits/google_flights/arcade_google_flights/tools/__init__.py b/toolkits/google_flights/arcade_google_flights/tools/__init__.py deleted file mode 100644 index de99e742..00000000 --- a/toolkits/google_flights/arcade_google_flights/tools/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from arcade_google_flights.tools.google_flights import ( - search_one_way_flights, -) - -__all__ = ["search_one_way_flights"] diff --git a/toolkits/google_flights/arcade_google_flights/tools/google_flights.py b/toolkits/google_flights/arcade_google_flights/tools/google_flights.py deleted file mode 100644 index 1609a004..00000000 --- a/toolkits/google_flights/arcade_google_flights/tools/google_flights.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool - -from arcade_google_flights.enums import ( - GoogleFlightsMaxStops, - GoogleFlightsSortBy, - GoogleFlightsTravelClass, -) -from arcade_google_flights.utils import call_serpapi, parse_flight_results, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_one_way_flights( - context: ToolContext, - departure_airport_code: Annotated[ - str, "The departure airport code. An uppercase 3-letter code" - ], - arrival_airport_code: Annotated[str, "The arrival airport code. An uppercase 3-letter code"], - outbound_date: Annotated[str, "Flight departure date in YYYY-MM-DD format"], - currency_code: Annotated[ - str | None, "Currency of the returned prices. Defaults to 'USD'" - ] = "USD", - travel_class: Annotated[ - GoogleFlightsTravelClass, - "Travel class of the flight. Defaults to 'ECONOMY'", - ] = GoogleFlightsTravelClass.ECONOMY, - num_adults: Annotated[int | None, "Number of adult passengers. Defaults to 1"] = 1, - num_children: Annotated[int | None, "Number of child passengers. Defaults to 0"] = 0, - max_stops: Annotated[ - GoogleFlightsMaxStops, - "Maximum number of stops (layovers) for the flight. Defaults to any number of stops", - ] = GoogleFlightsMaxStops.ANY, - sort_by: Annotated[ - GoogleFlightsSortBy, - "The sorting order of the results. Defaults to TOP_FLIGHTS.", - ] = GoogleFlightsSortBy.TOP_FLIGHTS, -) -> Annotated[dict[str, Any], "Flight search results from the Google Flights API"]: - """Retrieve flight search results for a one-way flight using Google Flights""" - params = prepare_params( - "google_flights", - departure_id=departure_airport_code, - arrival_id=arrival_airport_code, - outbound_date=outbound_date, - currency=currency_code, - travel_class=travel_class.to_api_value(), - adults=num_adults, - children=num_children, - stops=max_stops.to_api_value(), - sort_by=sort_by.to_api_value(), - type=2, # indicates one-way - deep_search=True, # Same search depth as the Google Flights page in the browser - ) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - flights = parse_flight_results(results) - - return flights diff --git a/toolkits/google_flights/arcade_google_flights/utils.py b/toolkits/google_flights/arcade_google_flights/utils.py deleted file mode 100644 index f314248b..00000000 --- a/toolkits/google_flights/arcade_google_flights/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -def parse_flight_results(results: dict[str, Any]) -> dict[str, Any]: - """Parse the flight results from the Google Flights API - - Note: Best flights is not always returned from the API. - """ - flight_data = {} - flights = [] - - if "best_flights" in results: - flights.extend(results["best_flights"]) - if "other_flights" in results: - flights.extend(results["other_flights"]) - if "price_insights" in results: - flight_data["price_insights"] = results["price_insights"] - - flight_data["flights"] = flights - - return flight_data diff --git a/toolkits/google_flights/pyproject.toml b/toolkits/google_flights/pyproject.toml deleted file mode 100644 index c99a7199..00000000 --- a/toolkits/google_flights/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_flights" -version = "2.0.0" -description = "Arcade.dev LLM tools for getting flights via Google Flights" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_flights/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_flights",] diff --git a/toolkits/google_hotels/.pre-commit-config.yaml b/toolkits/google_hotels/.pre-commit-config.yaml deleted file mode 100644 index 95883156..00000000 --- a/toolkits/google_hotels/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_hotels/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_hotels/.ruff.toml b/toolkits/google_hotels/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/google_hotels/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_hotels/LICENSE b/toolkits/google_hotels/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/google_hotels/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_hotels/Makefile b/toolkits/google_hotels/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_hotels/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_hotels/arcade_google_hotels/__init__.py b/toolkits/google_hotels/arcade_google_hotels/__init__.py deleted file mode 100644 index fecdfddb..00000000 --- a/toolkits/google_hotels/arcade_google_hotels/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_hotels.tools import search_hotels - -__all__ = ["search_hotels"] diff --git a/toolkits/google_hotels/arcade_google_hotels/enums.py b/toolkits/google_hotels/arcade_google_hotels/enums.py deleted file mode 100644 index 971bd04f..00000000 --- a/toolkits/google_hotels/arcade_google_hotels/enums.py +++ /dev/null @@ -1,17 +0,0 @@ -from enum import Enum - - -class GoogleHotelsSortBy(Enum): - RELEVANCE = "RELEVANCE" - LOWEST_PRICE = "LOWEST_PRICE" - HIGHEST_RATING = "HIGHEST_RATING" - MOST_REVIEWED = "MOST_REVIEWED" - - def to_api_value(self) -> int | None: - _map = { - "RELEVANCE": None, - "LOWEST_PRICE": 3, - "HIGHEST_RATING": 8, - "MOST_REVIEWED": 13, - } - return _map[self.value] diff --git a/toolkits/google_hotels/arcade_google_hotels/tools/__init__.py b/toolkits/google_hotels/arcade_google_hotels/tools/__init__.py deleted file mode 100644 index dc3bcb98..00000000 --- a/toolkits/google_hotels/arcade_google_hotels/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_hotels.tools.google_hotels import search_hotels - -__all__ = ["search_hotels"] diff --git a/toolkits/google_hotels/arcade_google_hotels/tools/google_hotels.py b/toolkits/google_hotels/arcade_google_hotels/tools/google_hotels.py deleted file mode 100644 index 8cef7661..00000000 --- a/toolkits/google_hotels/arcade_google_hotels/tools/google_hotels.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool - -from arcade_google_hotels.enums import GoogleHotelsSortBy -from arcade_google_hotels.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_hotels( - context: ToolContext, - location: Annotated[str, "Location to search for hotels, e.g., a city name, a state, etc."], - check_in_date: Annotated[str, "Check-in date in YYYY-MM-DD format"], - check_out_date: Annotated[str, "Check-out date in YYYY-MM-DD format"], - query: Annotated[ - str | None, "Anything that would be used in a regular Google Hotels search" - ] = None, - currency: Annotated[str | None, "Currency code for prices. Defaults to 'USD'"] = "USD", - min_price: Annotated[int | None, "Minimum price per night. Defaults to no minimum"] = None, - max_price: Annotated[int | None, "Maximum price per night. Defaults to no maximum"] = None, - num_adults: Annotated[int | None, "Number of adults per room. Defaults to 2"] = 2, - num_children: Annotated[int | None, "Number of children per room. Defaults to 0"] = 0, - sort_by: Annotated[ - GoogleHotelsSortBy, "The sorting order of the results. Defaults to RELEVANCE" - ] = GoogleHotelsSortBy.RELEVANCE, - num_results: Annotated[ - int | None, "Maximum number of results to return. Defaults to 5. Max 20" - ] = 5, -) -> Annotated[dict[str, Any], "Hotel search results from the Google Hotels API"]: - """Retrieve hotel search results using the Google Hotels API.""" - # Prepare the request - params = prepare_params( - "google_hotels", - q=f"{query}, {location}" if query else location, - check_in_date=check_in_date, - check_out_date=check_out_date, - currency=currency, - min_price=min_price, - max_price=max_price, - adults=num_adults, - children=num_children, - sort_by=sort_by.to_api_value(), - ) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - properties = results.get("properties", [])[:num_results] - - # Remove unwanted fields from each property - for hotel in properties: - hotel.pop("images", None) - hotel.pop("extracted_hotel_class", None) - hotel.pop("reviews_breakdown", None) - hotel.pop("serpapi_property_details_link", None) - - return {"properties": properties} diff --git a/toolkits/google_hotels/arcade_google_hotels/utils.py b/toolkits/google_hotels/arcade_google_hotels/utils.py deleted file mode 100644 index 00c0dcba..00000000 --- a/toolkits/google_hotels/arcade_google_hotels/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) diff --git a/toolkits/google_hotels/pyproject.toml b/toolkits/google_hotels/pyproject.toml deleted file mode 100644 index 20ebf5e4..00000000 --- a/toolkits/google_hotels/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_hotels" -version = "2.0.0" -description = "Arcade.dev LLM tools for getting Hotel information via Google Hotels" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_hotels/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_hotels",] diff --git a/toolkits/google_jobs/.pre-commit-config.yaml b/toolkits/google_jobs/.pre-commit-config.yaml deleted file mode 100644 index e347e6ee..00000000 --- a/toolkits/google_jobs/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_jobs/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_jobs/.ruff.toml b/toolkits/google_jobs/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/google_jobs/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_jobs/LICENSE b/toolkits/google_jobs/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/google_jobs/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_jobs/Makefile b/toolkits/google_jobs/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_jobs/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_jobs/arcade_google_jobs/__init__.py b/toolkits/google_jobs/arcade_google_jobs/__init__.py deleted file mode 100644 index 82988ada..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_jobs.tools import search_jobs - -__all__ = ["search_jobs"] diff --git a/toolkits/google_jobs/arcade_google_jobs/constants.py b/toolkits/google_jobs/arcade_google_jobs/constants.py deleted file mode 100644 index 6101a22a..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -import os - -DEFAULT_GOOGLE_LANGUAGE = os.getenv("ARCADE_GOOGLE_LANGUAGE", "en") - -DEFAULT_GOOGLE_JOBS_LANGUAGE = os.getenv("ARCADE_GOOGLE_JOBS_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE) diff --git a/toolkits/google_jobs/arcade_google_jobs/enums.py b/toolkits/google_jobs/arcade_google_jobs/enums.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_jobs/arcade_google_jobs/exceptions.py b/toolkits/google_jobs/arcade_google_jobs/exceptions.py deleted file mode 100644 index 1deb0eed..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -import json - -from arcade_tdk.errors import RetryableToolError - -from arcade_google_jobs.google_data import LANGUAGE_CODES - - -class GoogleRetryableError(RetryableToolError): - pass - - -class LanguageNotFoundError(GoogleRetryableError): - def __init__(self, language: str | None) -> None: - valid_languages = json.dumps(LANGUAGE_CODES, default=str) - message = f"Language not found: '{language}'." - additional_message = f"Valid languages are: {valid_languages}" - super().__init__(message, additional_prompt_content=additional_message) diff --git a/toolkits/google_jobs/arcade_google_jobs/google_data.py b/toolkits/google_jobs/arcade_google_jobs/google_data.py deleted file mode 100644 index 7ed7dff6..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/google_data.py +++ /dev/null @@ -1,33 +0,0 @@ -LANGUAGE_CODES = { - "ar": "Arabic", - "bn": "Bengali", - "da": "Danish", - "de": "German", - "el": "Greek", - "en": "English", - "es": "Spanish", - "fi": "Finnish", - "fr": "French", - "hi": "Hindi", - "hu": "Hungarian", - "id": "Indonesian", - "it": "Italian", - "ja": "Japanese", - "ko": "Korean", - "nl": "Dutch", - "ms": "Malay", - "no": "Norwegian", - "pcm": "Nigerian Pidgin", - "pl": "Polish", - "pt": "Portuguese", - "pt-br": "Portuguese (Brazil)", - "pt-pt": "Portuguese (Portugal)", - "ru": "Russian", - "sv": "Swedish", - "tl": "Filipino", - "tr": "Turkish", - "uk": "Ukrainian", - "zh": "Chinese", - "zh-cn": "Chinese (Simplified)", - "zh-tw": "Chinese (Traditional)", -} diff --git a/toolkits/google_jobs/arcade_google_jobs/tools/__init__.py b/toolkits/google_jobs/arcade_google_jobs/tools/__init__.py deleted file mode 100644 index e0acd323..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_jobs.tools.google_jobs import search_jobs - -__all__ = ["search_jobs"] diff --git a/toolkits/google_jobs/arcade_google_jobs/tools/google_jobs.py b/toolkits/google_jobs/arcade_google_jobs/tools/google_jobs.py deleted file mode 100644 index 4cde1b15..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/tools/google_jobs.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool - -from arcade_google_jobs.constants import DEFAULT_GOOGLE_JOBS_LANGUAGE -from arcade_google_jobs.exceptions import LanguageNotFoundError -from arcade_google_jobs.google_data import LANGUAGE_CODES -from arcade_google_jobs.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_jobs( - context: ToolContext, - query: Annotated[ - str, - "Search query. Provide a job title, company name, and/or any keywords in general " - "representing what kind of jobs the user is looking for. E.g. 'software engineer' " - "or 'data analyst at Apple'.", - ], - location: Annotated[ - str | None, - "Location to search for jobs. E.g. 'United States' or 'New York, NY'. Defaults to None.", - ] = None, - language: Annotated[ - str, - "2-character language code to use in the Google Jobs search. " - f"E.g. 'en' for English. Defaults to '{DEFAULT_GOOGLE_JOBS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_JOBS_LANGUAGE, - limit: Annotated[ - int, - "Maximum number of results to retrieve. Defaults to 10 (max supported by the API).", - ] = 10, - next_page_token: Annotated[ - str | None, - "Next page token to paginate results. Defaults to None (start from the first page).", - ] = None, -) -> Annotated[dict, "Google Jobs results"]: - """Search Google Jobs using SerpAPI.""" - if language not in LANGUAGE_CODES: - raise LanguageNotFoundError(language) - - params = prepare_params( - engine="google_jobs", - q=query, - hl=language, - ) - - if location: - params["location"] = location - - if next_page_token: - params["next_page_token"] = next_page_token - - results = call_serpapi(context, params) - jobs_results = results.get("jobs_results", []) - - try: - next_page_token = results["serpapi_pagination"]["next_page_token"] - except KeyError: - next_page_token = None - - return { - "jobs": jobs_results[:limit], - "next_page_token": next_page_token, - } diff --git a/toolkits/google_jobs/arcade_google_jobs/utils.py b/toolkits/google_jobs/arcade_google_jobs/utils.py deleted file mode 100644 index 00c0dcba..00000000 --- a/toolkits/google_jobs/arcade_google_jobs/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) diff --git a/toolkits/google_jobs/evals/eval_google_jobs.py b/toolkits/google_jobs/evals/eval_google_jobs.py deleted file mode 100644 index d14a3302..00000000 --- a/toolkits/google_jobs/evals/eval_google_jobs.py +++ /dev/null @@ -1,157 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NoneCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_jobs -from arcade_google_jobs.constants import DEFAULT_GOOGLE_JOBS_LANGUAGE -from arcade_google_jobs.tools import search_jobs - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the Google Jobs tool -catalog.add_module(arcade_google_jobs) - - -@tool_eval() -def google_jobs_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Jobs tool.""" - suite = EvalSuite( - name="Google Jobs Tool Evaluation", - system_message="You are an AI assistant that can perform job searches using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search for 'backend engineer' jobs", - user_message="Search for 'backend engineer' jobs", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "backend engineer", - "location": None, - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.5), - NoneCritic(critic_field="location", weight=0.1), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - NoneCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Search for 'senior backend engineer' jobs that are part-time", - user_message="Search for senior backend engineer jobs that are part-time", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "part-time senior backend engineer", - "location": None, - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.5), - NoneCritic(critic_field="location", weight=0.1), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - NoneCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Search for 'backend engineer' jobs in San Francisco", - user_message="Search for 'backend engineer' jobs in San Francisco", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "backend engineer", - "location": "San Francisco", - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.35), - SimilarityCritic(critic_field="location", weight=0.35), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - NoneCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Get the first 3 jobs for 'backend engineer' in San Francisco", - user_message="Get the first 3 jobs for 'backend engineer' in San Francisco", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "backend engineer", - "location": "San Francisco", - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 3, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.25), - SimilarityCritic(critic_field="location", weight=0.25), - BinaryCritic(critic_field="language", weight=0.125), - BinaryCritic(critic_field="limit", weight=0.25), - NoneCritic(critic_field="next_page_token", weight=0.125), - ], - ) - - suite.add_case( - name="Search for 'engenheiro de software' jobs in Brazil, return results in Portuguese", - user_message="Search for 'engenheiro de software' jobs in Brazil, return results in Portuguese", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "engenheiro de software", - "location": "Brazil", - "language": "pt", - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.25), - SimilarityCritic(critic_field="location", weight=0.125), - BinaryCritic(critic_field="language", weight=0.25), - BinaryCritic(critic_field="limit", weight=0.125), - NoneCritic(critic_field="next_page_token", weight=0.125), - ], - ) - - return suite diff --git a/toolkits/google_jobs/pyproject.toml b/toolkits/google_jobs/pyproject.toml deleted file mode 100644 index ca5152ae..00000000 --- a/toolkits/google_jobs/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_jobs" -version = "2.0.0" -description = "Arcade.dev LLM tools for getting job postings via Google Jobs" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_jobs/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_jobs",] diff --git a/toolkits/google_jobs/tests/__init__.py b/toolkits/google_jobs/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_jobs/tests/test_google_jobs.py b/toolkits/google_jobs/tests/test_google_jobs.py deleted file mode 100644 index ad0848dd..00000000 --- a/toolkits/google_jobs/tests/test_google_jobs.py +++ /dev/null @@ -1,90 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem - -from arcade_google_jobs.exceptions import LanguageNotFoundError -from arcade_google_jobs.tools.google_jobs import search_jobs - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="serp_api_key", value="fake_api_key")]) - - -@pytest.mark.asyncio -@patch("arcade_google_jobs.utils.SerpClient") -async def test_search_jobs_success(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search().as_dict.return_value = { - "jobs_results": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ] - } - - result = await search_jobs(mock_context, "engenheiro de software", "Brazil", "pt", 10, None) - assert result == { - "jobs": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ], - "next_page_token": None, - } - - -@pytest.mark.asyncio -@patch("arcade_google_jobs.utils.SerpClient") -async def test_search_jobs_success_with_custom_language_and_location( - mock_serp_client, mock_context -): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search().as_dict.return_value = { - "jobs_results": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ] - } - - result = await search_jobs( - context=mock_context, - query="engenheiro de software", - location="Brazil", - language="pt", - limit=10, - next_page_token=None, - ) - - mock_serp_client_instance.search.assert_called_with({ - "engine": "google_jobs", - "q": "engenheiro de software", - "hl": "pt", - "location": "Brazil", - }) - - assert result == { - "jobs": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ], - "next_page_token": None, - } - - -@pytest.mark.asyncio -@patch("arcade_google_jobs.utils.SerpClient") -async def test_search_jobs_language_not_found_error(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search().as_dict.return_value = { - "jobs_results": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ] - } - - with pytest.raises(LanguageNotFoundError): - await search_jobs( - context=mock_context, - query="backend engineer", - language="invalid_language", - ) diff --git a/toolkits/google_maps/.pre-commit-config.yaml b/toolkits/google_maps/.pre-commit-config.yaml deleted file mode 100644 index 7005e27d..00000000 --- a/toolkits/google_maps/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_maps/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_maps/.ruff.toml b/toolkits/google_maps/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/google_maps/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_maps/LICENSE b/toolkits/google_maps/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/google_maps/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_maps/Makefile b/toolkits/google_maps/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_maps/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_maps/arcade_google_maps/__init__.py b/toolkits/google_maps/arcade_google_maps/__init__.py deleted file mode 100644 index f116016b..00000000 --- a/toolkits/google_maps/arcade_google_maps/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from arcade_google_maps.tools import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) - -__all__ = ["get_directions_between_addresses", "get_directions_between_coordinates"] diff --git a/toolkits/google_maps/arcade_google_maps/constants.py b/toolkits/google_maps/arcade_google_maps/constants.py deleted file mode 100644 index e5877df2..00000000 --- a/toolkits/google_maps/arcade_google_maps/constants.py +++ /dev/null @@ -1,14 +0,0 @@ -import os - -from arcade_google_maps.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode - -DEFAULT_GOOGLE_LANGUAGE = os.getenv("ARCADE_GOOGLE_LANGUAGE", "en") - -DEFAULT_GOOGLE_MAPS_LANGUAGE = os.getenv("ARCADE_GOOGLE_MAPS_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE) -DEFAULT_GOOGLE_MAPS_COUNTRY = os.getenv("ARCADE_GOOGLE_MAPS_COUNTRY") -DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT = GoogleMapsDistanceUnit( - os.getenv("ARCADE_GOOGLE_MAPS_DISTANCE_UNIT", GoogleMapsDistanceUnit.KM.value) -) -DEFAULT_GOOGLE_MAPS_TRAVEL_MODE = GoogleMapsTravelMode( - os.getenv("ARCADE_GOOGLE_MAPS_TRAVEL_MODE", GoogleMapsTravelMode.BEST.value) -) diff --git a/toolkits/google_maps/arcade_google_maps/enums.py b/toolkits/google_maps/arcade_google_maps/enums.py deleted file mode 100644 index 98e22613..00000000 --- a/toolkits/google_maps/arcade_google_maps/enums.py +++ /dev/null @@ -1,35 +0,0 @@ -from enum import Enum - - -class GoogleMapsTravelMode(Enum): - BEST = "best" - DRIVING = "driving" - MOTORCYCLE = "motorcycle" - PUBLIC_TRANSPORTATION = "public_transportation" - WALKING = "walking" - BICYCLE = "bicycle" - FLIGHT = "flight" - - def to_api_value(self) -> int: - _map = { - str(self.BEST): 6, - str(self.DRIVING): 0, - str(self.MOTORCYCLE): 9, - str(self.PUBLIC_TRANSPORTATION): 3, - str(self.WALKING): 2, - str(self.BICYCLE): 1, - str(self.FLIGHT): 4, - } - return _map[str(self)] - - -class GoogleMapsDistanceUnit(Enum): - KM = "km" - MILES = "mi" - - def to_api_value(self) -> int: - _map = { - str(self.KM): 0, - str(self.MILES): 1, - } - return _map[str(self)] diff --git a/toolkits/google_maps/arcade_google_maps/exceptions.py b/toolkits/google_maps/arcade_google_maps/exceptions.py deleted file mode 100644 index 450b70c6..00000000 --- a/toolkits/google_maps/arcade_google_maps/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -from arcade_tdk.errors import RetryableToolError - -from arcade_google_maps.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -class GoogleRetryableError(RetryableToolError): - pass - - -class CountryNotFoundError(GoogleRetryableError): - def __init__(self, country: str | None) -> None: - valid_countries = json.dumps(COUNTRY_CODES, default=str) - message = f"Country not found: '{country}'." - additional_message = f"Valid countries are: {valid_countries}" - super().__init__(message, additional_prompt_content=additional_message) - - -class LanguageNotFoundError(GoogleRetryableError): - def __init__(self, language: str | None) -> None: - valid_languages = json.dumps(LANGUAGE_CODES, default=str) - message = f"Language not found: '{language}'." - additional_message = f"Valid languages are: {valid_languages}" - super().__init__(message, additional_prompt_content=additional_message) diff --git a/toolkits/google_maps/arcade_google_maps/google_data.py b/toolkits/google_maps/arcade_google_maps/google_data.py deleted file mode 100644 index 789e3183..00000000 --- a/toolkits/google_maps/arcade_google_maps/google_data.py +++ /dev/null @@ -1,281 +0,0 @@ -COUNTRY_CODES = { - "af": "Afghanistan", - "al": "Albania", - "dz": "Algeria", - "as": "American Samoa", - "ad": "Andorra", - "ao": "Angola", - "ai": "Anguilla", - "aq": "Antarctica", - "ag": "Antigua and Barbuda", - "ar": "Argentina", - "am": "Armenia", - "aw": "Aruba", - "au": "Australia", - "at": "Austria", - "az": "Azerbaijan", - "bs": "Bahamas", - "bh": "Bahrain", - "bd": "Bangladesh", - "bb": "Barbados", - "by": "Belarus", - "be": "Belgium", - "bz": "Belize", - "bj": "Benin", - "bm": "Bermuda", - "bt": "Bhutan", - "bo": "Bolivia", - "ba": "Bosnia and Herzegovina", - "bw": "Botswana", - "bv": "Bouvet Island", - "br": "Brazil", - "io": "British Indian Ocean Territory", - "bn": "Brunei Darussalam", - "bg": "Bulgaria", - "bf": "Burkina Faso", - "bi": "Burundi", - "kh": "Cambodia", - "cm": "Cameroon", - "ca": "Canada", - "cv": "Cape Verde", - "ky": "Cayman Islands", - "cf": "Central African Republic", - "td": "Chad", - "cl": "Chile", - "cn": "China", - "cx": "Christmas Island", - "cc": "Cocos (Keeling) Islands", - "co": "Colombia", - "km": "Comoros", - "cg": "Congo", - "cd": "Congo, the Democratic Republic of the", - "ck": "Cook Islands", - "cr": "Costa Rica", - "ci": "Cote D'ivoire", - "hr": "Croatia", - "cu": "Cuba", - "cy": "Cyprus", - "cz": "Czech Republic", - "dk": "Denmark", - "dj": "Djibouti", - "dm": "Dominica", - "do": "Dominican Republic", - "ec": "Ecuador", - "eg": "Egypt", - "sv": "El Salvador", - "gq": "Equatorial Guinea", - "er": "Eritrea", - "ee": "Estonia", - "et": "Ethiopia", - "fk": "Falkland Islands (Malvinas)", - "fo": "Faroe Islands", - "fj": "Fiji", - "fi": "Finland", - "fr": "France", - "gf": "French Guiana", - "pf": "French Polynesia", - "tf": "French Southern Territories", - "ga": "Gabon", - "gm": "Gambia", - "ge": "Georgia", - "de": "Germany", - "gh": "Ghana", - "gi": "Gibraltar", - "gr": "Greece", - "gl": "Greenland", - "gd": "Grenada", - "gp": "Guadeloupe", - "gu": "Guam", - "gt": "Guatemala", - "gg": "Guernsey", - "gn": "Guinea", - "gw": "Guinea-Bissau", - "gy": "Guyana", - "ht": "Haiti", - "hm": "Heard Island and Mcdonald Islands", - "va": "Holy See (Vatican City State)", - "hn": "Honduras", - "hk": "Hong Kong", - "hu": "Hungary", - "is": "Iceland", - "in": "India", - "id": "Indonesia", - "ir": "Iran, Islamic Republic of", - "iq": "Iraq", - "ie": "Ireland", - "im": "Isle of Man", - "il": "Israel", - "it": "Italy", - "je": "Jersey", - "jm": "Jamaica", - "jp": "Japan", - "jo": "Jordan", - "kz": "Kazakhstan", - "ke": "Kenya", - "ki": "Kiribati", - "kp": "Korea, Democratic People's Republic of", - "kr": "Korea, Republic of", - "kw": "Kuwait", - "kg": "Kyrgyzstan", - "la": "Lao People's Democratic Republic", - "lv": "Latvia", - "lb": "Lebanon", - "ls": "Lesotho", - "lr": "Liberia", - "ly": "Libyan Arab Jamahiriya", - "li": "Liechtenstein", - "lt": "Lithuania", - "lu": "Luxembourg", - "mo": "Macao", - "mk": "Macedonia, the Former Yugosalv Republic of", - "mg": "Madagascar", - "mw": "Malawi", - "my": "Malaysia", - "mv": "Maldives", - "ml": "Mali", - "mt": "Malta", - "mh": "Marshall Islands", - "mq": "Martinique", - "mr": "Mauritania", - "mu": "Mauritius", - "yt": "Mayotte", - "mx": "Mexico", - "fm": "Micronesia, Federated States of", - "md": "Moldova, Republic of", - "mc": "Monaco", - "mn": "Mongolia", - "me": "Montenegro", - "ms": "Montserrat", - "ma": "Morocco", - "mz": "Mozambique", - "mm": "Myanmar", - "na": "Namibia", - "nr": "Nauru", - "np": "Nepal", - "nl": "Netherlands", - "an": "Netherlands Antilles", - "nc": "New Caledonia", - "nz": "New Zealand", - "ni": "Nicaragua", - "ne": "Niger", - "ng": "Nigeria", - "nu": "Niue", - "nf": "Norfolk Island", - "mp": "Northern Mariana Islands", - "no": "Norway", - "om": "Oman", - "pk": "Pakistan", - "pw": "Palau", - "ps": "Palestinian Territory, Occupied", - "pa": "Panama", - "pg": "Papua New Guinea", - "py": "Paraguay", - "pe": "Peru", - "ph": "Philippines", - "pn": "Pitcairn", - "pl": "Poland", - "pt": "Portugal", - "pr": "Puerto Rico", - "qa": "Qatar", - "re": "Reunion", - "ro": "Romania", - "ru": "Russian Federation", - "rw": "Rwanda", - "sh": "Saint Helena", - "kn": "Saint Kitts and Nevis", - "lc": "Saint Lucia", - "pm": "Saint Pierre and Miquelon", - "vc": "Saint Vincent and the Grenadines", - "ws": "Samoa", - "sm": "San Marino", - "st": "Sao Tome and Principe", - "sa": "Saudi Arabia", - "sn": "Senegal", - "rs": "Serbia", - "sc": "Seychelles", - "sl": "Sierra Leone", - "sg": "Singapore", - "sk": "Slovakia", - "si": "Slovenia", - "sb": "Solomon Islands", - "so": "Somalia", - "za": "South Africa", - "gs": "South Georgia and the South Sandwich Islands", - "es": "Spain", - "lk": "Sri Lanka", - "sd": "Sudan", - "sr": "Suriname", - "sj": "Svalbard and Jan Mayen", - "sz": "Swaziland", - "se": "Sweden", - "ch": "Switzerland", - "sy": "Syrian Arab Republic", - "tw": "Taiwan, Province of China", - "tj": "Tajikistan", - "tz": "Tanzania, United Republic of", - "th": "Thailand", - "tl": "Timor-Leste", - "tg": "Togo", - "tk": "Tokelau", - "to": "Tonga", - "tt": "Trinidad and Tobago", - "tn": "Tunisia", - "tr": "Turkiye", - "tm": "Turkmenistan", - "tc": "Turks and Caicos Islands", - "tv": "Tuvalu", - "ug": "Uganda", - "ua": "Ukraine", - "ae": "United Arab Emirates", - "uk": "United Kingdom", - "gb": "United Kingdom", - "us": "United States", - "um": "United States Minor Outlying Islands", - "uy": "Uruguay", - "uz": "Uzbekistan", - "vu": "Vanuatu", - "ve": "Venezuela", - "vn": "Viet Nam", - "vg": "Virgin Islands, British", - "vi": "Virgin Islands, U.S.", - "wf": "Wallis and Futuna", - "eh": "Western Sahara", - "ye": "Yemen", - "zm": "Zambia", - "zw": "Zimbabwe", -} - - -LANGUAGE_CODES = { - "ar": "Arabic", - "bn": "Bengali", - "da": "Danish", - "de": "German", - "el": "Greek", - "en": "English", - "es": "Spanish", - "fi": "Finnish", - "fr": "French", - "hi": "Hindi", - "hu": "Hungarian", - "id": "Indonesian", - "it": "Italian", - "ja": "Japanese", - "ko": "Korean", - "nl": "Dutch", - "ms": "Malay", - "no": "Norwegian", - "pcm": "Nigerian Pidgin", - "pl": "Polish", - "pt": "Portuguese", - "pt-br": "Portuguese (Brazil)", - "pt-pt": "Portuguese (Portugal)", - "ru": "Russian", - "sv": "Swedish", - "tl": "Filipino", - "tr": "Turkish", - "uk": "Ukrainian", - "zh": "Chinese", - "zh-cn": "Chinese (Simplified)", - "zh-tw": "Chinese (Traditional)", -} diff --git a/toolkits/google_maps/arcade_google_maps/tools/__init__.py b/toolkits/google_maps/arcade_google_maps/tools/__init__.py deleted file mode 100644 index 0c6f6dd3..00000000 --- a/toolkits/google_maps/arcade_google_maps/tools/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from arcade_google_maps.tools.google_maps import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) - -__all__ = [ - "get_directions_between_addresses", - "get_directions_between_coordinates", -] diff --git a/toolkits/google_maps/arcade_google_maps/tools/google_maps.py b/toolkits/google_maps/arcade_google_maps/tools/google_maps.py deleted file mode 100644 index b4458f65..00000000 --- a/toolkits/google_maps/arcade_google_maps/tools/google_maps.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool - -from arcade_google_maps.constants import ( - DEFAULT_GOOGLE_MAPS_COUNTRY, - DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - DEFAULT_GOOGLE_MAPS_LANGUAGE, - DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -from arcade_google_maps.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode -from arcade_google_maps.utils import get_google_maps_directions - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_directions_between_addresses( - context: ToolContext, - origin_address: Annotated[ - str, "The origin address. Example: '123 Main St, New York, NY 10001'" - ], - destination_address: Annotated[ - str, "The destination address. Example: '456 Main St, New York, NY 10001'" - ], - language: Annotated[ - str, - "2-character language code to use in the Google Maps search. " - f"Defaults to '{DEFAULT_GOOGLE_MAPS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_MAPS_LANGUAGE, - country: Annotated[ - str | None, - "2-character country code to use in the Google Maps search. " - f"Defaults to '{DEFAULT_GOOGLE_MAPS_COUNTRY}'.", - ] = DEFAULT_GOOGLE_MAPS_COUNTRY, - distance_unit: Annotated[ - GoogleMapsDistanceUnit, - f"Distance unit to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT}'.", - ] = DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - travel_mode: Annotated[ - GoogleMapsTravelMode, - f"Travel mode to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_TRAVEL_MODE}'.", - ] = DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -> Annotated[dict, "The directions from Google Maps"]: - """Get directions from Google Maps.""" - return { - "directions": get_google_maps_directions( - context=context, - origin_address=origin_address, - destination_address=destination_address, - language=language, - country=country, - distance_unit=distance_unit, - travel_mode=travel_mode, - ), - } - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_directions_between_coordinates( - context: ToolContext, - origin_latitude: Annotated[str, "The origin latitude. E.g. '40.7128'"], - origin_longitude: Annotated[str, "The origin longitude. E.g. '-74.0060'"], - destination_latitude: Annotated[str, "The destination latitude. E.g. '40.7128'"], - destination_longitude: Annotated[str, "The destination longitude. E.g. '-74.0060'"], - language: Annotated[ - str, - "2-letter language code to use in the Google Maps search. " - f"Defaults to '{DEFAULT_GOOGLE_MAPS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_MAPS_LANGUAGE, - country: Annotated[ - str | None, - f"2-letter country code to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_COUNTRY}'.", - ] = DEFAULT_GOOGLE_MAPS_COUNTRY, - distance_unit: Annotated[ - GoogleMapsDistanceUnit, - f"Distance unit to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT}'.", - ] = DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - travel_mode: Annotated[ - GoogleMapsTravelMode, - f"Travel mode to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_TRAVEL_MODE}'.", - ] = DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -> Annotated[dict, "The directions from Google Maps"]: - """Get directions from Google Maps.""" - return { - "directions": get_google_maps_directions( - context=context, - origin_latitude=origin_latitude, - origin_longitude=origin_longitude, - destination_latitude=destination_latitude, - destination_longitude=destination_longitude, - language=language, - country=country, - distance_unit=distance_unit, - travel_mode=travel_mode, - ), - } diff --git a/toolkits/google_maps/arcade_google_maps/utils.py b/toolkits/google_maps/arcade_google_maps/utils.py deleted file mode 100644 index a27b781e..00000000 --- a/toolkits/google_maps/arcade_google_maps/utils.py +++ /dev/null @@ -1,175 +0,0 @@ -import contextlib -import re -from datetime import datetime -from typing import Any, cast -from zoneinfo import ZoneInfo - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - -from arcade_google_maps.constants import ( - DEFAULT_GOOGLE_MAPS_COUNTRY, - DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - DEFAULT_GOOGLE_MAPS_LANGUAGE, - DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -from arcade_google_maps.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode -from arcade_google_maps.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_google_maps.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -def get_google_maps_directions( - context: ToolContext, - origin_address: str | None = None, - destination_address: str | None = None, - origin_latitude: str | None = None, - origin_longitude: str | None = None, - destination_latitude: str | None = None, - destination_longitude: str | None = None, - language: str | None = DEFAULT_GOOGLE_MAPS_LANGUAGE, - country: str | None = DEFAULT_GOOGLE_MAPS_COUNTRY, - distance_unit: GoogleMapsDistanceUnit = DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - travel_mode: GoogleMapsTravelMode = DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -> list[dict[str, Any]]: - """Get directions from Google Maps. - - Provide either all(origin_address, destination_address) or - all(origin_latitude, origin_longitude, destination_latitude, destination_longitude). - - Args: - context: Tool context containing required Serp API Key secret. - origin_address: Origin address. - destination_address: Destination address. - origin_latitude: Origin latitude. - origin_longitude: Origin longitude. - destination_latitude: Destination latitude. - destination_longitude: Destination longitude. - language: Language to use in the Google Maps search. Defaults to 'en' (English). - country: 2-letter country code to use in the Google Maps search. Defaults to None - (no country is specified). - distance_unit: Distance unit to use in the Google Maps search. Defaults to 'km' - (kilometers). - travel_mode: Travel mode to use in the Google Maps search. Defaults to 'best' - (best mode). - - Returns: - The directions from Google Maps. - """ - if isinstance(language, str): - language = language.lower() - - if language not in LANGUAGE_CODES: - raise LanguageNotFoundError(language) - - params = prepare_params( - engine="google_maps_directions", - hl=language, - distance_unit=distance_unit.to_api_value(), - travel_mode=travel_mode.to_api_value(), - ) - - if any([ - origin_latitude, - origin_longitude, - destination_latitude, - destination_longitude, - ]) and any([origin_address, destination_address]): - raise ValueError("Either coordinates or addresses must be provided, not both") - - elif all([origin_latitude, origin_longitude, destination_latitude, destination_longitude]): - params["start_coords"] = f"{origin_latitude},{origin_longitude}" - params["end_coords"] = f"{destination_latitude},{destination_longitude}" - - elif all([origin_address, destination_address]): - params["start_addr"] = str(origin_address) - params["end_addr"] = str(destination_address) - - else: - raise ValueError("Either coordinates or addresses must be provided") - - if country: - country = country.lower() - if country not in COUNTRY_CODES: - raise CountryNotFoundError(country) - params["gl"] = country - - results = call_serpapi(context, params) - - directions = cast(list[dict[str, Any]], results.get("directions", [])) - - for direction in directions: - clean_google_maps_direction(direction) - - if "arrive_around" in direction: - direction["arrive_around"] = enrich_google_maps_arrive_around( - direction["arrive_around"] - ) - - return directions - - -def clean_google_maps_direction(direction: dict[str, Any]) -> None: - for trip in direction.get("trips", []): - with contextlib.suppress(KeyError): - del trip["start_stop"]["data_id"] - del trip["end_stop"]["data_id"] - - for detail in trip.get("details", []): - with contextlib.suppress(KeyError): - del detail["geo_photo"] - del detail["gps_coordinates"] - - for stop in trip.get("stops", []): - with contextlib.suppress(KeyError): - del stop["data_id"] - - -def enrich_google_maps_arrive_around(timestamp: int | None) -> dict[str, Any]: - if not timestamp: - return {} - - dt = datetime.fromtimestamp(timestamp, tz=ZoneInfo("UTC")).isoformat() - return {"datetime": dt, "timestamp": timestamp} diff --git a/toolkits/google_maps/evals/eval_google_maps_directions.py b/toolkits/google_maps/evals/eval_google_maps_directions.py deleted file mode 100644 index ee3f8cb8..00000000 --- a/toolkits/google_maps/evals/eval_google_maps_directions.py +++ /dev/null @@ -1,226 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_maps -from arcade_google_maps.constants import ( - DEFAULT_GOOGLE_MAPS_COUNTRY, - DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - DEFAULT_GOOGLE_MAPS_LANGUAGE, - DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -from arcade_google_maps.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode -from arcade_google_maps.tools.google_maps import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the Google Search tool -catalog.add_module(arcade_google_maps) - - -@tool_eval() -def google_maps_directions_by_addresses_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Maps Directions tools.""" - suite = EvalSuite( - name="Google Maps Directions Tool Evaluation", - system_message="You are an AI assistant that can get directions from Google Maps using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get directions between two addresses", - user_message="Get directions from Google Maps between the following addresses: 1600 Amphitheatre Parkway, Mountain View, CA 94043 and 1 Infinite Loop, Cupertino, CA 95014.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_addresses, - args={ - "origin_address": "1600 Amphitheatre Parkway, Mountain View, CA 94043", - "destination_address": "1 Infinite Loop, Cupertino, CA 95014", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_address", weight=0.3), - SimilarityCritic(critic_field="destination_address", weight=0.3), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two addresses with custom distance unit and travel mode", - user_message="Get walking directions from Google Maps between the following addresses in miles: 1600 Amphitheatre Parkway, Mountain View, CA 94043 and 1 Infinite Loop, Cupertino, CA 95014.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_addresses, - args={ - "origin_address": "1600 Amphitheatre Parkway, Mountain View, CA 94043", - "destination_address": "1 Infinite Loop, Cupertino, CA 95014", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": GoogleMapsDistanceUnit.MILES.value, - "travel_mode": GoogleMapsTravelMode.WALKING.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_address", weight=0.3), - SimilarityCritic(critic_field="destination_address", weight=0.3), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two addresses in a given country and language", - user_message="Get directions from Google Maps in Portuguese between the following addresses in Brazil: Rua do Amendoim, 1, Belo Horizonte, MG and Av. do Descobrimento, 515, Porto Seguro, BA.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_addresses, - args={ - "origin_address": "Rua do Amendoim, 1, Belo Horizonte, MG", - "destination_address": "Av. do Descobrimento, 515, Porto Seguro, BA", - "language": "pt", - "country": "br", - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_address", weight=0.3), - SimilarityCritic(critic_field="destination_address", weight=0.3), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - return suite - - -@tool_eval() -def google_maps_directions_by_coordinates_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Maps Directions tools.""" - suite = EvalSuite( - name="Google Maps Directions Tool Evaluation", - system_message="You are an AI assistant that can get directions from Google Maps using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get directions between two coordinates", - user_message="Get directions from Google Maps between the following coordinates: 37.422740,-122.084961 and 37.331820,-122.031180.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_coordinates, - args={ - "origin_latitude": "37.422740", - "origin_longitude": "-122.084961", - "destination_latitude": "37.331820", - "destination_longitude": "-122.031180", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_latitude", weight=0.15), - SimilarityCritic(critic_field="origin_longitude", weight=0.15), - SimilarityCritic(critic_field="destination_latitude", weight=0.15), - SimilarityCritic(critic_field="destination_longitude", weight=0.15), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two coordinates with custom distance unit and travel mode", - user_message="Get walking directions from Google Maps between the following coordinates in miles: 37.422740,-122.084961 and 37.331820,-122.031180.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_coordinates, - args={ - "origin_latitude": "37.422740", - "origin_longitude": "-122.084961", - "destination_latitude": "37.331820", - "destination_longitude": "-122.031180", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": GoogleMapsDistanceUnit.MILES.value, - "travel_mode": GoogleMapsTravelMode.WALKING.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_latitude", weight=0.15), - SimilarityCritic(critic_field="origin_longitude", weight=0.15), - SimilarityCritic(critic_field="destination_latitude", weight=0.15), - SimilarityCritic(critic_field="destination_longitude", weight=0.15), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two coordinates in a given country and language", - user_message="Get directions from Google Maps in Portuguese between the following coordinates in Brazil: 37.422740,-122.084961 and 37.331820,-122.031180.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_coordinates, - args={ - "origin_latitude": "37.422740", - "origin_longitude": "-122.084961", - "destination_latitude": "37.331820", - "destination_longitude": "-122.031180", - "language": "pt", - "country": "br", - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_latitude", weight=0.15), - SimilarityCritic(critic_field="origin_longitude", weight=0.15), - SimilarityCritic(critic_field="destination_latitude", weight=0.15), - SimilarityCritic(critic_field="destination_longitude", weight=0.15), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/google_maps/pyproject.toml b/toolkits/google_maps/pyproject.toml deleted file mode 100644 index 7adb3620..00000000 --- a/toolkits/google_maps/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_maps" -version = "2.0.0" -description = "Arcade.dev LLM tools for getting directions via Google Maps" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_maps/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_maps",] diff --git a/toolkits/google_maps/tests/__init__.py b/toolkits/google_maps/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_maps/tests/test_google_maps_directions.py b/toolkits/google_maps/tests/test_google_maps_directions.py deleted file mode 100644 index 46624f69..00000000 --- a/toolkits/google_maps/tests/test_google_maps_directions.py +++ /dev/null @@ -1,131 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem - -from arcade_google_maps.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_google_maps.tools.google_maps import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="serp_api_key", value="fake_api_key")]) - - -@pytest.mark.asyncio -@patch("arcade_google_maps.utils.SerpClient") -async def test_get_directions_between_coordinates_success(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - result = await get_directions_between_coordinates( - context=mock_context, - origin_latitude="1", - origin_longitude="2", - destination_latitude="3", - destination_longitude="4", - ) - - assert result == { - "directions": [ - { - "arrive_around": { - "datetime": "2025-03-12T14:30:39+00:00", - "timestamp": 1741789839, - }, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - -@pytest.mark.asyncio -@patch("arcade_google_maps.utils.SerpClient") -async def test_get_directions_between_addresses_success(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - result = await get_directions_between_addresses( - context=mock_context, - origin_address="1", - destination_address="2", - ) - - assert result == { - "directions": [ - { - "arrive_around": { - "datetime": "2025-03-12T14:30:39+00:00", - "timestamp": 1741789839, - }, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - -@pytest.mark.asyncio -@patch("arcade_google_maps.utils.SerpClient") -async def test_get_directions_between_addresses_country_not_found(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - with pytest.raises(CountryNotFoundError): - await get_directions_between_addresses( - context=mock_context, - origin_address="1", - destination_address="2", - country="invalid", - ) - - -@pytest.mark.asyncio -@patch("arcade_google_maps.utils.SerpClient") -async def test_get_directions_between_addresses_language_not_found(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - with pytest.raises(LanguageNotFoundError): - await get_directions_between_addresses( - context=mock_context, - origin_address="1", - destination_address="2", - language="invalid", - ) diff --git a/toolkits/google_news/.pre-commit-config.yaml b/toolkits/google_news/.pre-commit-config.yaml deleted file mode 100644 index 4b9f271a..00000000 --- a/toolkits/google_news/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_news/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_news/.ruff.toml b/toolkits/google_news/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/google_news/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_news/LICENSE b/toolkits/google_news/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/google_news/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_news/Makefile b/toolkits/google_news/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_news/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_news/arcade_google_news/__init__.py b/toolkits/google_news/arcade_google_news/__init__.py deleted file mode 100644 index 42ae5b0c..00000000 --- a/toolkits/google_news/arcade_google_news/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from arcade_google_news.tools import search_news_stories -from arcade_google_news.types import ( - CountryCode, - GoogleNewsResponse, - LanguageCode, - SearchNewsOutput, - SearchNewsParams, - SimplifiedNewsResult, -) - -__all__ = [ - "search_news_stories", - "CountryCode", - "GoogleNewsResponse", - "LanguageCode", - "SearchNewsOutput", - "SearchNewsParams", - "SimplifiedNewsResult", -] diff --git a/toolkits/google_news/arcade_google_news/constants.py b/toolkits/google_news/arcade_google_news/constants.py deleted file mode 100644 index cb5183ae..00000000 --- a/toolkits/google_news/arcade_google_news/constants.py +++ /dev/null @@ -1,6 +0,0 @@ -import os - -DEFAULT_GOOGLE_LANGUAGE = os.getenv("ARCADE_GOOGLE_LANGUAGE", "en") - -DEFAULT_GOOGLE_NEWS_LANGUAGE = os.getenv("ARCADE_GOOGLE_NEWS_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE) -DEFAULT_GOOGLE_NEWS_COUNTRY = os.getenv("ARCADE_GOOGLE_NEWS_COUNTRY") diff --git a/toolkits/google_news/arcade_google_news/exceptions.py b/toolkits/google_news/arcade_google_news/exceptions.py deleted file mode 100644 index 480065af..00000000 --- a/toolkits/google_news/arcade_google_news/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -from arcade_tdk.errors import RetryableToolError - -from arcade_google_news.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -class GoogleRetryableError(RetryableToolError): - pass - - -class CountryNotFoundError(GoogleRetryableError): - def __init__(self, country: str | None) -> None: - valid_countries = json.dumps(COUNTRY_CODES, default=str) - message = f"Country not found: '{country}'." - additional_message = f"Valid countries are: {valid_countries}" - super().__init__(message, additional_prompt_content=additional_message) - - -class LanguageNotFoundError(GoogleRetryableError): - def __init__(self, language: str | None) -> None: - valid_languages = json.dumps(LANGUAGE_CODES, default=str) - message = f"Language not found: '{language}'." - additional_message = f"Valid languages are: {valid_languages}" - super().__init__(message, additional_prompt_content=additional_message) diff --git a/toolkits/google_news/arcade_google_news/google_data.py b/toolkits/google_news/arcade_google_news/google_data.py deleted file mode 100644 index 22cdec15..00000000 --- a/toolkits/google_news/arcade_google_news/google_data.py +++ /dev/null @@ -1,281 +0,0 @@ -COUNTRY_CODES: dict[str, str] = { - "af": "Afghanistan", - "al": "Albania", - "dz": "Algeria", - "as": "American Samoa", - "ad": "Andorra", - "ao": "Angola", - "ai": "Anguilla", - "aq": "Antarctica", - "ag": "Antigua and Barbuda", - "ar": "Argentina", - "am": "Armenia", - "aw": "Aruba", - "au": "Australia", - "at": "Austria", - "az": "Azerbaijan", - "bs": "Bahamas", - "bh": "Bahrain", - "bd": "Bangladesh", - "bb": "Barbados", - "by": "Belarus", - "be": "Belgium", - "bz": "Belize", - "bj": "Benin", - "bm": "Bermuda", - "bt": "Bhutan", - "bo": "Bolivia", - "ba": "Bosnia and Herzegovina", - "bw": "Botswana", - "bv": "Bouvet Island", - "br": "Brazil", - "io": "British Indian Ocean Territory", - "bn": "Brunei Darussalam", - "bg": "Bulgaria", - "bf": "Burkina Faso", - "bi": "Burundi", - "kh": "Cambodia", - "cm": "Cameroon", - "ca": "Canada", - "cv": "Cape Verde", - "ky": "Cayman Islands", - "cf": "Central African Republic", - "td": "Chad", - "cl": "Chile", - "cn": "China", - "cx": "Christmas Island", - "cc": "Cocos (Keeling) Islands", - "co": "Colombia", - "km": "Comoros", - "cg": "Congo", - "cd": "Congo, the Democratic Republic of the", - "ck": "Cook Islands", - "cr": "Costa Rica", - "ci": "Cote D'ivoire", - "hr": "Croatia", - "cu": "Cuba", - "cy": "Cyprus", - "cz": "Czech Republic", - "dk": "Denmark", - "dj": "Djibouti", - "dm": "Dominica", - "do": "Dominican Republic", - "ec": "Ecuador", - "eg": "Egypt", - "sv": "El Salvador", - "gq": "Equatorial Guinea", - "er": "Eritrea", - "ee": "Estonia", - "et": "Ethiopia", - "fk": "Falkland Islands (Malvinas)", - "fo": "Faroe Islands", - "fj": "Fiji", - "fi": "Finland", - "fr": "France", - "gf": "French Guiana", - "pf": "French Polynesia", - "tf": "French Southern Territories", - "ga": "Gabon", - "gm": "Gambia", - "ge": "Georgia", - "de": "Germany", - "gh": "Ghana", - "gi": "Gibraltar", - "gr": "Greece", - "gl": "Greenland", - "gd": "Grenada", - "gp": "Guadeloupe", - "gu": "Guam", - "gt": "Guatemala", - "gg": "Guernsey", - "gn": "Guinea", - "gw": "Guinea-Bissau", - "gy": "Guyana", - "ht": "Haiti", - "hm": "Heard Island and Mcdonald Islands", - "va": "Holy See (Vatican City State)", - "hn": "Honduras", - "hk": "Hong Kong", - "hu": "Hungary", - "is": "Iceland", - "in": "India", - "id": "Indonesia", - "ir": "Iran, Islamic Republic of", - "iq": "Iraq", - "ie": "Ireland", - "im": "Isle of Man", - "il": "Israel", - "it": "Italy", - "je": "Jersey", - "jm": "Jamaica", - "jp": "Japan", - "jo": "Jordan", - "kz": "Kazakhstan", - "ke": "Kenya", - "ki": "Kiribati", - "kp": "Korea, Democratic People's Republic of", - "kr": "Korea, Republic of", - "kw": "Kuwait", - "kg": "Kyrgyzstan", - "la": "Lao People's Democratic Republic", - "lv": "Latvia", - "lb": "Lebanon", - "ls": "Lesotho", - "lr": "Liberia", - "ly": "Libyan Arab Jamahiriya", - "li": "Liechtenstein", - "lt": "Lithuania", - "lu": "Luxembourg", - "mo": "Macao", - "mk": "Macedonia, the Former Yugosalv Republic of", - "mg": "Madagascar", - "mw": "Malawi", - "my": "Malaysia", - "mv": "Maldives", - "ml": "Mali", - "mt": "Malta", - "mh": "Marshall Islands", - "mq": "Martinique", - "mr": "Mauritania", - "mu": "Mauritius", - "yt": "Mayotte", - "mx": "Mexico", - "fm": "Micronesia, Federated States of", - "md": "Moldova, Republic of", - "mc": "Monaco", - "mn": "Mongolia", - "me": "Montenegro", - "ms": "Montserrat", - "ma": "Morocco", - "mz": "Mozambique", - "mm": "Myanmar", - "na": "Namibia", - "nr": "Nauru", - "np": "Nepal", - "nl": "Netherlands", - "an": "Netherlands Antilles", - "nc": "New Caledonia", - "nz": "New Zealand", - "ni": "Nicaragua", - "ne": "Niger", - "ng": "Nigeria", - "nu": "Niue", - "nf": "Norfolk Island", - "mp": "Northern Mariana Islands", - "no": "Norway", - "om": "Oman", - "pk": "Pakistan", - "pw": "Palau", - "ps": "Palestinian Territory, Occupied", - "pa": "Panama", - "pg": "Papua New Guinea", - "py": "Paraguay", - "pe": "Peru", - "ph": "Philippines", - "pn": "Pitcairn", - "pl": "Poland", - "pt": "Portugal", - "pr": "Puerto Rico", - "qa": "Qatar", - "re": "Reunion", - "ro": "Romania", - "ru": "Russian Federation", - "rw": "Rwanda", - "sh": "Saint Helena", - "kn": "Saint Kitts and Nevis", - "lc": "Saint Lucia", - "pm": "Saint Pierre and Miquelon", - "vc": "Saint Vincent and the Grenadines", - "ws": "Samoa", - "sm": "San Marino", - "st": "Sao Tome and Principe", - "sa": "Saudi Arabia", - "sn": "Senegal", - "rs": "Serbia", - "sc": "Seychelles", - "sl": "Sierra Leone", - "sg": "Singapore", - "sk": "Slovakia", - "si": "Slovenia", - "sb": "Solomon Islands", - "so": "Somalia", - "za": "South Africa", - "gs": "South Georgia and the South Sandwich Islands", - "es": "Spain", - "lk": "Sri Lanka", - "sd": "Sudan", - "sr": "Suriname", - "sj": "Svalbard and Jan Mayen", - "sz": "Swaziland", - "se": "Sweden", - "ch": "Switzerland", - "sy": "Syrian Arab Republic", - "tw": "Taiwan, Province of China", - "tj": "Tajikistan", - "tz": "Tanzania, United Republic of", - "th": "Thailand", - "tl": "Timor-Leste", - "tg": "Togo", - "tk": "Tokelau", - "to": "Tonga", - "tt": "Trinidad and Tobago", - "tn": "Tunisia", - "tr": "Turkiye", - "tm": "Turkmenistan", - "tc": "Turks and Caicos Islands", - "tv": "Tuvalu", - "ug": "Uganda", - "ua": "Ukraine", - "ae": "United Arab Emirates", - "uk": "United Kingdom", - "gb": "United Kingdom", - "us": "United States", - "um": "United States Minor Outlying Islands", - "uy": "Uruguay", - "uz": "Uzbekistan", - "vu": "Vanuatu", - "ve": "Venezuela", - "vn": "Viet Nam", - "vg": "Virgin Islands, British", - "vi": "Virgin Islands, U.S.", - "wf": "Wallis and Futuna", - "eh": "Western Sahara", - "ye": "Yemen", - "zm": "Zambia", - "zw": "Zimbabwe", -} - - -LANGUAGE_CODES: dict[str, str] = { - "ar": "Arabic", - "bn": "Bengali", - "da": "Danish", - "de": "German", - "el": "Greek", - "en": "English", - "es": "Spanish", - "fi": "Finnish", - "fr": "French", - "hi": "Hindi", - "hu": "Hungarian", - "id": "Indonesian", - "it": "Italian", - "ja": "Japanese", - "ko": "Korean", - "nl": "Dutch", - "ms": "Malay", - "no": "Norwegian", - "pcm": "Nigerian Pidgin", - "pl": "Polish", - "pt": "Portuguese", - "pt-br": "Portuguese (Brazil)", - "pt-pt": "Portuguese (Portugal)", - "ru": "Russian", - "sv": "Swedish", - "tl": "Filipino", - "tr": "Turkish", - "uk": "Ukrainian", - "zh": "Chinese", - "zh-cn": "Chinese (Simplified)", - "zh-tw": "Chinese (Traditional)", -} diff --git a/toolkits/google_news/arcade_google_news/tools/__init__.py b/toolkits/google_news/arcade_google_news/tools/__init__.py deleted file mode 100644 index d5d1050d..00000000 --- a/toolkits/google_news/arcade_google_news/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_news.tools.google_news import search_news_stories - -__all__ = ["search_news_stories"] diff --git a/toolkits/google_news/arcade_google_news/tools/google_news.py b/toolkits/google_news/arcade_google_news/tools/google_news.py deleted file mode 100644 index 71f76a70..00000000 --- a/toolkits/google_news/arcade_google_news/tools/google_news.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import ToolExecutionError - -from arcade_google_news.constants import ( - DEFAULT_GOOGLE_NEWS_COUNTRY, - DEFAULT_GOOGLE_NEWS_LANGUAGE, -) -from arcade_google_news.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_google_news.google_data import COUNTRY_CODES, LANGUAGE_CODES -from arcade_google_news.types import CountryCode, LanguageCode, SearchNewsOutput -from arcade_google_news.utils import ( - call_serpapi, - extract_news_results, - prepare_params, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_news_stories( - context: ToolContext, - keywords: Annotated[ - str, - "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.", - ], - country_code: Annotated[ - CountryCode | None, - "2-character country code to search for news articles. " - "E.g. 'us' (United States). " - f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.", - ] = None, - language_code: Annotated[ - LanguageCode, - "2-character language code to search for news articles. E.g. 'en' (English). " - f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_NEWS_LANGUAGE, - limit: Annotated[ - int | None, - "Maximum number of news articles to return. Defaults to None " - "(returns all results found by the API).", - ] = None, -) -> Annotated[SearchNewsOutput, "News search results with article details."]: - """Search for news articles related to a given query.""" - if not keywords: - raise ToolExecutionError("Keywords are required to search for news articles.") - - if country_code and country_code not in COUNTRY_CODES: - raise CountryNotFoundError(country_code) - - if language_code not in LANGUAGE_CODES: - raise LanguageNotFoundError(language_code) - - params = prepare_params("google_news", q=keywords, gl=country_code, hl=language_code) - results = call_serpapi(context, params) - return SearchNewsOutput(news_results=extract_news_results(results, limit=limit)) diff --git a/toolkits/google_news/arcade_google_news/types.py b/toolkits/google_news/arcade_google_news/types.py deleted file mode 100644 index 5a6c4c22..00000000 --- a/toolkits/google_news/arcade_google_news/types.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Type definitions for Google News API responses and parameters.""" - -from typing_extensions import TypedDict - -# For now, we'll use str type alias to maintain compatibility -# In the future, these could be converted to proper Literal types -CountryCode = str -LanguageCode = str - - -class SearchNewsParams(TypedDict): - """Input parameters for searching news articles.""" - - keywords: str - """Search query terms to find relevant news articles \ - (e.g., 'Apple launches new iPhone').""" - - country_code: CountryCode | None - """Optional 2-letter country code to filter news by region \ - (e.g., 'us' for United States, 'uk' for United Kingdom).""" - - language_code: LanguageCode | None - """Optional 2-letter language code to filter news by language \ - (e.g., 'en' for English, 'es' for Spanish).""" - - limit: int | None - """Optional maximum number of news articles to return. \ - If not specified, returns all results from the API.""" - - -class SourceInfo(TypedDict, total=False): - """Information about the news source/publication.""" - - name: str - """Name of the publication (e.g., 'CNN', 'BBC News', 'The New York Times').""" - - icon: str - """URL to the source's favicon or logo image.""" - - authors: list[str] - """List of author names for the article, if available.""" - - -class NewsResult(TypedDict, total=False): - """Individual news article from the Google News API response.""" - - position: int - """Ranking position of this result in the search results.""" - - title: str - """Headline or title of the news article.""" - - link: str - """Full URL to the original news article.""" - - source: SourceInfo - """Information about the publication source.""" - - date: str - """Publication date and time (e.g., '2 hours ago', 'Dec 15, 2023').""" - - snippet: str - """Brief excerpt or summary from the article content.""" - - thumbnail: str - """URL to a high-resolution thumbnail image for the article.""" - - thumbnail_small: str - """URL to a low-resolution thumbnail image for the article.""" - - story_token: str - """Token for accessing full coverage of this news story across multiple sources.""" - - stories: list["NewsResult"] - """Related news stories from other sources covering the same topic.""" - - highlight: dict - """Additional highlighted information about the story.""" - - -class SearchMetadata(TypedDict, total=False): - """Metadata about the search request and processing.""" - - id: str - """Unique identifier for this search request within SerpApi.""" - - status: str - """Current processing status ('Processing', 'Success', or 'Error').""" - - json_endpoint: str - """URL to retrieve the JSON results for this search.""" - - created_at: str - """Timestamp when the search request was created.""" - - processed_at: str - """Timestamp when the search request was processed.""" - - google_news_url: str - """Original Google News URL that would return these results.""" - - total_time_taken: float - """Total time in seconds taken to process this search.""" - - -class SearchParameters(TypedDict, total=False): - """Parameters used for the search request.""" - - engine: str - """Search engine used (always 'google_news' for this API).""" - - q: str - """Search query string.""" - - gl: str - """Country code used for geographic filtering.""" - - hl: str - """Language code used for language filtering.""" - - topic_token: str - """Token for accessing specific news topics (e.g., 'World', 'Business', 'Technology').""" - - publication_token: str - """Token for accessing news from specific publishers.""" - - -class MenuLink(TypedDict): - """Navigation link for news categories or topics.""" - - title: str - """Display text for the menu item (e.g., 'Technology', 'Sports', 'Business').""" - - topic_token: str - """Token to access this specific topic or category.""" - - serpapi_link: str - """SerpApi URL to search within this topic.""" - - -class TopStoriesLink(TypedDict): - """Link to top stories section.""" - - topic_token: str - """Token to access top stories.""" - - serpapi_link: str - """SerpApi URL to retrieve top stories.""" - - -class GoogleNewsResponse(TypedDict, total=False): - """Complete response from the Google News API.""" - - search_metadata: SearchMetadata - """Metadata about the search request and processing.""" - - search_parameters: SearchParameters - """Parameters that were used for this search.""" - - news_results: list[NewsResult] - """List of news articles matching the search criteria.""" - - menu_links: list[MenuLink] - """Navigation links to different news categories and topics.""" - - top_stories_link: TopStoriesLink - """Link to access top stories.""" - - title: str - """Title of the page or topic being displayed.""" - - -class SimplifiedNewsResult(TypedDict): - """Simplified news article format for tool output.""" - - title: str - """Headline of the news article.""" - - link: str - """URL to the full article.""" - - source: str | None - """Name of the publication source.""" - - date: str | None - """When the article was published.""" - - snippet: str | None - """Brief excerpt from the article.""" - - -class SearchNewsOutput(TypedDict): - """Output format for the search_news_stories tool.""" - - news_results: list[SimplifiedNewsResult] - """List of news articles in simplified format.""" diff --git a/toolkits/google_news/arcade_google_news/utils.py b/toolkits/google_news/arcade_google_news/utils.py deleted file mode 100644 index 458faae4..00000000 --- a/toolkits/google_news/arcade_google_news/utils.py +++ /dev/null @@ -1,70 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - -from arcade_google_news.types import GoogleNewsResponse, SimplifiedNewsResult - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict[str, Any]) -> GoogleNewsResponse: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(GoogleNewsResponse, search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -def extract_news_results( - results: GoogleNewsResponse, limit: int | None = None -) -> list[SimplifiedNewsResult]: - news_results: list[SimplifiedNewsResult] = [] - for result in results.get("news_results", []): - news_results.append( - SimplifiedNewsResult( - title=result.get("title", ""), - link=result.get("link", ""), - source=result.get("source", {}).get("name"), - date=result.get("date"), - snippet=result.get("snippet"), - ) - ) - - if limit: - return news_results[:limit] - return news_results diff --git a/toolkits/google_news/pyproject.toml b/toolkits/google_news/pyproject.toml deleted file mode 100644 index 36513703..00000000 --- a/toolkits/google_news/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_news" -version = "2.0.0" -description = "Arcade.dev LLM tools for getting new via Google News" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_news/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_news",] diff --git a/toolkits/google_search/.pre-commit-config.yaml b/toolkits/google_search/.pre-commit-config.yaml deleted file mode 100644 index d4c8cef7..00000000 --- a/toolkits/google_search/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_search/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_search/.ruff.toml b/toolkits/google_search/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_search/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_search/LICENSE b/toolkits/google_search/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/google_search/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_search/Makefile b/toolkits/google_search/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_search/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_search/arcade_google_search/__init__.py b/toolkits/google_search/arcade_google_search/__init__.py deleted file mode 100644 index 42837602..00000000 --- a/toolkits/google_search/arcade_google_search/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_search.tools import search - -__all__ = ["search"] diff --git a/toolkits/google_search/arcade_google_search/tools/__init__.py b/toolkits/google_search/arcade_google_search/tools/__init__.py deleted file mode 100644 index b8618dc0..00000000 --- a/toolkits/google_search/arcade_google_search/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_search.tools.google_search import search - -__all__ = ["search"] diff --git a/toolkits/google_search/arcade_google_search/tools/google_search.py b/toolkits/google_search/arcade_google_search/tools/google_search.py deleted file mode 100644 index 288e97cc..00000000 --- a/toolkits/google_search/arcade_google_search/tools/google_search.py +++ /dev/null @@ -1,21 +0,0 @@ -import json -from typing import Annotated - -from arcade_tdk import ToolContext, tool - -from arcade_google_search.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search( - context: ToolContext, - query: Annotated[str, "Search query"], - n_results: Annotated[int, "Number of results to retrieve"] = 5, -) -> str: - """Search Google using SerpAPI and return organic search results.""" - - params = prepare_params("google", q=query) - results = call_serpapi(context, params) - organic_results = results.get("organic_results", []) - - return json.dumps(organic_results[:n_results]) diff --git a/toolkits/google_search/arcade_google_search/utils.py b/toolkits/google_search/arcade_google_search/utils.py deleted file mode 100644 index 00c0dcba..00000000 --- a/toolkits/google_search/arcade_google_search/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) diff --git a/toolkits/google_search/evals/eval_google_search.py b/toolkits/google_search/evals/eval_google_search.py deleted file mode 100644 index 662db4fb..00000000 --- a/toolkits/google_search/evals/eval_google_search.py +++ /dev/null @@ -1,240 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - NumericCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_search -from arcade_google_search.tools import search - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the Google Search tool -catalog.add_module(arcade_google_search) - - -@tool_eval() -def google_search_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Search tool.""" - suite = EvalSuite( - name="Google Search Tool Evaluation", - system_message="You are an AI assistant that can perform web searches using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Simple search query with default results - suite.add_case( - name="Simple search query with default results", - user_message="Search for 'Climate change effects on polar bears' on Google.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "Climate change effects on polar bears", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with specific number of results - suite.add_case( - name="Search query with specific number of results", - user_message="Find the top 3 articles about quantum computing.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "articles about quantum computing", - "n_results": 3, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - NumericCritic( - critic_field="n_results", - weight=0.3, - value_range=(1, 100), - ), - ], - ) - - # Search query with 'n' results specified in words - suite.add_case( - name="Search query with 'n' results specified in words", - user_message="Give me five recipes for vegan lasagna.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "recipes for vegan lasagna", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - NumericCritic( - critic_field="n_results", - weight=0.3, - value_range=(1, 100), - ), - ], - ) - - # Ambiguous number of results - suite.add_case( - name="Ambiguous number of results", - user_message="Find articles about climate change impacts 10.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "articles about climate change impacts 10", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with multiple instructions - suite.add_case( - name="Search query with multiple instructions", - user_message="Search for the latest news on electric cars, and tell me about Tesla's new model.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "latest news on electric cars", - "n_results": 5, - }, - ), - ExpectedToolCall( - func=search, - args={ - "query": "Tesla's new model", - "n_results": 5, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search with stop words and filler words - suite.add_case( - name="Search with stop words and filler words", - user_message="Could you please search for the best ways to learn French?", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "best ways to learn French", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # No clear query given - suite.add_case( - name="No clear query given", - user_message="Find it for me.", - expected_tool_calls=[], - critics=[], - ) - - # Search query with special characters - suite.add_case( - name="Search query with special characters", - user_message="Find me '@OpenAI's latest research papers'", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "@OpenAI's latest research papers", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with complex instructions - suite.add_case( - name="Search query with complex instructions", - user_message="I need information about the impact of deforestation in the Amazon over the past decade.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "impact of deforestation in the Amazon over the past decade", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query in a different language - suite.add_case( - name="Search query in a different language", - user_message="Busca información sobre la economía de España.", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "economía de España", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with numeric data - suite.add_case( - name="Search query with numeric data", - user_message="What was the population of Japan in 2020?", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "query": "population of Japan in 2020", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/google_search/pyproject.toml b/toolkits/google_search/pyproject.toml deleted file mode 100644 index 412b3915..00000000 --- a/toolkits/google_search/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_search" -version = "2.0.0" -description = "Arcade.dev LLM tools for searching via Google" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "serpapi>=0.1.5,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_search/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_search",] diff --git a/toolkits/google_search/tests/__init__.py b/toolkits/google_search/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_search/tests/test_google_search.py b/toolkits/google_search/tests/test_google_search.py deleted file mode 100644 index 69a90c96..00000000 --- a/toolkits/google_search/tests/test_google_search.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem - -from arcade_google_search.tools import search - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="serp_api_key", value="fake_api_key")]) - - -@pytest.mark.asyncio -async def test_search_google_success(mock_context): - with ( - patch("arcade_google_search.utils.SerpClient") as MockClient, - ): - mock_client_instance = MockClient.return_value - mock_client_instance.search.return_value.as_dict.return_value = { - "organic_results": [ - {"title": "Result 1", "link": "http://example.com/1"}, - {"title": "Result 2", "link": "http://example.com/2"}, - {"title": "Result 3", "link": "http://example.com/3"}, - ] - } - - result = await search(mock_context, "test query", 2) - - expected_result = json.dumps([ - {"title": "Result 1", "link": "http://example.com/1"}, - {"title": "Result 2", "link": "http://example.com/2"}, - ]) - assert result == expected_result - - -@pytest.mark.asyncio -async def test_search_google_no_results(mock_context): - with ( - patch("arcade_google_search.utils.SerpClient") as MockClient, - ): - mock_client_instance = MockClient.return_value - mock_client_instance.search.return_value.as_dict.return_value = {"organic_results": []} - - result = await search(mock_context, "test query", 2) - - expected_result = json.dumps([]) - assert result == expected_result diff --git a/toolkits/google_search/tests/test_utils.py b/toolkits/google_search/tests/test_utils.py deleted file mode 100644 index 0bbbd3e9..00000000 --- a/toolkits/google_search/tests/test_utils.py +++ /dev/null @@ -1,68 +0,0 @@ -import pytest -import serpapi -from arcade_tdk.errors import ToolExecutionError - -from arcade_google_search.utils import call_serpapi, prepare_params - - -class DummyContext: - def get_secret(self, key: str) -> str | None: - if key.lower() == "serp_api_key": - return "dummy_key" - return None - - -@pytest.fixture -def dummy_context(): - return DummyContext() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "engine, kwargs, expected", - [ - ("google", {}, {"engine": "google"}), - ( - "google", - {"q": "test", "window": 10, "time": "00:12:12"}, - { - "engine": "google", - "q": "test", - "window": 10, - "time": "00:12:12", - }, - ), - ], -) -async def test_prepare_params(engine, kwargs, expected): - params = prepare_params(engine, **kwargs) - assert params == expected - - -@pytest.mark.parametrize( - "error_message, sanitized_message", - [ - ( - "You hit your rate limit", - "You hit your rate limit", - ), - ( - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=ABC123456", - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=***", - ), - ( - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=ABC123456 make sure the api key is correct", - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=*** make sure the api key is correct", - ), - ], -) -def test_call_serpapi_failure(monkeypatch, dummy_context, error_message, sanitized_message): - def fake_serpapi_search(self, params: dict) -> dict: - raise Exception(error_message) # noqa: TRY002 - - monkeypatch.setattr(serpapi.Client, "search", fake_serpapi_search) - - with pytest.raises(ToolExecutionError) as excinfo: - call_serpapi(dummy_context, {}) - - assert excinfo.value.developer_message == sanitized_message diff --git a/toolkits/google_sheets/.pre-commit-config.yaml b/toolkits/google_sheets/.pre-commit-config.yaml deleted file mode 100644 index 4baefff8..00000000 --- a/toolkits/google_sheets/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_sheets/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_sheets/.ruff.toml b/toolkits/google_sheets/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_sheets/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_sheets/LICENSE b/toolkits/google_sheets/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/google_sheets/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_sheets/Makefile b/toolkits/google_sheets/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_sheets/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_sheets/arcade_google_sheets/__init__.py b/toolkits/google_sheets/arcade_google_sheets/__init__.py deleted file mode 100644 index 97ee1d19..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_google_sheets.tools import ( - create_spreadsheet, - get_spreadsheet, - write_to_cell, -) - -__all__ = ["create_spreadsheet", "get_spreadsheet", "write_to_cell"] diff --git a/toolkits/google_sheets/arcade_google_sheets/constants.py b/toolkits/google_sheets/arcade_google_sheets/constants.py deleted file mode 100644 index 20fd986e..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/constants.py +++ /dev/null @@ -1,2 +0,0 @@ -DEFAULT_SHEET_ROW_COUNT = 1000 -DEFAULT_SHEET_COLUMN_COUNT = 26 diff --git a/toolkits/google_sheets/arcade_google_sheets/decorators.py b/toolkits/google_sheets/arcade_google_sheets/decorators.py deleted file mode 100644 index 0760576c..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/decorators.py +++ /dev/null @@ -1,24 +0,0 @@ -import functools -from collections.abc import Callable -from typing import Any - -from arcade_tdk import ToolContext -from googleapiclient.errors import HttpError - -from arcade_google_sheets.file_picker import generate_google_file_picker_url - - -def with_filepicker_fallback(func: Callable[..., Any]) -> Callable[..., Any]: - """ """ - - @functools.wraps(func) - async def async_wrapper(context: ToolContext, *args: Any, **kwargs: Any) -> Any: - try: - return await func(context, *args, **kwargs) - except HttpError as e: - if e.status_code in [403, 404]: - file_picker_response = generate_google_file_picker_url(context) - return file_picker_response - raise - - return async_wrapper diff --git a/toolkits/google_sheets/arcade_google_sheets/enums.py b/toolkits/google_sheets/arcade_google_sheets/enums.py deleted file mode 100644 index a836f0b8..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/enums.py +++ /dev/null @@ -1,25 +0,0 @@ -from enum import Enum - - -class CellErrorType(str, Enum): - """The type of error in a cell - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType - """ - - ERROR_TYPE_UNSPECIFIED = "ERROR_TYPE_UNSPECIFIED" # The default error type, do not use this. - ERROR = "ERROR" # Corresponds to the #ERROR! error. - NULL_VALUE = "NULL_VALUE" # Corresponds to the #NULL! error. - DIVIDE_BY_ZERO = "DIVIDE_BY_ZERO" # Corresponds to the #DIV/0 error. - VALUE = "VALUE" # Corresponds to the #VALUE! error. - REF = "REF" # Corresponds to the #REF! error. - NAME = "NAME" # Corresponds to the #NAME? error. - NUM = "NUM" # Corresponds to the #NUM! error. - N_A = "N_A" # Corresponds to the #N/A error. - LOADING = "LOADING" # Corresponds to the Loading... state. - - -class NumberFormatType(str, Enum): - NUMBER = "NUMBER" - PERCENT = "PERCENT" - CURRENCY = "CURRENCY" diff --git a/toolkits/google_sheets/arcade_google_sheets/file_picker.py b/toolkits/google_sheets/arcade_google_sheets/file_picker.py deleted file mode 100644 index 193690ef..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/file_picker.py +++ /dev/null @@ -1,49 +0,0 @@ -import base64 -import json - -from arcade_tdk import ToolContext, ToolMetadataKey -from arcade_tdk.errors import ToolExecutionError - - -def generate_google_file_picker_url(context: ToolContext) -> dict: - """Generate a Google File Picker URL for user-driven file selection and authorization. - - Generates a URL that directs the end-user to a Google File Picker interface where - where they can select or upload Google Drive files. Users can grant permission to access their - Drive files, providing a secure and authorized way to interact with their files. - - This is particularly useful when prior tools (e.g., those accessing or modifying - Google Docs, Google Sheets, etc.) encountered failures due to file non-existence - (Requested entity was not found) or permission errors. Once the user completes the file - picker flow, the prior tool can be retried. - - Returns: - A dictionary containing the URL and instructions for the llm to instruct the user. - """ - client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID) - client_id_parts = client_id.split("-") - if not client_id_parts: - raise ToolExecutionError( - message="Invalid Google Client ID", - developer_message=f"Google Client ID '{client_id}' is not valid", - ) - app_id = client_id_parts[0] - cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/") - - config = { - "auth": { - "client_id": client_id, - "app_id": app_id, - }, - } - config_json = json.dumps(config) - config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8") - url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}" - - return { - "url": url, - "llm_instructions": ( - "Instruct the user to click the following link to open the Google Drive File Picker. " - f"This will allow them to select files and grant access permissions: {url}" - ), - } diff --git a/toolkits/google_sheets/arcade_google_sheets/models.py b/toolkits/google_sheets/arcade_google_sheets/models.py deleted file mode 100644 index d2ea5566..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/models.py +++ /dev/null @@ -1,241 +0,0 @@ -import json -from typing import Optional - -from pydantic import BaseModel, field_validator, model_validator - -from arcade_google_sheets.enums import CellErrorType, NumberFormatType -from arcade_google_sheets.types import CellValue - - -class CellErrorValue(BaseModel): - """An error in a cell - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorValue - """ - - type: CellErrorType - message: str - - -class CellExtendedValue(BaseModel): - """The kinds of value that a cell in a spreadsheet can have - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ExtendedValue - """ - - numberValue: float | None = None - stringValue: str | None = None - boolValue: bool | None = None - formulaValue: str | None = None - errorValue: Optional["CellErrorValue"] = None - - @model_validator(mode="after") - def check_exactly_one_value(cls, instance): # type: ignore[no-untyped-def] - provided = [v for v in instance.__dict__.values() if v is not None] - if len(provided) != 1: - raise ValueError( - "Exactly one of numberValue, stringValue, boolValue, " - "formulaValue, or errorValue must be set." - ) - return instance - - -class NumberFormat(BaseModel): - """The format of a number - - Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#NumberFormat - """ - - pattern: str - type: NumberFormatType - - -class CellFormat(BaseModel): - """The format of a cell - - Partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat - """ - - numberFormat: NumberFormat - - -class CellData(BaseModel): - """Data about a specific cell - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellData - """ - - userEnteredValue: CellExtendedValue - userEnteredFormat: CellFormat | None = None - - -class RowData(BaseModel): - """Data about each cellin a row - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData - """ - - values: list[CellData] - - -class GridData(BaseModel): - """Data in the grid - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData - """ - - startRow: int - startColumn: int - rowData: list[RowData] - - -class GridProperties(BaseModel): - """Properties of a grid - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties - """ - - rowCount: int - columnCount: int - - -class SheetProperties(BaseModel): - """Properties of a Sheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetProperties - """ - - sheetId: int - title: str - gridProperties: GridProperties | None = None - - -class Sheet(BaseModel): - """A Sheet in a spreadsheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet - """ - - properties: SheetProperties - data: list[GridData] | None = None - - -class SpreadsheetProperties(BaseModel): - """Properties of a spreadsheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties - """ - - title: str - - -class Spreadsheet(BaseModel): - """A spreadsheet - - A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets - """ - - properties: SpreadsheetProperties - sheets: list[Sheet] - - -class SheetDataInput(BaseModel): - """ - SheetDataInput models the cell data of a spreadsheet in a custom format. - - It is a dictionary mapping row numbers (as ints) to dictionaries that map - column letters (as uppercase strings) to cell values (int, float, str, or bool). - - This model enforces that: - - The outer keys are convertible to int. - - The inner keys are alphabetic strings (normalized to uppercase). - - All cell values are only of type int, float, str, or bool. - - The model automatically serializes (via `json_data()`) - and validates the inner types. - """ - - data: dict[int, dict[str, CellValue]] - - @classmethod - def _parse_json_if_string(cls, value): # type: ignore[no-untyped-def] - """Parses the value if it is a JSON string, otherwise returns it. - - Helper method for when validating the `data` field. - """ - if isinstance(value, str): - try: - return json.loads(value) - except json.JSONDecodeError as e: - raise TypeError(f"Invalid JSON: {e}") - return value - - @classmethod - def _validate_row_key(cls, row_key) -> int: # type: ignore[no-untyped-def] - """Converts the row key to an integer, raising an error if conversion fails. - - Helper method for when validating the `data` field. - """ - try: - return int(row_key) - except (ValueError, TypeError): - raise TypeError(f"Row key '{row_key}' is not convertible to int.") - - @classmethod - def _validate_inner_cells(cls, cells, row_int: int) -> dict: # type: ignore[no-untyped-def] - """Validates that 'cells' is a dict mapping column letters to valid cell values - and normalizes the keys. - - Helper method for when validating the `data` field. - """ - if not isinstance(cells, dict): - raise TypeError( - f"Value for row '{row_int}' must be a dict mapping column letters to cell values." - ) - new_inner = {} - for col_key, cell_value in cells.items(): - if not isinstance(col_key, str): - raise TypeError(f"Column key '{col_key}' must be a string.") - col_string = col_key.upper() - if not col_string.isalpha(): - raise TypeError(f"Column key '{col_key}' is invalid. Must be alphabetic.") - if not isinstance(cell_value, int | float | str | bool): - raise TypeError( - f"Cell value for {col_string}{row_int} must be an int, float, str, or bool." - ) - new_inner[col_string] = cell_value - return new_inner - - @field_validator("data", mode="before") - @classmethod - def validate_and_convert_keys(cls, value): # type: ignore[no-untyped-def] - """ - Validates data when SheetDataInput is instantiated and converts it to the correct format. - Uses private helper methods to parse JSON, validate row keys, and validate inner cell data. - """ - if value is None: - return {} - - value = cls._parse_json_if_string(value) - if isinstance(value, dict): - new_value = {} - for row_key, cells in value.items(): - row_int = cls._validate_row_key(row_key) - inner_cells = cls._validate_inner_cells(cells, row_int) - new_value[row_int] = inner_cells - return new_value - - raise TypeError("data must be a dict or a valid JSON string representing a dict") - - def json_data(self) -> str: - """ - Serialize the sheet data to a JSON string. - """ - return json.dumps(self.data) - - @classmethod - def from_json(cls, json_str: str) -> "SheetDataInput": - """ - Create a SheetData instance from a JSON string. - """ - return cls.model_validate_json(json_str) diff --git a/toolkits/google_sheets/arcade_google_sheets/tools/__init__.py b/toolkits/google_sheets/arcade_google_sheets/tools/__init__.py deleted file mode 100644 index e4158202..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/tools/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from arcade_google_sheets.tools.read import get_spreadsheet -from arcade_google_sheets.tools.write import create_spreadsheet, write_to_cell - -__all__ = ["create_spreadsheet", "get_spreadsheet", "write_to_cell"] diff --git a/toolkits/google_sheets/arcade_google_sheets/tools/read.py b/toolkits/google_sheets/arcade_google_sheets/tools/read.py deleted file mode 100644 index baf00013..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/tools/read.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, ToolMetadataKey, tool -from arcade_tdk.auth import Google - -from arcade_google_sheets.decorators import with_filepicker_fallback -from arcade_google_sheets.utils import ( - build_sheets_service, - parse_get_spreadsheet_response, -) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ), - requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL], -) -@with_filepicker_fallback -async def get_spreadsheet( - context: ToolContext, - spreadsheet_id: Annotated[str, "The id of the spreadsheet to get"], -) -> Annotated[ - dict, - "The spreadsheet properties and data for all sheets in the spreadsheet", -]: - """ - Get the user entered values and formatted values for all cells in all sheets in the spreadsheet - along with the spreadsheet's properties - """ - service = build_sheets_service(context.get_auth_token_or_empty()) - - response = ( - service.spreadsheets() - .get( - spreadsheetId=spreadsheet_id, - includeGridData=True, - fields="spreadsheetId,spreadsheetUrl,properties/title,sheets/properties,sheets/data/rowData/values/userEnteredValue,sheets/data/rowData/values/formattedValue,sheets/data/rowData/values/effectiveValue", - ) - .execute() - ) - return parse_get_spreadsheet_response(response) diff --git a/toolkits/google_sheets/arcade_google_sheets/tools/write.py b/toolkits/google_sheets/arcade_google_sheets/tools/write.py deleted file mode 100644 index 30179b38..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/tools/write.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Google -from arcade_tdk.errors import RetryableToolError - -from arcade_google_sheets.models import ( - SheetDataInput, - Spreadsheet, - SpreadsheetProperties, -) -from arcade_google_sheets.utils import ( - build_sheets_service, - create_sheet, - parse_write_to_cell_response, - validate_write_to_cell_params, -) - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -def create_spreadsheet( - context: ToolContext, - title: Annotated[str, "The title of the new spreadsheet"] = "Untitled spreadsheet", - data: Annotated[ - str | None, - "The data to write to the spreadsheet. A JSON string " - "(property names enclosed in double quotes) representing a dictionary that " - "maps row numbers to dictionaries that map column letters to cell values. " - "For example, data[23]['C'] would be the value of the cell in row 23, column C. " - "Type hint: dict[int, dict[str, Union[int, float, str, bool]]]", - ] = None, -) -> Annotated[dict, "The created spreadsheet's id and title"]: - """Create a new spreadsheet with the provided title and data in its first sheet - - Returns the newly created spreadsheet's id and title - """ - service = build_sheets_service(context.get_auth_token_or_empty()) - - try: - sheet_data = SheetDataInput(data=data) # type: ignore[arg-type] - except Exception as e: - msg = "Invalid JSON or unexpected data format for parameter `data`" - raise RetryableToolError( - message=msg, - additional_prompt_content=f"{msg}: {e}", - retry_after_ms=100, - ) - - spreadsheet = Spreadsheet( - properties=SpreadsheetProperties(title=title), - sheets=[create_sheet(sheet_data)], - ) - - body = spreadsheet.model_dump() - - response = ( - service.spreadsheets() - .create(body=body, fields="spreadsheetId,spreadsheetUrl,properties/title") - .execute() - ) - - return { - "title": response["properties"]["title"], - "spreadsheetId": response["spreadsheetId"], - "spreadsheetUrl": response["spreadsheetUrl"], - } - - -@tool( - requires_auth=Google( - scopes=["https://www.googleapis.com/auth/drive.file"], - ) -) -def write_to_cell( - context: ToolContext, - spreadsheet_id: Annotated[str, "The id of the spreadsheet to write to"], - column: Annotated[str, "The column string to write to. For example, 'A', 'F', or 'AZ'"], - row: Annotated[int, "The row number to write to"], - value: Annotated[str, "The value to write to the cell"], - sheet_name: Annotated[ - str, "The name of the sheet to write to. Defaults to 'Sheet1'" - ] = "Sheet1", -) -> Annotated[dict, "The status of the operation"]: - """ - Write a value to a single cell in a spreadsheet. - """ - service = build_sheets_service(context.get_auth_token_or_empty()) - validate_write_to_cell_params(service, spreadsheet_id, sheet_name, column, row) - - range_ = f"'{sheet_name}'!{column.upper()}{row}" - body = { - "range": range_, - "majorDimension": "ROWS", - "values": [[value]], - } - - sheet_properties = ( - service.spreadsheets() - .values() - .update( - spreadsheetId=spreadsheet_id, - range=range_, - valueInputOption="USER_ENTERED", - includeValuesInResponse=True, - body=body, - ) - .execute() - ) - - return parse_write_to_cell_response(sheet_properties) diff --git a/toolkits/google_sheets/arcade_google_sheets/types.py b/toolkits/google_sheets/arcade_google_sheets/types.py deleted file mode 100644 index f42a5061..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/types.py +++ /dev/null @@ -1 +0,0 @@ -CellValue = int | float | str | bool diff --git a/toolkits/google_sheets/arcade_google_sheets/utils.py b/toolkits/google_sheets/arcade_google_sheets/utils.py deleted file mode 100644 index 8495a029..00000000 --- a/toolkits/google_sheets/arcade_google_sheets/utils.py +++ /dev/null @@ -1,548 +0,0 @@ -import logging -from typing import Any - -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build - -from arcade_google_sheets.constants import ( - DEFAULT_SHEET_COLUMN_COUNT, - DEFAULT_SHEET_ROW_COUNT, -) -from arcade_google_sheets.enums import NumberFormatType -from arcade_google_sheets.models import ( - CellData, - CellExtendedValue, - CellFormat, - GridData, - GridProperties, - NumberFormat, - RowData, - Sheet, - SheetDataInput, - SheetProperties, -) -from arcade_google_sheets.types import CellValue - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def build_sheets_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported] - """ - Build a Sheets service object. - """ - auth_token = auth_token or "" - return build("sheets", "v4", credentials=Credentials(auth_token)) - - -def col_to_index(col: str) -> int: - """Convert a sheet's column string to a 0-indexed column index - - Args: - col (str): The column string to convert. e.g., "A", "AZ", "QED" - - Returns: - int: The 0-indexed column index. - """ - result = 0 - for char in col.upper(): - result = result * 26 + (ord(char) - ord("A") + 1) - return result - 1 - - -def index_to_col(index: int) -> str: - """Convert a 0-indexed column index to its corresponding column string - - Args: - index (int): The 0-indexed column index to convert. - - Returns: - str: The column string. e.g., "A", "AZ", "QED" - """ - result = "" - index += 1 - while index > 0: - index, rem = divmod(index - 1, 26) - result = chr(rem + ord("A")) + result - return result - - -def is_col_greater(col1: str, col2: str) -> bool: - """Determine if col1 represents a column that comes after col2 in a sheet - - This comparison is based on: - 1. The length of the column string (longer means greater). - 2. Lexicographical comparison if both strings are the same length. - - Args: - col1 (str): The first column string to compare. - col2 (str): The second column string to compare. - - Returns: - bool: True if col1 comes after col2, False otherwise. - """ - if len(col1) != len(col2): - return len(col1) > len(col2) - return col1.upper() > col2.upper() - - -def compute_sheet_data_dimensions( - sheet_data_input: SheetDataInput, -) -> tuple[tuple[int, int], tuple[int, int]]: - """ - Compute the dimensions of a sheet based on the data provided. - - Args: - sheet_data_input (SheetDataInput): - The data to compute the dimensions of. - - Returns: - tuple[tuple[int, int], tuple[int, int]]: The dimensions of the sheet. The first tuple - contains the row range (start, end) and the second tuple contains the column range - (start, end). - """ - max_row = 0 - min_row = 10_000_000 # max number of cells in a sheet - max_col_str = None - min_col_str = None - - for key, row in sheet_data_input.data.items(): - try: - row_num = int(key) - except ValueError: - continue - if row_num > max_row: - max_row = row_num - if row_num < min_row: - min_row = row_num - - if isinstance(row, dict): - for col in row: - # Update max column string - if max_col_str is None or is_col_greater(col, max_col_str): - max_col_str = col - # Update min column string - if min_col_str is None or is_col_greater(min_col_str, col): - min_col_str = col - - max_col_index = col_to_index(max_col_str) if max_col_str is not None else -1 - min_col_index = col_to_index(min_col_str) if min_col_str is not None else 0 - - return (min_row, max_row), (min_col_index, max_col_index) - - -def create_sheet(sheet_data_input: SheetDataInput) -> Sheet: - """Create a Google Sheet from a dictionary of data. - - Args: - sheet_data_input (SheetDataInput): The data to create the sheet from. - - Returns: - Sheet: The created sheet. - """ - (_, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(sheet_data_input) - sheet_data = create_sheet_data(sheet_data_input, min_col_index, max_col_index) - sheet_properties = create_sheet_properties( - row_count=max(DEFAULT_SHEET_ROW_COUNT, max_row), - column_count=max(DEFAULT_SHEET_COLUMN_COUNT, max_col_index + 1), - ) - - return Sheet(properties=sheet_properties, data=sheet_data) - - -def create_sheet_properties( - sheet_id: int = 1, - title: str = "Sheet1", - row_count: int = DEFAULT_SHEET_ROW_COUNT, - column_count: int = DEFAULT_SHEET_COLUMN_COUNT, -) -> SheetProperties: - """Create a SheetProperties object - - Args: - sheet_id (int): The ID of the sheet. - title (str): The title of the sheet. - row_count (int): The number of rows in the sheet. - column_count (int): The number of columns in the sheet. - - Returns: - SheetProperties: The created sheet properties object. - """ - return SheetProperties( - sheetId=sheet_id, - title=title, - gridProperties=GridProperties(rowCount=row_count, columnCount=column_count), - ) - - -def group_contiguous_rows(row_numbers: list[int]) -> list[list[int]]: - """Groups a sorted list of row numbers into contiguous groups - - A contiguous group is a list of row numbers that are consecutive integers. - For example, [1,2,3,5,6] is converted to [[1,2,3],[5,6]]. - - Args: - row_numbers (list[int]): The list of row numbers to group. - - Returns: - list[list[int]]: The grouped row numbers. - """ - if not row_numbers: - return [] - groups = [] - current_group = [row_numbers[0]] - for r in row_numbers[1:]: - if r == current_group[-1] + 1: - current_group.append(r) - else: - groups.append(current_group) - current_group = [r] - groups.append(current_group) - return groups - - -def create_cell_data(cell_value: CellValue) -> CellData: - """ - Create a CellData object based on the type of cell_value. - """ - if isinstance(cell_value, bool): - return _create_bool_cell(cell_value) - elif isinstance(cell_value, int): - return _create_int_cell(cell_value) - elif isinstance(cell_value, float): - return _create_float_cell(cell_value) - elif isinstance(cell_value, str): - return _create_string_cell(cell_value) - - -def _create_formula_cell(cell_value: str) -> CellData: - cell_val = CellExtendedValue(formulaValue=cell_value) - return CellData(userEnteredValue=cell_val) - - -def _create_currency_cell(cell_value: str) -> CellData: - value_without_symbol = cell_value[1:] - try: - num_value = int(value_without_symbol) - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0") - ) - cell_val = CellExtendedValue(numberValue=num_value) - return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format) - except ValueError: - try: - num_value = float(value_without_symbol) # type: ignore[assignment] - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0.00") - ) - cell_val = CellExtendedValue(numberValue=num_value) - return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format) - except ValueError: - return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value)) - - -def _create_percent_cell(cell_value: str) -> CellData: - try: - num_value = float(cell_value[:-1].strip()) - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.PERCENT, pattern="0.00%") - ) - cell_val = CellExtendedValue(numberValue=num_value) - return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format) - except ValueError: - return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value)) - - -def _create_bool_cell(cell_value: bool) -> CellData: - return CellData(userEnteredValue=CellExtendedValue(boolValue=cell_value)) - - -def _create_int_cell(cell_value: int) -> CellData: - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0") - ) - return CellData( - userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format - ) - - -def _create_float_cell(cell_value: float) -> CellData: - cell_format = CellFormat( - numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0.00") - ) - return CellData( - userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format - ) - - -def _create_string_cell(cell_value: str) -> CellData: - if cell_value.startswith("="): - return _create_formula_cell(cell_value) - elif cell_value.startswith("$") and len(cell_value) > 1: - return _create_currency_cell(cell_value) - elif cell_value.endswith("%") and len(cell_value) > 1: - return _create_percent_cell(cell_value) - - return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value)) - - -def create_row_data( - row_data: dict[str, CellValue], min_col_index: int, max_col_index: int -) -> RowData: - """Constructs RowData for a single row using the provided row_data. - - Args: - row_data (dict[str, CellValue]): The data to create the row from. - min_col_index (int): The minimum column index from the SheetDataInput. - max_col_index (int): The maximum column index from the SheetDataInput. - """ - row_cells = [] - for col_idx in range(min_col_index, max_col_index + 1): - col_letter = index_to_col(col_idx) - if col_letter in row_data: - cell_data = create_cell_data(row_data[col_letter]) - else: - cell_data = CellData(userEnteredValue=CellExtendedValue(stringValue="")) - row_cells.append(cell_data) - return RowData(values=row_cells) - - -def create_sheet_data( - sheet_data_input: SheetDataInput, - min_col_index: int, - max_col_index: int, -) -> list[GridData]: - """Create grid data from SheetDataInput by grouping contiguous rows and processing cells. - - Args: - sheet_data_input (SheetDataInput): The data to create the sheet from. - min_col_index (int): The minimum column index from the SheetDataInput. - max_col_index (int): The maximum column index from the SheetDataInput. - - Returns: - list[GridData]: The created grid data. - """ - row_numbers = list(sheet_data_input.data.keys()) - if not row_numbers: - return [] - - sorted_rows = sorted(row_numbers) - groups = group_contiguous_rows(sorted_rows) - - sheet_data = [] - for group in groups: - rows_data = [] - for r in group: - current_row_data = sheet_data_input.data.get(r, {}) - row = create_row_data(current_row_data, min_col_index, max_col_index) - rows_data.append(row) - grid_data = GridData( - startRow=group[0] - 1, # convert to 0-indexed - startColumn=min_col_index, - rowData=rows_data, - ) - sheet_data.append(grid_data) - - return sheet_data - - -def parse_get_spreadsheet_response(api_response: dict) -> dict: - """ - Parse the get spreadsheet Google Sheets API response into a structured dictionary. - """ - properties = api_response.get("properties", {}) - sheets = [parse_sheet(sheet) for sheet in api_response.get("sheets", [])] - - return { - "title": properties.get("title", ""), - "spreadsheetId": api_response.get("spreadsheetId", ""), - "spreadsheetUrl": api_response.get("spreadsheetUrl", ""), - "sheets": sheets, - } - - -def parse_sheet(api_sheet: dict) -> dict: - """ - Parse an individual sheet's data from the Google Sheets 'get spreadsheet' - API response into a structured dictionary. - """ - props = api_sheet.get("properties", {}) - grid_props = props.get("gridProperties", {}) - cell_data = convert_api_grid_data_to_dict(api_sheet.get("data", [])) - - return { - "sheetId": props.get("sheetId"), - "title": props.get("title", ""), - "rowCount": grid_props.get("rowCount", 0), - "columnCount": grid_props.get("columnCount", 0), - "data": cell_data, - } - - -def extract_user_entered_cell_value(cell: dict) -> Any: - """ - Extract the user entered value from a cell's 'userEnteredValue'. - - Args: - cell (dict): A cell dictionary from the grid data. - - Returns: - The extracted value if present, otherwise None. - """ - user_val = cell.get("userEnteredValue", {}) - for key in ["stringValue", "numberValue", "boolValue", "formulaValue"]: - if key in user_val: - return user_val[key] - - return "" - - -def process_row(row: dict, start_column_index: int) -> dict: - """ - Process a single row from grid data, converting non-empty cells into a dictionary - that maps column letters to cell values. - - Args: - row (dict): A row from the grid data. - start_column_index (int): The starting column index for this row. - - Returns: - dict: A mapping of column letters to cell values for non-empty cells. - """ - row_result = {} - for j, cell in enumerate(row.get("values", [])): - column_index = start_column_index + j - column_string = index_to_col(column_index) - user_entered_cell_value = extract_user_entered_cell_value(cell) - formatted_cell_value = cell.get("formattedValue", "") - - if user_entered_cell_value != "" or formatted_cell_value != "": - row_result[column_string] = { - "userEnteredValue": user_entered_cell_value, - "formattedValue": formatted_cell_value, - } - - return row_result - - -def convert_api_grid_data_to_dict(grids: list[dict]) -> dict: - """ - Convert a list of grid data dictionaries from the 'get spreadsheet' API - response into a structured cell dictionary. - - The returned dictionary maps row numbers to sub-dictionaries that map column letters - (e.g., 'A', 'B', etc.) to their corresponding non-empty cell values. - - Args: - grids (list[dict]): The list of grid data dictionaries from the API. - - Returns: - dict: A dictionary mapping row numbers to dictionaries of column letter/value pairs. - Only includes non-empty rows and non-empty cells. - """ - result = {} - for grid in grids: - start_row = grid.get("startRow", 0) - start_column = grid.get("startColumn", 0) - - for i, row in enumerate(grid.get("rowData", []), start=1): - current_row = start_row + i - row_data = process_row(row, start_column) - - if row_data: - result[current_row] = row_data - - return dict(sorted(result.items())) - - -def validate_write_to_cell_params( # type: ignore[no-any-unimported] - service: Resource, - spreadsheet_id: str, - sheet_name: str, - column: str, - row: int, -) -> None: - """Validates the input parameters for the write to cell tool. - - Args: - service (Resource): The Google Sheets service. - spreadsheet_id (str): The ID of the spreadsheet provided to the tool. - sheet_name (str): The name of the sheet provided to the tool. - column (str): The column to write to provided to the tool. - row (int): The row to write to provided to the tool. - - Raises: - RetryableToolError: - If the sheet name is not found in the spreadsheet - ToolExecutionError: - If the column is not alphabetical - If the row is not a positive number - If the row is out of bounds for the sheet - If the column is out of bounds for the sheet - """ - if not column.isalpha(): - raise ToolExecutionError( - message=( - f"Invalid column name {column}. " - "It must be a non-empty string containing only letters" - ), - ) - - if row < 1: - raise ToolExecutionError( - message=(f"Invalid row number {row}. It must be a positive integer greater than 0."), - ) - - sheet_properties = ( - service.spreadsheets() - .get( - spreadsheetId=spreadsheet_id, - includeGridData=True, - fields="sheets/properties/title,sheets/properties/gridProperties/rowCount,sheets/properties/gridProperties/columnCount", - ) - .execute() - ) - sheet_names = [sheet["properties"]["title"] for sheet in sheet_properties["sheets"]] - sheet_row_count = sheet_properties["sheets"][0]["properties"]["gridProperties"]["rowCount"] - sheet_column_count = sheet_properties["sheets"][0]["properties"]["gridProperties"][ - "columnCount" - ] - - if sheet_name not in sheet_names: - raise RetryableToolError( - message=f"Sheet name {sheet_name} not found in spreadsheet with id {spreadsheet_id}", - additional_prompt_content=f"Sheet names in the spreadsheet: {sheet_names}", - retry_after_ms=100, - ) - - if row > sheet_row_count: - raise ToolExecutionError( - message=( - f"Row {row} is out of bounds for sheet {sheet_name} " - f"in spreadsheet with id {spreadsheet_id}. " - f"Sheet only has {sheet_row_count} rows which is less than the requested row {row}" - ) - ) - - if col_to_index(column) > sheet_column_count: - raise ToolExecutionError( - message=( - f"Column {column} is out of bounds for sheet {sheet_name} " - f"in spreadsheet with id {spreadsheet_id}. " - f"Sheet only has {sheet_column_count} columns which " - f"is less than the requested column {column}" - ) - ) - - -def parse_write_to_cell_response(response: dict) -> dict: - return { - "spreadsheetId": response["spreadsheetId"], - "sheetTitle": response["updatedData"]["range"].split("!")[0], - "updatedCell": response["updatedData"]["range"].split("!")[1], - "value": response["updatedData"]["values"][0][0], - } diff --git a/toolkits/google_sheets/evals/eval_google_sheets.py b/toolkits/google_sheets/evals/eval_google_sheets.py deleted file mode 100644 index 5312a0b6..00000000 --- a/toolkits/google_sheets/evals/eval_google_sheets.py +++ /dev/null @@ -1,169 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_google_sheets -from arcade_google_sheets.tools import ( - create_spreadsheet, - get_spreadsheet, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_google_sheets) - -sheet_content_prompt = """name age email score gender city country registration_date -John Doe 28 johndoe@example.com 85 Male New York USA 2023-01-15 -Jane Smith 34 janesmith@example.com 92 Female Los Angeles USA 2023-02-20 -Alice Johnson 22 alicej@example.com 78 Female Chicago USA 2023-03-10 -Bob Brown 45 bobbrown@example.com 88 Male Houston USA 2023-04-05 -Charlie Davis 30 charlied@example.com 95 Male Phoenix USA 2023-05-12 -Eve White 27 evewhite@example.com 82 Female Philadelphia USA 2023-06-18 -Frank Black 40 frankb@example.com 90 Male San Antonio USA 2023-07-25 -Grace Green 29 graceg@example.com 76 Female Dallas USA 2023-08-30 -Hank Blue 35 hankb@example.com 89 Male San Diego USA 2023-09-15 -Ivy Red 31 ivyred@example.com 91 Female San Jose USA 2023-10-01 -Michael Grey 33 michaelg@example.com 87 Male Seattle USA 2023-10-05 -Nina Black 26 ninab@example.com 84 Female Miami USA 2023-10-10 -Oscar White 38 oscarw@example.com 90 Male Atlanta USA 2023-10-15 -Paula Green 32 paulag@example.com 93 Female Boston USA 2023-10-20 -Quentin Brown 29 quentinb@example.com 81 Male Denver USA 2023-10-25 -Rachel Blue 24 rachelb@example.com 79 Female Orlando USA 2023-10-30 -Steve Red 36 stever@example.com 88 Male Las Vegas USA 2023-11-01 -Tina Yellow 30 tinay@example.com 85 Female Portland USA 2023-11-05 -Ursula Pink 27 ursulap@example.com 82 Female San Francisco USA 2023-11-10 -Victor Grey 41 victorg@example.com 91 Male Charlotte USA 2023-11-15 -Wendy Black 34 wendyb@example.com 89 Female Detroit USA 2023-11-20 -Xander White 29 xanderw@example.com 86 Male Indianapolis USA 2023-11-25 -Yvonne Green 25 yvonnag@example.com 83 Female Columbus USA 2023-11-30 -Zachary Blue 37 zacharyb@example.com 90 Male Jacksonville USA 2023-12-01 -Alice Brown 28 aliceb@example.com 80 Female Memphis USA 2023-12-05 -Brian Black 39 brianb@example.com 92 Male Nashville USA 2023-12-10 -Cathy Green 31 cathyg@example.com 84 Female Virginia Beach USA 2023-12-15 -Daniel White 30 danielw@example.com 88 Male Atlanta USA 2023-12-20 -Eva Red 26 evar@example.com 81 Female New Orleans USA 2023-12-25 -Frankie Grey 35 frankieg@example.com 90 Male San Antonio USA 2023-12-30 -Gina Blue 29 ginab@example.com 87 Female San Diego USA 2024-01-01 -Henry Black 42 henryb@example.com 93 Male Philadelphia USA 2024-01-05 -Isla Green 24 islag@example.com 79 Female Chicago USA 2024-01-10 -Jack White 33 jackw@example.com 85 Male Los Angeles USA 2024-01-15 -Kathy Red 31 kathyr@example.com 82 Female Miami USA 2024-01-20 -Liam Grey 36 liamg@example.com 89 Male Seattle USA 2024-01-25 -Mia Black 27 miab@example.com 80 Female Denver USA 2024-01-30 -Nate Green 30 nateg@example.com 88 Male Orlando USA 2024-02-01 -- (empty row) -- (empty row) -- (empty row) -100, 300, 234, 399, 5039, 2345, 23526, 123, 54, 234, 54, 23, 12, 57, 1324, (the formula for sum of everything to the left) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -- (empty row) -456, 234, 234, 399, 234, 1234, 23526, 123, 54, 234, 4567, 23, 12, 234, 1324, (the formula for sum of everything to the left) -""" - - -@tool_eval() -def create_spreadsheet_eval() -> EvalSuite: - """Create an evaluation suite for Google Sheets create_spreadsheet tool.""" - - sheet_content_expected1 = """{"1": {"A": "name", "B": "age", "C": "email", "D": "score", "E": "gender", "F": "city", "G": "country", "H": "registration_date"}, "2": {"A": "John Doe", "B": 28, "C": "johndoe@example.com", "D": 85, "E": "Male", "F": "New York", "G": "USA", "H": "2023-01-15"}, "3": {"A": "Jane Smith", "B": 34, "C": "janesmith@example.com", "D": 92, "E": "Female", "F": "Los Angeles", "G": "USA", "H": "2023-02-20"}, "4": {"A": "Alice Johnson", "B": 22, "C": "alicej@example.com", "D": 78, "E": "Female", "F": "Chicago", "G": "USA", "H": "2023-03-10"}, "5": {"A": "Bob Brown", "B": 45, "C": "bobbrown@example.com", "D": 88, "E": "Male", "F": "Houston", "G": "USA", "H": "2023-04-05"}, "6": {"A": "Charlie Davis", "B": 30, "C": "charlied@example.com", "D": 95, "E": "Male", "F": "Phoenix", "G": "USA", "H": "2023-05-12"}, "7": {"A": "Eve White", "B": 27, "C": "evewhite@example.com", "D": 82, "E": "Female", "F": "Philadelphia", "G": "USA", "H": "2023-06-18"}, "8": {"A": "Frank Black", "B": 40, "C": "frankb@example.com", "D": 90, "E": "Male", "F": "San Antonio", "G": "USA", "H": "2023-07-25"}, "9": {"A": "Grace Green", "B": 29, "C": "graceg@example.com", "D": 76, "E": "Female", "F": "Dallas", "G": "USA", "H": "2023-08-30"}, "10": {"A": "Hank Blue", "B": 35, "C": "hankb@example.com", "D": 89, "E": "Male", "F": "San Diego", "G": "USA", "H": "2023-09-15"}, "11": {"A": "Ivy Red", "B": 31, "C": "ivyred@example.com", "D": 91, "E": "Female", "F": "San Jose", "G": "USA", "H": "2023-10-01"}, "12": {"A": "Michael Grey", "B": 33, "C": "michaelg@example.com", "D": 87, "E": "Male", "F": "Seattle", "G": "USA", "H": "2023-10-05"}, "13": {"A": "Nina Black", "B": 26, "C": "ninab@example.com", "D": 84, "E": "Female", "F": "Miami", "G": "USA", "H": "2023-10-10"}, "14": {"A": "Oscar White", "B": 38, "C": "oscarw@example.com", "D": 90, "E": "Male", "F": "Atlanta", "G": "USA", "H": "2023-10-15"}, "15": {"A": "Paula Green", "B": 32, "C": "paulag@example.com", "D": 93, "E": "Female", "F": "Boston", "G": "USA", "H": "2023-10-20"}, "16": {"A": "Quentin Brown", "B": 29, "C": "quentinb@example.com", "D": 81, "E": "Male", "F": "Denver", "G": "USA", "H": "2023-10-25"}, "17": {"A": "Rachel Blue", "B": 24, "C": "rachelb@example.com", "D": 79, "E": "Female", "F": "Orlando", "G": "USA", "H": "2023-10-30"}, "18": {"A": "Steve Red", "B": 36, "C": "stever@example.com", "D": 88, "E": "Male", "F": "Las Vegas", "G": "USA", "H": "2023-11-01"}, "19": {"A": "Tina Yellow", "B": 30, "C": "tinay@example.com", "D": 85, "E": "Female", "F": "Portland", "G": "USA", "H": "2023-11-05"}, "20": {"A": "Ursula Pink", "B": 27, "C": "ursulap@example.com", "D": 82, "E": "Female", "F": "San Francisco", "G": "USA", "H": "2023-11-10"}, "21": {"A": "Victor Grey", "B": 41, "C": "victorg@example.com", "D": 91, "E": "Male", "F": "Charlotte", "G": "USA", "H": "2023-11-15"}, "22": {"A": "Wendy Black", "B": 34, "C": "wendyb@example.com", "D": 89, "E": "Female", "F": "Detroit", "G": "USA", "H": "2023-11-20"}, "23": {"A": "Xander White", "B": 29, "C": "xanderw@example.com", "D": 86, "E": "Male", "F": "Indianapolis", "G": "USA", "H": "2023-11-25"}, "24": {"A": "Yvonne Green", "B": 25, "C": "yvonnag@example.com", "D": 83, "E": "Female", "F": "Columbus", "G": "USA", "H": "2023-11-30"}, "25": {"A": "Zachary Blue", "B": 37, "C": "zacharyb@example.com", "D": 90, "E": "Male", "F": "Jacksonville", "G": "USA", "H": "2023-12-01"}, "26": {"A": "Alice Brown", "B": 28, "C": "aliceb@example.com", "D": 80, "E": "Female", "F": "Memphis", "G": "USA", "H": "2023-12-05"}, "27": {"A": "Brian Black", "B": 39, "C": "brianb@example.com", "D": 92, "E": "Male", "F": "Nashville", "G": "USA", "H": "2023-12-10"}, "28": {"A": "Cathy Green", "B": 31, "C": "cathyg@example.com", "D": 84, "E": "Female", "F": "Virginia Beach", "G": "USA", "H": "2023-12-15"}, "29": {"A": "Daniel White", "B": 30, "C": "danielw@example.com", "D": 88, "E": "Male", "F": "Atlanta", "G": "USA", "H": "2023-12-20"}, "30": {"A": "Eva Red", "B": 26, "C": "evar@example.com", "D": 81, "E": "Female", "F": "New Orleans", "G": "USA", "H": "2023-12-25"}, "31": {"A": "Frankie Grey", "B": 35, "C": "frankieg@example.com", "D": 90, "E": "Male", "F": "San Antonio", "G": "USA", "H": "2023-12-30"}, "32": {"A": "Gina Blue", "B": 29, "C": "ginab@example.com", "D": 87, "E": "Female", "F": "San Diego", "G": "USA", "H": "2024-01-01"}, "33": {"A": "Henry Black", "B": 42, "C": "henryb@example.com", "D": 93, "E": "Male", "F": "Philadelphia", "G": "USA", "H": "2024-01-05"}, "34": {"A": "Isla Green", "B": 24, "C": "islag@example.com", "D": 79, "E": "Female", "F": "Chicago", "G": "USA", "H": "2024-01-10"}, "35": {"A": "Jack White", "B": 33, "C": "jackw@example.com", "D": 85, "E": "Male", "F": "Los Angeles", "G": "USA", "H": "2024-01-15"}, "36": {"A": "Kathy Red", "B": 31, "C": "kathyr@example.com", "D": 82, "E": "Female", "F": "Miami", "G": "USA", "H": "2024-01-20"}, "37": {"A": "Liam Grey", "B": 36, "C": "liamg@example.com", "D": 89, "E": "Male", "F": "Seattle", "G": "USA", "H": "2024-01-25"}, "38": {"A": "Mia Black", "B": 27, "C": "miab@example.com", "D": 80, "E": "Female", "F": "Denver", "G": "USA", "H": "2024-01-30"}, "39": {"A": "Nate Green", "B": 30, "C": "nateg@example.com", "D": 88, "E": "Male", "F": "Orlando", "G": "USA", "H": "2024-02-01"}, "40": {}, "41": {}, "42": {}, "43": {"A": 100, "B": 300, "C": 234, "D": 399, "E": 5039, "F": 2345, "G": 23526, "H": 123, "I": 54, "J": 234, "K": 54, "L": 23, "M": 12, "N": 57, "O": 1324, "P": "(the formula for sum of everything to the left)"}, "44": {}, "45": {}, "46": {}, "47": {}, "48": {}, "49": {}, "50": {}, "51": {}, "52": {}, "53": {}, "54": {}, "55": {}, "56": {}, "57": {}, "58": {}, "59": {}, "60": {"A": 456, "B": 234, "C": 234, "D": 399, "E": 234, "F": 1234, "G": 23526, "H": 123, "I": 54, "J": 234, "K": 4567, "L": 899, "M": 12, "N": 234, "O": 45, "P": "(the formula for sum of everything to the left)"}}""" - sheet_content_sparse_expected = """{"1": {"AA": "=SUM(A1,A2,A3)", "3782": {"A": 3783, "D": 3784, "AAZ": 3785, "ZZFS": 3786, "CA": 3787}}}""" - - suite = EvalSuite( - name="Google Sheets Tools Evaluation", - system_message="You are an AI assistant that can manage Google Sheets using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create a spreadsheet from large data payload", - user_message=f"Create a spreadsheet named 'Data' with the following content:\n{sheet_content_prompt}", - expected_tool_calls=[ - ExpectedToolCall( - func=create_spreadsheet, - args={ - "title": "Data", - "data": sheet_content_expected1, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="title", weight=0.1), - SimilarityCritic(critic_field="data", weight=0.9, similarity_threshold=0.99), - ], - ) - - suite.add_case( - name="Create a spreadsheet from sparse data payload", - user_message="Create a spreadsheet named 'Sparse Data' that fills the 27th column in the first row with the formula that sums A1, A2, and A3 cells. The 3782nd row should have its A, D, AAZ, ZZFS, and CA columns filled with the numbers 1, 2, 3, 4, and 5, respectively, summed with its row number.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_spreadsheet, - args={ - "title": "Sparse Data", - "data": sheet_content_sparse_expected, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="title", weight=0.1), - SimilarityCritic(critic_field="data", weight=0.9, similarity_threshold=0.95), - ], - ) - - return suite - - -@tool_eval() -def get_spreadsheet_eval() -> EvalSuite: - """Create an evaluation suite for Google Sheets get_spreadsheet tool.""" - - suite = EvalSuite( - name="Google Sheets Tools Evaluation", - system_message="You are an AI assistant that can manage Google Sheets using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get a spreadsheet", - user_message="Get the data in the second sheet of the spreadsheet with the following id 1L2ovCUcRNOacoWxtLV3jgaidWZq4Bw_WXbIWJcxobN0", - expected_tool_calls=[ - ExpectedToolCall( - func=get_spreadsheet, - args={ - "spreadsheet_id": "1L2ovCUcRNOacoWxtLV3jgaidWZq4Bw_WXbIWJcxobN0", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="spreadsheet_id", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/google_sheets/pyproject.toml b/toolkits/google_sheets/pyproject.toml deleted file mode 100644 index a00d06d7..00000000 --- a/toolkits/google_sheets/pyproject.toml +++ /dev/null @@ -1,63 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_sheets" -version = "2.0.0" -description = "Arcade.dev LLM tools for Google Sheets" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "google-api-python-client>=2.137.0,<3.0.0", - "google-api-core>=2.19.1,<3.0.0", - "google-auth>=2.32.0,<3.0.0", - "google-auth-httplib2>=0.2.0,<1.0.0", - "googleapis-common-protos>=1.63.2,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_sheets/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_sheets",] diff --git a/toolkits/google_sheets/tests/__init__.py b/toolkits/google_sheets/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/google_sheets/tests/test_sheets_models.py b/toolkits/google_sheets/tests/test_sheets_models.py deleted file mode 100644 index 428e8b66..00000000 --- a/toolkits/google_sheets/tests/test_sheets_models.py +++ /dev/null @@ -1,84 +0,0 @@ -from arcade_google_sheets.models import SheetDataInput - - -def test_sheet_input_data_init(): - data = '{"1":{"A":"name","B":"age","C":"email","D":"score","E":"gender","F":"city","G":"country","H":"registration_date"},"34":{"A":"Isla Green","B":24,"C":"islag@example.com","D":79,"E":"Female","F":"Chicago","G":"USA","H":"2024-01-10"},"38":{"A":"Mia Black","B":27,"C":"miab@example.com","D":80,"E":"Female","F":"Denver","G":"USA","H":"2024-01-30"},"39":{"A":"Nate Green","B":30,"C":"nateg@example.com","D":88,"E":"Male","F":"Orlando","G":"USA","H":"2024-02-01"},"43":{"A":100,"B":300,"C":234,"D":399,"E":5039,"F":2345,"G":23526,"H":123,"I":54,"J":234,"K":54,"L":23,"M":12,"N":57,"O":1324},"47":{"A":456,"B":234,"C":234,"D":399,"E":234,"F":1234,"G":23526,"H":123,"I":54,"J":234,"K":4567,"L":23,"M":12,"N":234,"O":1324}}' - expected_data = { - 1: { - "A": "name", - "B": "age", - "C": "email", - "D": "score", - "E": "gender", - "F": "city", - "G": "country", - "H": "registration_date", - }, - 34: { - "A": "Isla Green", - "B": 24, - "C": "islag@example.com", - "D": 79, - "E": "Female", - "F": "Chicago", - "G": "USA", - "H": "2024-01-10", - }, - 38: { - "A": "Mia Black", - "B": 27, - "C": "miab@example.com", - "D": 80, - "E": "Female", - "F": "Denver", - "G": "USA", - "H": "2024-01-30", - }, - 39: { - "A": "Nate Green", - "B": 30, - "C": "nateg@example.com", - "D": 88, - "E": "Male", - "F": "Orlando", - "G": "USA", - "H": "2024-02-01", - }, - 43: { - "A": 100, - "B": 300, - "C": 234, - "D": 399, - "E": 5039, - "F": 2345, - "G": 23526, - "H": 123, - "I": 54, - "J": 234, - "K": 54, - "L": 23, - "M": 12, - "N": 57, - "O": 1324, - }, - 47: { - "A": 456, - "B": 234, - "C": 234, - "D": 399, - "E": 234, - "F": 1234, - "G": 23526, - "H": 123, - "I": 54, - "J": 234, - "K": 4567, - "L": 23, - "M": 12, - "N": 234, - "O": 1324, - }, - } - - sheet_input_data = SheetDataInput(data=data) - assert sheet_input_data.data == expected_data diff --git a/toolkits/google_sheets/tests/test_sheets_utils.py b/toolkits/google_sheets/tests/test_sheets_utils.py deleted file mode 100644 index d8ad179a..00000000 --- a/toolkits/google_sheets/tests/test_sheets_utils.py +++ /dev/null @@ -1,542 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_google_sheets.enums import NumberFormatType -from arcade_google_sheets.models import ( - CellData, - CellExtendedValue, - RowData, - SheetDataInput, -) -from arcade_google_sheets.utils import ( - col_to_index, - compute_sheet_data_dimensions, - convert_api_grid_data_to_dict, - create_cell_data, - create_row_data, - create_sheet_data, - create_sheet_properties, - extract_user_entered_cell_value, - group_contiguous_rows, - index_to_col, - is_col_greater, - process_row, - validate_write_to_cell_params, -) - - -@pytest.fixture -def sheet_data_input_fixture(): - data = { - 1: { - "A": "name", - "B": "age", - "C": "email", - "D": "score", - "E": "gender", - "F": "city", - "G": "country", - "H": "registration_date", - }, - 2: { - "A": "John Doe", - "B": 28, - "C": "johndoe@example.com", - "D": 85.4, - "E": "Male", - "F": "New York", - "G": "USA", - "H": "2023-01-15", - }, - 10: { - "A": "Nate Green", - "B": 30, - "C": "nateg@example.com", - "D": 88, - "E": "Male", - "F": "Orlando", - "G": "USA", - "H": "2024-02-01", - }, - 43: { - "A": 100, - "B": 300, - "H": 123, - "I": "=SUM(SEQUENCE(10))", - }, - 44: { - "A": 456, - "B": 234, - "H": 123, - "I": "=SUM(SEQUENCE(10))", - }, - } - return SheetDataInput(data=data) - - -@pytest.mark.parametrize( - "col, expected_index", - [ - ("A", 0), - ("B", 1), - ("Z", 25), - ("AA", 26 + 0), - ("AZ", (1 * 26) + 25), - ("BA", (2 * 26) + 0), - ("ZZ", (26 * 26) + 25), - ("AAA", (1 * 26 * 26) + (1 * 26) + 0), - ("AAB", (1 * 26 * 26) + (1 * 26) + 1), - ("QED", (17 * 26 * 26) + (5 * 26) + 3), - ], -) -def test_col_to_index(col, expected_index): - assert col_to_index(col) == expected_index - - -@pytest.mark.parametrize( - "index, expected_col", - [ - (0, "A"), - (1, "B"), - (25, "Z"), - (26 + 0, "AA"), - ((1 * 26) + 25, "AZ"), - ((2 * 26) + 0, "BA"), - ((26 * 26) + 25, "ZZ"), - ((1 * 26 * 26) + (1 * 26) + 0, "AAA"), - ((1 * 26 * 26) + (1 * 26) + 1, "AAB"), - ((17 * 26 * 26) + (5 * 26) + 3, "QED"), - ], -) -def test_index_to_col(index, expected_col): - assert index_to_col(index) == expected_col - - -@pytest.mark.parametrize( - "col1, col2, expected_result", - [ - ("A", "B", False), - ("B", "A", True), - ("AA", "AB", False), - ("AB", "AA", True), - ("A", "AA", False), - ("AA", "A", True), - ("Z", "AA", False), - ("AA", "Z", True), - ("AAA", "AAB", False), - ("AAB", "AAA", True), - ("QED", "QEE", False), - ("QEE", "QED", True), - ], -) -def test_is_col_greater(col1, col2, expected_result): - assert is_col_greater(col1, col2) == expected_result - - -def test_compute_sheet_data_dimensions(sheet_data_input_fixture): - (min_row, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions( - sheet_data_input_fixture - ) - - expected_min_row = 1 - expected_max_row = 44 - expected_min_col_index = 0 # Column "A" - expected_max_col_index = 8 # Column "I" - - assert min_row == expected_min_row - assert max_row == expected_max_row - assert min_col_index == expected_min_col_index - assert max_col_index == expected_max_col_index - - -def test_create_sheet_properties(): - sheet_properties = create_sheet_properties( - sheet_id=1, - title="Sheet1", - row_count=10000, - column_count=260, - ) - - assert sheet_properties.sheetId == 1 - assert sheet_properties.title == "Sheet1" - assert sheet_properties.gridProperties.rowCount == 10000 - assert sheet_properties.gridProperties.columnCount == 260 - - -@pytest.mark.parametrize( - "row_numbers, expected_groups", - [ - ([], []), - ([5, 6, 7], [[5, 6, 7]]), - ( - [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 18, 19, 20], - [[1, 2, 3], [5, 6, 7, 8, 9, 10, 11], [18, 19, 20]], - ), - ], -) -def test_group_contiguous_rows(row_numbers, expected_groups): - grouped_rows = group_contiguous_rows(row_numbers) - assert grouped_rows == expected_groups - - -@pytest.mark.parametrize( - "input_value, expected_key, expected_value, expected_type, expected_pattern", - [ - (1234, "numberValue", 1234, NumberFormatType.NUMBER, "#,##0"), - (1.234, "numberValue", 1.234, NumberFormatType.NUMBER, "#,##0.00"), - ("$100", "numberValue", 100, NumberFormatType.CURRENCY, "$#,##0"), - ("$100.50", "numberValue", 100.50, NumberFormatType.CURRENCY, "$#,##0.00"), - ("75%", "numberValue", 75.00, NumberFormatType.PERCENT, "0.00%"), - ("75.34%", "numberValue", 75.34, NumberFormatType.PERCENT, "0.00%"), - ("$1abc", "stringValue", "$1abc", None, None), - ("abc7%", "stringValue", "abc7%", None, None), - ("=SUM(A1:B1)", "formulaValue", "=SUM(A1:B1)", None, None), - (True, "boolValue", True, None, None), - ], -) -def test_create_cell_data( - input_value, expected_key, expected_value, expected_type, expected_pattern -): - cell_data = create_cell_data(input_value) - expected_cell_value = CellExtendedValue(**{expected_key: expected_value}) - assert cell_data.userEnteredValue == expected_cell_value - if expected_type is None: - assert cell_data.userEnteredFormat is None - else: - assert cell_data.userEnteredFormat is not None - assert cell_data.userEnteredFormat.numberFormat.type == expected_type - assert cell_data.userEnteredFormat.numberFormat.pattern == expected_pattern - - -def test_create_row_data(): - row_data = { - "A": 1, # Column index 0 - "B": 2.5, # Column index 1 - "AA": "test", # Column index 26 - "BA": True, # Column index 52 - "BB": "=SUM(A1:B1)", # Column index 53 - } - min_col_index = 0 # Column "A" - max_col_index = 53 # Column "BB" - - expected_row_data = RowData( - values=[ - CellData(userEnteredValue=CellExtendedValue(stringValue="")) - for _ in range(max_col_index + 1) - ] - ) - expected_row_data.values[0].userEnteredValue = CellExtendedValue(numberValue=1) - expected_row_data.values[1].userEnteredValue = CellExtendedValue(numberValue=2.5) - expected_row_data.values[26].userEnteredValue = CellExtendedValue(stringValue="test") - expected_row_data.values[52].userEnteredValue = CellExtendedValue(boolValue=True) - expected_row_data.values[53].userEnteredValue = CellExtendedValue(formulaValue="=SUM(A1:B1)") - - row_data = create_row_data(row_data, min_col_index, max_col_index) - - assert len(row_data.values) == len(expected_row_data.values) - for cell, expected in zip(row_data.values, expected_row_data.values, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - -def test_create_sheet_data(): - from arcade_google_sheets.models import CellData, CellExtendedValue, SheetDataInput - from arcade_google_sheets.utils import create_cell_data - - test_data = { - 2: {"B": "row2B", "C": 200}, - 3: {"B": "row3B"}, - 5: {"A": "=SUM(A1:A1)", "C": "row5C"}, - } - sheet_data_input = SheetDataInput(data=test_data) - min_col_index = 0 # Column "A" - max_col_index = 2 # Column "C" - - grid_data_list = create_sheet_data(sheet_data_input, min_col_index, max_col_index) - - assert len(grid_data_list) == 2, "Should have two groups of contiguous rows" - - group1 = grid_data_list[0] - assert group1.startRow == 1 - assert group1.startColumn == min_col_index - assert len(group1.rowData) == 2 - - row2_cells = group1.rowData[0].values - expected_row2 = [ - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - create_cell_data("row2B"), - create_cell_data(200), - ] - for cell, expected in zip(row2_cells, expected_row2, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - row3_cells = group1.rowData[1].values - expected_row3 = [ - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - create_cell_data("row3B"), - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - ] - for cell, expected in zip(row3_cells, expected_row3, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - group2 = grid_data_list[1] - assert group2.startRow == 4 - assert group2.startColumn == min_col_index - assert len(group2.rowData) == 1 - - row5_cells = group2.rowData[0].values - expected_row5 = [ - create_cell_data("=SUM(A1:A1)"), - CellData(userEnteredValue=CellExtendedValue(stringValue="")), - create_cell_data("row5C"), - ] - for cell, expected in zip(row5_cells, expected_row5, strict=False): - assert cell.userEnteredValue == expected.userEnteredValue - - -@pytest.mark.parametrize( - "cell, expected", - [ - ({}, ""), - ({"userEnteredValue": {}}, ""), - ({"userEnteredValue": {"stringValue": "hello"}}, "hello"), - ({"userEnteredValue": {"numberValue": 123}}, 123), - ({"userEnteredValue": {"boolValue": True}}, True), - ({"userEnteredValue": {"formulaValue": "=SUM(A1:A2)"}}, "=SUM(A1:A2)"), - ], -) -def test_extract_user_entered_cell_value(cell, expected): - result = extract_user_entered_cell_value(cell) - assert result == expected - - -def test_process_row_empty(): - row = {} - assert process_row(row, 0) == {} - - -def test_process_row_non_empty(): - row = { - "values": [ - {"userEnteredValue": {"stringValue": "cell1"}, "formattedValue": "cell1"}, - {"userEnteredValue": {}}, # should be ignored - {"userEnteredValue": {"formulaValue": "=C1+D4"}, "formattedValue": 42}, - {"userEnteredValue": {"stringValue": ""}, "formattedValue": ""}, # should be ignored - {"userEnteredValue": {"boolValue": False}, "formattedValue": False}, - ] - } - expected = { - "A": {"userEnteredValue": "cell1", "formattedValue": "cell1"}, - "C": {"userEnteredValue": "=C1+D4", "formattedValue": 42}, - "E": {"userEnteredValue": False, "formattedValue": False}, - } - - assert process_row(row, 0) == expected - - -def test_process_row_with_start_index(): - row = { - "values": [ - {"userEnteredValue": {"stringValue": "x"}, "formattedValue": "x"}, - {"userEnteredValue": {"formulaValue": "=C1+D4"}, "formattedValue": "$10.00"}, - ] - } - expected = { - "C": {"userEnteredValue": "x", "formattedValue": "x"}, - "D": {"userEnteredValue": "=C1+D4", "formattedValue": "$10.00"}, - } - - assert process_row(row, 2) == expected - - -def test_convert_api_grid_data_to_dict_single_grid(): - data = [ - { - "startRow": 0, - "startColumn": 0, - "rowData": [ - { - "values": [ - {"userEnteredValue": {"stringValue": "A1"}, "formattedValue": "A1"}, - {"userEnteredValue": {"numberValue": 1}, "formattedValue": 1}, - ] - }, - { - "values": [ - {"userEnteredValue": {"stringValue": "A2"}, "formattedValue": "A2"}, - {"userEnteredValue": {"numberValue": 2}, "formattedValue": 2}, - ] - }, - { - "values": [ - {"userEnteredValue": {}}, - { - "userEnteredValue": {"stringValue": "ignored"}, - "formattedValue": "ignored", - }, - {"userEnteredValue": {"numberValue": 3}, "formattedValue": 3}, - ] - }, - ], - } - ] - result = convert_api_grid_data_to_dict(data) - expected = { - 1: { - "A": {"userEnteredValue": "A1", "formattedValue": "A1"}, - "B": {"userEnteredValue": 1, "formattedValue": 1}, - }, - 2: { - "A": {"userEnteredValue": "A2", "formattedValue": "A2"}, - "B": {"userEnteredValue": 2, "formattedValue": 2}, - }, - 3: { - "B": {"userEnteredValue": "ignored", "formattedValue": "ignored"}, - "C": {"userEnteredValue": 3, "formattedValue": 3}, - }, - } - - assert result == expected - - -def test_convert_api_grid_data_to_dict_multiple_grids(): - data = [ - { - "startRow": 5, - "startColumn": 1, - "rowData": [ - { - "values": [ - {"userEnteredValue": {"numberValue": 100}, "formattedValue": 100}, - {"userEnteredValue": {"stringValue": "=SUM(A1:A2)"}, "formattedValue": 23}, - ] - } - ], - }, - { - "startRow": 0, - "startColumn": 0, - "rowData": [ - { - "values": [ - {"userEnteredValue": {"stringValue": "First"}, "formattedValue": "First"}, - {"userEnteredValue": {"numberValue": 10}, "formattedValue": 10}, - ] - } - ], - }, - ] - result = convert_api_grid_data_to_dict(data) - expected = { - 1: { - "A": {"userEnteredValue": "First", "formattedValue": "First"}, - "B": {"userEnteredValue": 10, "formattedValue": 10}, - }, - 6: { - "B": {"userEnteredValue": 100, "formattedValue": 100}, - "C": {"userEnteredValue": "=SUM(A1:A2)", "formattedValue": 23}, - }, - } - - assert result == expected - - -def test_convert_api_grid_data_to_dict_empty_rows(): - data = [ - { - "startRow": 10, - "startColumn": 0, - "rowData": [ - {"values": [{"userEnteredValue": {}, "formattedValue": ""}]}, - {"values": []}, - ], - } - ] - result = convert_api_grid_data_to_dict(data) - expected = {} - - assert result == expected - - -FAKE_SHEET_RESPONSE = { - "sheets": [ - {"properties": {"title": "Sheet1", "gridProperties": {"rowCount": 10, "columnCount": 6}}} - ] -} - - -@patch("arcade_google_sheets.utils.build_sheets_service") -def test_validate_write_to_cell_params_valid(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="Sheet1", - column="B", - row=5, - ) - - -@patch("arcade_google_sheets.utils.build_sheets_service") -def test_validate_write_to_cell_params_invalid_sheet_name(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - with pytest.raises(RetryableToolError) as excinfo: - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="NonExistentSheet", - column="A", - row=5, - ) - assert "Sheet name NonExistentSheet not found" in str(excinfo.value) - - -@patch("arcade_google_sheets.utils.build_sheets_service") -def test_validate_write_to_cell_params_row_out_of_bounds(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - out_of_bounds_row = 15 - with pytest.raises(ToolExecutionError) as excinfo: - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="Sheet1", - column="A", - row=out_of_bounds_row, - ) - assert f"Row {out_of_bounds_row} is out of bounds" in str(excinfo.value) - - -@patch("arcade_google_sheets.utils.build_sheets_service") -def test_validate_write_to_cell_params_column_out_of_bounds(mock_build): - mock_service = MagicMock() - mock_service.spreadsheets().get().execute.return_value = FAKE_SHEET_RESPONSE - mock_build.return_value = mock_service - - service = mock_build("dummy_token") - - out_of_bounds_column = "Z" - with pytest.raises(ToolExecutionError) as excinfo: - validate_write_to_cell_params( - service=service, - spreadsheet_id="dummy_id", - sheet_name="Sheet1", - column=out_of_bounds_column, - row=5, - ) - assert f"Column {out_of_bounds_column} is out of bounds" in str(excinfo.value) diff --git a/toolkits/google_shopping/.pre-commit-config.yaml b/toolkits/google_shopping/.pre-commit-config.yaml deleted file mode 100644 index cbf1287c..00000000 --- a/toolkits/google_shopping/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/google_shopping/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/google_shopping/.ruff.toml b/toolkits/google_shopping/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/google_shopping/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/google_shopping/LICENSE b/toolkits/google_shopping/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/google_shopping/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/google_shopping/Makefile b/toolkits/google_shopping/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/google_shopping/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/google_shopping/arcade_google_shopping/__init__.py b/toolkits/google_shopping/arcade_google_shopping/__init__.py deleted file mode 100644 index 27cf60fb..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_shopping.tools import search_products - -__all__ = ["search_products"] diff --git a/toolkits/google_shopping/arcade_google_shopping/constants.py b/toolkits/google_shopping/arcade_google_shopping/constants.py deleted file mode 100644 index 600b7307..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/constants.py +++ /dev/null @@ -1,10 +0,0 @@ -import os - -DEFAULT_GOOGLE_LANGUAGE = os.getenv("ARCADE_GOOGLE_LANGUAGE", "en") -DEFAULT_GOOGLE_COUNTRY = os.getenv("ARCADE_GOOGLE_COUNTRY") -DEFAULT_GOOGLE_SHOPPING_LANGUAGE = os.getenv( - "ARCADE_GOOGLE_SHOPPING_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE -) -DEFAULT_GOOGLE_SHOPPING_COUNTRY = os.getenv( - "ARCADE_GOOGLE_SHOPPING_COUNTRY", DEFAULT_GOOGLE_COUNTRY -) diff --git a/toolkits/google_shopping/arcade_google_shopping/exceptions.py b/toolkits/google_shopping/arcade_google_shopping/exceptions.py deleted file mode 100644 index 3c039160..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -from arcade_tdk.errors import RetryableToolError - -from arcade_google_shopping.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -class GoogleRetryableError(RetryableToolError): - pass - - -class CountryNotFoundError(GoogleRetryableError): - def __init__(self, country: str | None) -> None: - valid_countries = json.dumps(COUNTRY_CODES, default=str) - message = f"Country not found: '{country}'." - additional_message = f"Valid countries are: {valid_countries}" - super().__init__(message, additional_prompt_content=additional_message) - - -class LanguageNotFoundError(GoogleRetryableError): - def __init__(self, language: str | None) -> None: - valid_languages = json.dumps(LANGUAGE_CODES, default=str) - message = f"Language not found: '{language}'." - additional_message = f"Valid languages are: {valid_languages}" - super().__init__(message, additional_prompt_content=additional_message) diff --git a/toolkits/google_shopping/arcade_google_shopping/google_data.py b/toolkits/google_shopping/arcade_google_shopping/google_data.py deleted file mode 100644 index 8fff7a1e..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/google_data.py +++ /dev/null @@ -1,468 +0,0 @@ -COUNTRY_CODES = { - "af": "Afghanistan", - "al": "Albania", - "dz": "Algeria", - "as": "American Samoa", - "ad": "Andorra", - "ao": "Angola", - "ai": "Anguilla", - "aq": "Antarctica", - "ag": "Antigua and Barbuda", - "ar": "Argentina", - "am": "Armenia", - "aw": "Aruba", - "au": "Australia", - "at": "Austria", - "az": "Azerbaijan", - "bs": "Bahamas", - "bh": "Bahrain", - "bd": "Bangladesh", - "bb": "Barbados", - "by": "Belarus", - "be": "Belgium", - "bz": "Belize", - "bj": "Benin", - "bm": "Bermuda", - "bt": "Bhutan", - "bo": "Bolivia", - "ba": "Bosnia and Herzegovina", - "bw": "Botswana", - "bv": "Bouvet Island", - "br": "Brazil", - "io": "British Indian Ocean Territory", - "bn": "Brunei Darussalam", - "bg": "Bulgaria", - "bf": "Burkina Faso", - "bi": "Burundi", - "kh": "Cambodia", - "cm": "Cameroon", - "ca": "Canada", - "cv": "Cape Verde", - "ky": "Cayman Islands", - "cf": "Central African Republic", - "td": "Chad", - "cl": "Chile", - "cn": "China", - "cx": "Christmas Island", - "cc": "Cocos (Keeling) Islands", - "co": "Colombia", - "km": "Comoros", - "cg": "Congo", - "cd": "Congo, the Democratic Republic of the", - "ck": "Cook Islands", - "cr": "Costa Rica", - "ci": "Cote D'ivoire", - "hr": "Croatia", - "cu": "Cuba", - "cy": "Cyprus", - "cz": "Czech Republic", - "dk": "Denmark", - "dj": "Djibouti", - "dm": "Dominica", - "do": "Dominican Republic", - "ec": "Ecuador", - "eg": "Egypt", - "sv": "El Salvador", - "gq": "Equatorial Guinea", - "er": "Eritrea", - "ee": "Estonia", - "et": "Ethiopia", - "fk": "Falkland Islands (Malvinas)", - "fo": "Faroe Islands", - "fj": "Fiji", - "fi": "Finland", - "fr": "France", - "gf": "French Guiana", - "pf": "French Polynesia", - "tf": "French Southern Territories", - "ga": "Gabon", - "gm": "Gambia", - "ge": "Georgia", - "de": "Germany", - "gh": "Ghana", - "gi": "Gibraltar", - "gr": "Greece", - "gl": "Greenland", - "gd": "Grenada", - "gp": "Guadeloupe", - "gu": "Guam", - "gt": "Guatemala", - "gg": "Guernsey", - "gn": "Guinea", - "gw": "Guinea-Bissau", - "gy": "Guyana", - "ht": "Haiti", - "hm": "Heard Island and Mcdonald Islands", - "va": "Holy See (Vatican City State)", - "hn": "Honduras", - "hk": "Hong Kong", - "hu": "Hungary", - "is": "Iceland", - "in": "India", - "id": "Indonesia", - "ir": "Iran, Islamic Republic of", - "iq": "Iraq", - "ie": "Ireland", - "im": "Isle of Man", - "il": "Israel", - "it": "Italy", - "je": "Jersey", - "jm": "Jamaica", - "jp": "Japan", - "jo": "Jordan", - "kz": "Kazakhstan", - "ke": "Kenya", - "ki": "Kiribati", - "kp": "Korea, Democratic People's Republic of", - "kr": "Korea, Republic of", - "kw": "Kuwait", - "kg": "Kyrgyzstan", - "la": "Lao People's Democratic Republic", - "lv": "Latvia", - "lb": "Lebanon", - "ls": "Lesotho", - "lr": "Liberia", - "ly": "Libyan Arab Jamahiriya", - "li": "Liechtenstein", - "lt": "Lithuania", - "lu": "Luxembourg", - "mo": "Macao", - "mk": "Macedonia, the Former Yugosalv Republic of", - "mg": "Madagascar", - "mw": "Malawi", - "my": "Malaysia", - "mv": "Maldives", - "ml": "Mali", - "mt": "Malta", - "mh": "Marshall Islands", - "mq": "Martinique", - "mr": "Mauritania", - "mu": "Mauritius", - "yt": "Mayotte", - "mx": "Mexico", - "fm": "Micronesia, Federated States of", - "md": "Moldova, Republic of", - "mc": "Monaco", - "mn": "Mongolia", - "me": "Montenegro", - "ms": "Montserrat", - "ma": "Morocco", - "mz": "Mozambique", - "mm": "Myanmar", - "na": "Namibia", - "nr": "Nauru", - "np": "Nepal", - "nl": "Netherlands", - "an": "Netherlands Antilles", - "nc": "New Caledonia", - "nz": "New Zealand", - "ni": "Nicaragua", - "ne": "Niger", - "ng": "Nigeria", - "nu": "Niue", - "nf": "Norfolk Island", - "mp": "Northern Mariana Islands", - "no": "Norway", - "om": "Oman", - "pk": "Pakistan", - "pw": "Palau", - "ps": "Palestinian Territory, Occupied", - "pa": "Panama", - "pg": "Papua New Guinea", - "py": "Paraguay", - "pe": "Peru", - "ph": "Philippines", - "pn": "Pitcairn", - "pl": "Poland", - "pt": "Portugal", - "pr": "Puerto Rico", - "qa": "Qatar", - "re": "Reunion", - "ro": "Romania", - "ru": "Russian Federation", - "rw": "Rwanda", - "sh": "Saint Helena", - "kn": "Saint Kitts and Nevis", - "lc": "Saint Lucia", - "pm": "Saint Pierre and Miquelon", - "vc": "Saint Vincent and the Grenadines", - "ws": "Samoa", - "sm": "San Marino", - "st": "Sao Tome and Principe", - "sa": "Saudi Arabia", - "sn": "Senegal", - "rs": "Serbia", - "sc": "Seychelles", - "sl": "Sierra Leone", - "sg": "Singapore", - "sk": "Slovakia", - "si": "Slovenia", - "sb": "Solomon Islands", - "so": "Somalia", - "za": "South Africa", - "gs": "South Georgia and the South Sandwich Islands", - "es": "Spain", - "lk": "Sri Lanka", - "sd": "Sudan", - "sr": "Suriname", - "sj": "Svalbard and Jan Mayen", - "sz": "Swaziland", - "se": "Sweden", - "ch": "Switzerland", - "sy": "Syrian Arab Republic", - "tw": "Taiwan, Province of China", - "tj": "Tajikistan", - "tz": "Tanzania, United Republic of", - "th": "Thailand", - "tl": "Timor-Leste", - "tg": "Togo", - "tk": "Tokelau", - "to": "Tonga", - "tt": "Trinidad and Tobago", - "tn": "Tunisia", - "tr": "Turkiye", - "tm": "Turkmenistan", - "tc": "Turks and Caicos Islands", - "tv": "Tuvalu", - "ug": "Uganda", - "ua": "Ukraine", - "ae": "United Arab Emirates", - "uk": "United Kingdom", - "gb": "United Kingdom", - "us": "United States", - "um": "United States Minor Outlying Islands", - "uy": "Uruguay", - "uz": "Uzbekistan", - "vu": "Vanuatu", - "ve": "Venezuela", - "vn": "Viet Nam", - "vg": "Virgin Islands, British", - "vi": "Virgin Islands, U.S.", - "wf": "Wallis and Futuna", - "eh": "Western Sahara", - "ye": "Yemen", - "zm": "Zambia", - "zw": "Zimbabwe", -} - - -LANGUAGE_CODES = { - "ar": "Arabic", - "bn": "Bengali", - "da": "Danish", - "de": "German", - "el": "Greek", - "en": "English", - "es": "Spanish", - "fi": "Finnish", - "fr": "French", - "hi": "Hindi", - "hu": "Hungarian", - "id": "Indonesian", - "it": "Italian", - "ja": "Japanese", - "ko": "Korean", - "nl": "Dutch", - "ms": "Malay", - "no": "Norwegian", - "pcm": "Nigerian Pidgin", - "pl": "Polish", - "pt": "Portuguese", - "pt-br": "Portuguese (Brazil)", - "pt-pt": "Portuguese (Portugal)", - "ru": "Russian", - "sv": "Swedish", - "tl": "Filipino", - "tr": "Turkish", - "uk": "Ukrainian", - "zh": "Chinese", - "zh-cn": "Chinese (Simplified)", - "zh-tw": "Chinese (Traditional)", -} - -GOOGLE_DOMAIN_BY_COUNTRY_CODE = { - "ad": "google.ad", - "ae": "google.ae", - "al": "google.al", - "am": "google.am", - "as": "google.as", - "at": "google.at", - "az": "google.az", - "ba": "google.ba", - "be": "google.be", - "bf": "google.bf", - "bg": "google.bg", - "bi": "google.bi", - "bj": "google.bj", - "bs": "google.bs", - "bt": "google.bt", - "by": "google.by", - "ca": "google.ca", - "cg": "google.cg", - "cf": "google.cf", - "ch": "google.ch", - "ci": "google.ci", - "cl": "google.cl", - "cm": "google.cm", - "ao": "google.co.ao", - "bw": "google.co.bw", - "ck": "google.co.ck", - "cr": "google.co.cr", - "id": "google.co.id", - "il": "google.co.il", - "in": "google.co.in", - "jp": "google.co.jp", - "ke": "google.co.ke", - "kr": "google.co.kr", - "ls": "google.co.ls", - "ma": "google.co.ma", - "mz": "google.co.mz", - "nz": "google.co.nz", - "th": "google.co.th", - "tz": "google.co.tz", - "ug": "google.co.ug", - "uk": "google.co.uk", - "uz": "google.co.uz", - "ve": "google.co.ve", - "vi": "google.co.vi", - "za": "google.co.za", - "zm": "google.co.zm", - "zw": "google.co.zw", - "us": "google.com", - "af": "google.com.af", - "ag": "google.com.ag", - "ai": "google.com.ai", - "ar": "google.com.ar", - "au": "google.com.au", - "bd": "google.com.bd", - "bh": "google.com.bh", - "bn": "google.com.bn", - "bo": "google.com.bo", - "br": "google.com.br", - "bz": "google.com.bz", - "co": "google.com.co", - "cu": "google.com.cu", - "cy": "google.com.cy", - "do": "google.com.do", - "ec": "google.com.ec", - "eg": "google.com.eg", - "et": "google.com.et", - "fj": "google.com.fj", - "gh": "google.com.gh", - "gi": "google.com.gi", - "gt": "google.com.gt", - "hk": "google.com.hk", - "jm": "google.com.jm", - "kh": "google.com.kh", - "kw": "google.com.kw", - "lb": "google.com.lb", - "ly": "google.com.ly", - "mm": "google.com.mm", - "mt": "google.com.mt", - "mx": "google.com.mx", - "my": "google.com.my", - "na": "google.com.na", - "ng": "google.com.ng", - "ni": "google.com.ni", - "np": "google.com.np", - "om": "google.com.om", - "pa": "google.com.pa", - "pe": "google.com.pe", - "pg": "google.com.pg", - "ph": "google.com.ph", - "pk": "google.com.pk", - "pr": "google.com.pr", - "py": "google.com.py", - "qa": "google.com.qa", - "sa": "google.com.sa", - "sb": "google.com.sb", - "sg": "google.com.sg", - "sl": "google.com.sl", - "sv": "google.com.sv", - "tj": "google.com.tj", - "tr": "google.com.tr", - "tw": "google.com.tw", - "ua": "google.com.ua", - "uy": "google.com.uy", - "vc": "google.com.vc", - "vn": "google.com.vn", - "cv": "google.cv", - "cz": "google.cz", - "de": "google.de", - "dj": "google.dj", - "dk": "google.dk", - "dm": "google.dm", - "dz": "google.dz", - "ee": "google.ee", - "es": "google.es", - "fi": "google.fi", - "fm": "google.fm", - "fr": "google.fr", - "ga": "google.ga", - "ge": "google.ge", - "gl": "google.gl", - "gm": "google.gm", - "gp": "google.gp", - "gr": "google.gr", - "gy": "google.gy", - "hn": "google.hn", - "hr": "google.hr", - "ht": "google.ht", - "hu": "google.hu", - "ie": "google.ie", - "iq": "google.iq", - "is": "google.is", - "it": "google.it", - "je": "google.je", - "jo": "google.jo", - "kg": "google.kg", - "ki": "google.ki", - "kz": "google.kz", - "la": "google.la", - "li": "google.li", - "lk": "google.lk", - "lt": "google.lt", - "lu": "google.lu", - "lv": "google.lv", - "md": "google.md", - "mg": "google.mg", - "mk": "google.mk", - "ml": "google.ml", - "mn": "google.mn", - "ms": "google.ms", - "mu": "google.mu", - "mv": "google.mv", - "mw": "google.mw", - "ne": "google.ne", - "nl": "google.nl", - "no": "google.no", - "nr": "google.nr", - "nu": "google.nu", - "pl": "google.pl", - "ps": "google.ps", - "pt": "google.pt", - "ro": "google.ro", - "rs": "google.rs", - "ru": "google.ru", - "rw": "google.rw", - "sc": "google.sc", - "se": "google.se", - "sh": "google.sh", - "si": "google.si", - "sk": "google.sk", - "sm": "google.sm", - "sn": "google.sn", - "so": "google.so", - "sr": "google.sr", - "td": "google.td", - "tg": "google.tg", - "tk": "google.tk", - "tl": "google.tl", - "tm": "google.tm", - "tn": "google.tn", - "to": "google.to", - "tt": "google.tt", - "vg": "google.vg", - "vu": "google.vu", - "ws": "google.ws", -} diff --git a/toolkits/google_shopping/arcade_google_shopping/tools/__init__.py b/toolkits/google_shopping/arcade_google_shopping/tools/__init__.py deleted file mode 100644 index a6d408ed..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_google_shopping.tools.google_shopping import search_products - -__all__ = ["search_products"] diff --git a/toolkits/google_shopping/arcade_google_shopping/tools/google_shopping.py b/toolkits/google_shopping/arcade_google_shopping/tools/google_shopping.py deleted file mode 100644 index 0a7e18c0..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/tools/google_shopping.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import ToolExecutionError - -from arcade_google_shopping.constants import ( - DEFAULT_GOOGLE_SHOPPING_COUNTRY, - DEFAULT_GOOGLE_SHOPPING_LANGUAGE, -) -from arcade_google_shopping.google_data import GOOGLE_DOMAIN_BY_COUNTRY_CODE -from arcade_google_shopping.utils import ( - call_serpapi, - extract_shopping_results, - prepare_params, - resolve_country_code, - resolve_language_code, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_products( - context: ToolContext, - keywords: Annotated[ - str, - "Keywords to search for products in Google Shopping. E.g. 'Apple iPhone'.", - ], - country_code: Annotated[ - str | None, - "2-character country code to search for products in Google Shopping. " - f"E.g. 'us' (United States). Defaults to '{DEFAULT_GOOGLE_SHOPPING_COUNTRY or 'us'}'.", - ] = DEFAULT_GOOGLE_SHOPPING_COUNTRY, - language_code: Annotated[ - str | None, - "2-character language code to search for products on Google Shopping. E.g. 'en' (English). " - f"Defaults to '{DEFAULT_GOOGLE_SHOPPING_LANGUAGE or 'en'}'.", - ] = DEFAULT_GOOGLE_SHOPPING_LANGUAGE, -) -> Annotated[dict[str, list[dict[str, Any]]], "Products on Google Shopping."]: - """Search for products on Google Shopping related to a given query.""" - country_code = resolve_country_code(country_code, DEFAULT_GOOGLE_SHOPPING_COUNTRY) - language_code = resolve_language_code(language_code, DEFAULT_GOOGLE_SHOPPING_LANGUAGE) - - if not isinstance(country_code, str): - country_code = "us" - - if not isinstance(language_code, str): - language_code = "en" - - google_domain = GOOGLE_DOMAIN_BY_COUNTRY_CODE.get(country_code, "google.com") - - params = prepare_params( - "google_shopping", - q=keywords, - gl=country_code, - hl=language_code, - google_domain=google_domain, - ) - - response = call_serpapi(context, params) - - if response.get("error"): - error_msg = response.get("error") or "Unknown Google Shopping Error" - raise ToolExecutionError(error_msg) - - return { - "products": extract_shopping_results(response.get("shopping_results", [])), - } diff --git a/toolkits/google_shopping/arcade_google_shopping/utils.py b/toolkits/google_shopping/arcade_google_shopping/utils.py deleted file mode 100644 index 9ef8433d..00000000 --- a/toolkits/google_shopping/arcade_google_shopping/utils.py +++ /dev/null @@ -1,117 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - -from arcade_google_shopping.constants import ( - DEFAULT_GOOGLE_COUNTRY, - DEFAULT_GOOGLE_LANGUAGE, -) -from arcade_google_shopping.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_google_shopping.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -def default_language_code(default_service_language_code: str | None = None) -> str | None: - if isinstance(default_service_language_code, str): - return default_service_language_code.lower() - elif isinstance(DEFAULT_GOOGLE_LANGUAGE, str): - return DEFAULT_GOOGLE_LANGUAGE.lower() - return None - - -def default_country_code(default_service_country_code: str | None = None) -> str | None: - if isinstance(default_service_country_code, str): - return default_service_country_code.lower() - elif isinstance(DEFAULT_GOOGLE_COUNTRY, str): - return DEFAULT_GOOGLE_COUNTRY.lower() - return None - - -def resolve_language_code( - language_code: str | None = None, - default_service_language_code: str | None = None, -) -> str | None: - language_code = language_code or default_language_code(default_service_language_code) - - if isinstance(language_code, str): - language_code = language_code.lower() - if language_code not in LANGUAGE_CODES: - raise LanguageNotFoundError(language_code) - - return language_code - - -def resolve_country_code( - country_code: str | None = None, - default_service_country_code: str | None = None, -) -> str | None: - country_code = country_code or default_country_code(default_service_country_code) - - if isinstance(country_code, str): - country_code = country_code.lower() - if country_code not in COUNTRY_CODES: - raise CountryNotFoundError(country_code) - - return country_code - - -def extract_shopping_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - { - "title": result.get("title"), - "direct_link": result.get("link"), - "google_link": result.get("product_link"), - "source": result.get("source"), - "price": result.get("price"), - "product_rating": result.get("rating"), - "product_reviews": result.get("reviews"), - "store_rating": result.get("store_rating"), - "store_reviews": result.get("store_reviews"), - "delivery": result.get("delivery"), - } - for result in results - ] diff --git a/toolkits/google_shopping/pyproject.toml b/toolkits/google_shopping/pyproject.toml deleted file mode 100644 index 787c6ea5..00000000 --- a/toolkits/google_shopping/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_google_shopping" -version = "2.0.0" -description = "Arcade.dev LLM tools for shopping via Google Shopping" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "serpapi>=0.1.5,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_google_shopping/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_google_shopping",] diff --git a/toolkits/hubspot/.pre-commit-config.yaml b/toolkits/hubspot/.pre-commit-config.yaml deleted file mode 100644 index e6805917..00000000 --- a/toolkits/hubspot/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/hubspot/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/hubspot/.ruff.toml b/toolkits/hubspot/.ruff.toml deleted file mode 100644 index bacd9161..00000000 --- a/toolkits/hubspot/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py39" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/hubspot/LICENSE b/toolkits/hubspot/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/hubspot/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/hubspot/Makefile b/toolkits/hubspot/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/hubspot/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/hubspot/arcade_hubspot/__init__.py b/toolkits/hubspot/arcade_hubspot/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/hubspot/arcade_hubspot/constants.py b/toolkits/hubspot/arcade_hubspot/constants.py deleted file mode 100644 index 95301000..00000000 --- a/toolkits/hubspot/arcade_hubspot/constants.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - -HUBSPOT_BASE_URL = "https://api.hubapi.com" -HUBSPOT_CRM_BASE_URL = f"{HUBSPOT_BASE_URL}/crm" -HUBSPOT_DEFAULT_API_VERSION = "v3" - -try: - HUBSPOT_MAX_CONCURRENT_REQUESTS = int(os.getenv("HUBSPOT_MAX_CONCURRENT_REQUESTS", 3)) -except ValueError: - HUBSPOT_MAX_CONCURRENT_REQUESTS = 3 - -GLOBALLY_IGNORED_FIELDS = [ - "createdate", - "hs_createdate", - "hs_lastmodifieddate", - "lastmodifieddate", -] diff --git a/toolkits/hubspot/arcade_hubspot/custom_critics.py b/toolkits/hubspot/arcade_hubspot/custom_critics.py deleted file mode 100644 index a438374a..00000000 --- a/toolkits/hubspot/arcade_hubspot/custom_critics.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Any - -from arcade_evals import BinaryCritic - - -class ValueInListCritic(BinaryCritic): - def evaluate(self, expected: list[Any], actual: Any) -> dict[str, float | bool]: - match = actual in expected - return {"match": match, "score": self.weight if match else 0.0} diff --git a/toolkits/hubspot/arcade_hubspot/enums.py b/toolkits/hubspot/arcade_hubspot/enums.py deleted file mode 100644 index fcc7383d..00000000 --- a/toolkits/hubspot/arcade_hubspot/enums.py +++ /dev/null @@ -1,20 +0,0 @@ -from enum import Enum - - -class HubspotObject(Enum): - ACCOUNT = "account" - CALL = "call" - COMMUNICATION = "communication" - COMPANY = "company" - CONTACT = "contact" - DEAL = "deal" - EMAIL = "email" - MEETING = "meeting" - NOTE = "note" - TASK = "task" - - @property - def plural(self) -> str: - if self.value == "company": - return "companies" - return f"{self.value}s" diff --git a/toolkits/hubspot/arcade_hubspot/exceptions.py b/toolkits/hubspot/arcade_hubspot/exceptions.py deleted file mode 100644 index 1f858cce..00000000 --- a/toolkits/hubspot/arcade_hubspot/exceptions.py +++ /dev/null @@ -1,9 +0,0 @@ -from arcade_tdk.errors import ToolExecutionError - - -class HubspotToolExecutionError(ToolExecutionError): - pass - - -class NotFoundError(HubspotToolExecutionError): - pass diff --git a/toolkits/hubspot/arcade_hubspot/models.py b/toolkits/hubspot/arcade_hubspot/models.py deleted file mode 100644 index 34bbcc35..00000000 --- a/toolkits/hubspot/arcade_hubspot/models.py +++ /dev/null @@ -1,228 +0,0 @@ -import asyncio -import json -from dataclasses import dataclass -from typing import Any, Optional, cast - -import httpx - -from arcade_hubspot.constants import ( - HUBSPOT_CRM_BASE_URL, - HUBSPOT_DEFAULT_API_VERSION, - HUBSPOT_MAX_CONCURRENT_REQUESTS, -) -from arcade_hubspot.enums import HubspotObject -from arcade_hubspot.exceptions import HubspotToolExecutionError, NotFoundError -from arcade_hubspot.properties import get_object_properties -from arcade_hubspot.utils import clean_data, prepare_api_search_response, remove_none_values - - -@dataclass -class HubspotCrmClient: - auth_token: str - base_url: str = HUBSPOT_CRM_BASE_URL - max_concurrent_requests: int = HUBSPOT_MAX_CONCURRENT_REQUESTS - _semaphore: asyncio.Semaphore | None = None - - def __post_init__(self) -> None: - self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests) - - def _raise_for_status(self, response: httpx.Response) -> None: - if response.status_code < 300: - return - - try: - data = response.json() - error_message = data["message"] - developer_message = json.dumps(data["errors"]) - except Exception: - error_message = response.text - developer_message = None - - if response.status_code == 404: - raise NotFoundError(error_message, developer_message) - - raise HubspotToolExecutionError(error_message, developer_message) - - async def get( - self, - endpoint: str, - params: Optional[dict] = None, - headers: Optional[dict] = None, - api_version: str = HUBSPOT_DEFAULT_API_VERSION, - ) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - - kwargs = { - "url": f"{self.base_url}/{api_version}/{endpoint}", - "headers": headers, - } - - if isinstance(params, dict): - kwargs["params"] = params - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.get(**kwargs) # type: ignore[arg-type] - self._raise_for_status(response) - return cast(dict, response.json()) - - async def post( - self, - endpoint: str, - data: Optional[dict] = None, - json_data: Optional[dict] = None, - headers: Optional[dict] = None, - api_version: str = HUBSPOT_DEFAULT_API_VERSION, - ) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - headers["Content-Type"] = "application/json" - - kwargs = { - "url": f"{self.base_url}/{api_version}/{endpoint}", - "headers": headers, - } - - if data and json_data: - raise ValueError("Cannot provide both data and json_data") - - if data: - kwargs["data"] = data - - elif json_data: - kwargs["json"] = json_data - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.post(**kwargs) # type: ignore[arg-type] - self._raise_for_status(response) - return cast(dict, response.json()) - - async def get_associated_objects( - self, - parent_object: HubspotObject, - parent_id: str, - associated_object: HubspotObject, - limit: int = 10, - after: Optional[str] = None, - properties: Optional[list[str]] = None, - ) -> list[dict]: - endpoint = ( - f"objects/{parent_object.value}/{parent_id}/associations/{associated_object.value}" - ) - params = { - "limit": limit, - } - if after: - params["after"] = after # type: ignore[assignment] - - response = await self.get(endpoint, params=params, api_version="v4") - - if not response["results"]: - return [] - - return await self.batch_get_objects( - object_type=associated_object, - object_ids=[object_data["toObjectId"] for object_data in response["results"]], - properties=properties or get_object_properties(associated_object), - ) - - async def get_object_by_id( - self, - object_type: HubspotObject, - object_id: str, - properties: Optional[list[str]] = None, - ) -> dict: - endpoint = f"objects/{object_type.plural}/{object_id}" - params = {} - if properties: - params["properties"] = properties - return clean_data(await self.get(endpoint, params=params), object_type) - - async def batch_get_objects( - self, - object_type: HubspotObject, - object_ids: list[str], - properties: Optional[list[str]] = None, - ) -> list[dict]: - endpoint = f"objects/{object_type.plural}/batch/read" - data: dict[str, Any] = {"inputs": [{"id": object_id} for object_id in object_ids]} - if properties: - data["properties"] = properties - response = await self.post(endpoint, json_data=data) - return [clean_data(object_data, object_type) for object_data in response["results"]] - - async def search_by_keywords( - self, - object_type: HubspotObject, - keywords: str, - limit: int = 10, - next_page_token: Optional[str] = None, - associations: Optional[list[HubspotObject]] = None, - ) -> dict: - if not keywords: - raise HubspotToolExecutionError("`keywords` must be a non-empty string") - - associations = associations or [] - - endpoint = f"objects/{object_type.plural}/search" - request_data = { - "query": keywords, - "limit": limit, - "sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "DESCENDING"}], - "properties": get_object_properties(object_type), - } - - if next_page_token: - request_data["after"] = next_page_token - - data = prepare_api_search_response( - data=await self.post(endpoint, json_data=request_data), - object_type=object_type, - ) - - for object_ in data[object_type.plural]: - for association in associations: - results = await self.get_associated_objects( - parent_object=object_type, - parent_id=object_["id"], - associated_object=association, - limit=10, - ) - if results: - object_[association.plural] = results - - return data - - async def create_contact( - self, - company_id: str, - first_name: str, - last_name: Optional[str] = None, - email: Optional[str] = None, - phone: Optional[str] = None, - mobile_phone: Optional[str] = None, - job_title: Optional[str] = None, - ) -> dict: - request_data = { - "associations": [ - { - "types": [ - { - "associationCategory": "HUBSPOT_DEFINED", - "associationTypeId": "1", - } - ], - "to": {"id": company_id}, - }, - ], - "properties": remove_none_values({ - "firstname": first_name, - "lastname": last_name, - "email": email, - "phone": phone, - "mobilephone": mobile_phone, - "jobtitle": job_title, - }), - } - endpoint = "objects/contacts" - return await self.post(endpoint, json_data=request_data) diff --git a/toolkits/hubspot/arcade_hubspot/properties.py b/toolkits/hubspot/arcade_hubspot/properties.py deleted file mode 100644 index aa881bc2..00000000 --- a/toolkits/hubspot/arcade_hubspot/properties.py +++ /dev/null @@ -1,127 +0,0 @@ -from arcade_hubspot.enums import HubspotObject - -HUBSPOT_OBJECT_PROPERTIES = { - HubspotObject.COMPANY: [ - "type", - "name", - "about_us", - "address", - "city", - "state", - "zip", - "country", - "annualrevenue", - "hs_annual_revenue_currency_code", - "industry", - "phone", - "website", - "domain", - "numberofemployees", - "hs_lead_status", - "lifecyclestage", - "linkedin_company_page", - "twitterhandle", - ], - HubspotObject.CONTACT: [ - "salutation", - "email", - "work_email", - "hs_additional_emails", - "mobilephone", - "phone", - "firstname", - "lastname", - "lifecyclestage", - "annualrevenue", - "address", - "date_of_birth", - "degree", - "gender", - "buying_role", - "hs_language", - "hs_lead_status", - "hs_timezone", - "hubspot_owner_id", - "hubspot_owner_name", - "job_function", - "jobtitle", - "seniority", - "industry", - "twitterhandle", - ], - HubspotObject.CALL: [ - "hs_body_preview", - "hs_call_direction", - "hs_call_status", - "hs_call_summary", - "hs_call_title", - "hs_createdate", - "hubspot_owner_id", - "hs_call_disposition", - "hs_timestamp", - ], - HubspotObject.DEAL: [ - "dealname", - "dealstage", - "dealtype", - "closedate", - "closed_lost_reason", - "closed_won_reason", - "hs_is_closed_won", - "hs_is_closed_lost", - "hs_closed_amount", - "pipeline", - "hubspot_owner_id", - "description", - "hs_deal_score", - "amount", - "hs_forecast_probability", - "hs_forecast_amount", - ], - HubspotObject.EMAIL: [ - "hs_object_id", - "hs_body_preview", - "hs_email_sender_raw", - "hs_email_from_raw", - "hs_email_to_raw", - "hs_email_bcc_raw", - "hs_email_cc_raw", - "hs_email_subject", - "hs_email_status", - "hs_timestamp", - "hubspot_owner_id", - "hs_email_associated_contact_id", - ], - HubspotObject.MEETING: [ - "hs_object_id", - "hs_meeting_title", - "hs_body_preview", - "hs_meeting_location", - "hs_meeting_outcome", - "hubspot_owner_id", - "hs_meeting_start_time", - "hs_meeting_end_time", - ], - HubspotObject.NOTE: [ - "hs_object_id", - "hs_body_preview", - "hs_timestamp", - "hubspot_owner_id", - ], - HubspotObject.TASK: [ - "hs_object_id", - "hs_body_preview", - "hs_timestamp", - "hubspot_owner_id", - "hs_associated_contact_labels", - "hs_task_is_overdue", - "hs_task_priority", - "hs_task_status", - "hs_task_subject", - "hs_task_type", - ], -} - - -def get_object_properties(object_type: HubspotObject) -> list[str]: - return HUBSPOT_OBJECT_PROPERTIES[object_type] diff --git a/toolkits/hubspot/arcade_hubspot/tools/__init__.py b/toolkits/hubspot/arcade_hubspot/tools/__init__.py deleted file mode 100644 index 7b696b03..00000000 --- a/toolkits/hubspot/arcade_hubspot/tools/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from arcade_hubspot.tools.crm.companies import get_company_data_by_keywords -from arcade_hubspot.tools.crm.contacts import create_contact, get_contact_data_by_keywords - -__all__ = [ - "get_company_data_by_keywords", - "get_contact_data_by_keywords", - "create_contact", -] diff --git a/toolkits/hubspot/arcade_hubspot/tools/crm/companies.py b/toolkits/hubspot/arcade_hubspot/tools/crm/companies.py deleted file mode 100644 index b84e6759..00000000 --- a/toolkits/hubspot/arcade_hubspot/tools/crm/companies.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Annotated, Any, Optional - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Hubspot - -from arcade_hubspot.enums import HubspotObject -from arcade_hubspot.models import HubspotCrmClient - - -@tool( - requires_auth=Hubspot( - scopes=[ - "oauth", - "crm.objects.companies.read", - "crm.objects.contacts.read", - "crm.objects.deals.read", - "sales-email-read", - ], - ), -) -async def get_company_data_by_keywords( - context: ToolContext, - keywords: Annotated[ - str, - "The keywords to search for companies. It will match against the company name, phone, " - "and website.", - ], - limit: Annotated[ - int, "The maximum number of companies to return. Defaults to 10. Max is 10." - ] = 10, - next_page_token: Annotated[ - Optional[str], - "The token to get the next page of results. " - "Defaults to None (returns first page of results)", - ] = None, -) -> Annotated[ - dict[str, Any], - "Retrieve company data with associated contacts, deals, calls, emails, " - "meetings, notes, and tasks.", -]: - """Retrieve company data with associated contacts, deals, calls, emails, - meetings, notes, and tasks. - - This tool will return up to 10 items of each associated object (contacts, leads, etc). - """ - limit = min(limit, 10) - client = HubspotCrmClient(context.get_auth_token_or_empty()) - return await client.search_by_keywords( - object_type=HubspotObject.COMPANY, - keywords=keywords, - limit=limit, - next_page_token=next_page_token, - associations=[ - HubspotObject.CALL, - HubspotObject.CONTACT, - HubspotObject.DEAL, - HubspotObject.EMAIL, - HubspotObject.MEETING, - HubspotObject.NOTE, - HubspotObject.TASK, - ], - ) diff --git a/toolkits/hubspot/arcade_hubspot/tools/crm/contacts.py b/toolkits/hubspot/arcade_hubspot/tools/crm/contacts.py deleted file mode 100644 index 7459ed40..00000000 --- a/toolkits/hubspot/arcade_hubspot/tools/crm/contacts.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Annotated, Any, Optional - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Hubspot - -from arcade_hubspot.enums import HubspotObject -from arcade_hubspot.models import HubspotCrmClient -from arcade_hubspot.utils import clean_data - - -@tool( - requires_auth=Hubspot( - scopes=[ - "oauth", - "crm.objects.contacts.read", - ], - ), -) -async def get_contact_data_by_keywords( - context: ToolContext, - keywords: Annotated[ - str, - "The keywords to search for contacts. It will match against the contact's " - "first and last name, email addresses, phone numbers, and company name.", - ], - limit: Annotated[ - int, "The maximum number of contacts to return. Defaults to 10. Max is 100." - ] = 10, - next_page_token: Annotated[ - Optional[str], - "The token to get the next page of results. " - "Defaults to None (returns first page of results)", - ] = None, -) -> Annotated[ - dict[str, Any], - "Retrieve contact data with associated companies, deals, calls, " - "emails, meetings, notes, and tasks.", -]: - """ - Retrieve contact data with associated companies, deals, calls, emails, - meetings, notes, and tasks. - """ - limit = min(limit, 100) - client = HubspotCrmClient(context.get_auth_token_or_empty()) - return await client.search_by_keywords( - object_type=HubspotObject.CONTACT, - keywords=keywords, - limit=limit, - next_page_token=next_page_token, - associations=[ - HubspotObject.CALL, - HubspotObject.COMPANY, - HubspotObject.DEAL, - HubspotObject.EMAIL, - HubspotObject.MEETING, - HubspotObject.NOTE, - HubspotObject.TASK, - ], - ) - - -@tool( - requires_auth=Hubspot( - scopes=["oauth", "crm.objects.contacts.write"], - ), -) -async def create_contact( - context: ToolContext, - company_id: Annotated[str, "The ID of the company to create the contact for."], - first_name: Annotated[str, "The first name of the contact."], - last_name: Annotated[Optional[str], "The last name of the contact."] = None, - email: Annotated[Optional[str], "The email address of the contact."] = None, - phone: Annotated[Optional[str], "The phone number of the contact."] = None, - mobile_phone: Annotated[Optional[str], "The mobile phone number of the contact."] = None, - job_title: Annotated[Optional[str], "The job title of the contact."] = None, -) -> Annotated[dict, "Create a contact associated with a company."]: - """Create a contact associated with a company.""" - client = HubspotCrmClient(context.get_auth_token_or_empty()) - response = await client.create_contact( - company_id=company_id, - first_name=first_name, - last_name=last_name, - email=email, - phone=phone, - mobile_phone=mobile_phone, - job_title=job_title, - ) - - return clean_data(response, HubspotObject.CONTACT) diff --git a/toolkits/hubspot/arcade_hubspot/utils.py b/toolkits/hubspot/arcade_hubspot/utils.py deleted file mode 100644 index ccb864e7..00000000 --- a/toolkits/hubspot/arcade_hubspot/utils.py +++ /dev/null @@ -1,313 +0,0 @@ -from typing import Any, Callable - -from arcade_hubspot.constants import GLOBALLY_IGNORED_FIELDS -from arcade_hubspot.enums import HubspotObject - - -def remove_none_values(data: dict) -> dict: - cleaned: dict[str, Any] = {} - for key, value in data.items(): - if value is None or key in GLOBALLY_IGNORED_FIELDS: - continue - if isinstance(value, dict): - cleaned_dict = remove_none_values(value) - if cleaned_dict: - cleaned[key] = cleaned_dict - elif isinstance(value, (list, tuple, set)): - collection_type = type(value) - cleaned_list = [remove_none_values(item) for item in value] - if cleaned_list: - cleaned[key] = collection_type(cleaned_list) - else: - cleaned[key] = value - return cleaned - - -def prepare_api_search_response(data: dict, object_type: HubspotObject) -> dict: - response: dict[str, Any] = { - object_type.plural: [clean_data(company, object_type) for company in data["results"]], - } - - after = data.get("paging", {}).get("next", {}).get("after") - - if after: - response["pagination"] = { - "are_there_more_results?": True, - "next_page_token": after, - } - else: - response["pagination"] = { - "are_there_more_results?": False, - } - - return response - - -def rename_dict_keys(data: dict, rename: dict) -> dict: - for old_key, new_key in rename.items(): - if old_key in data: - data[new_key] = data[old_key] - data.pop(old_key, None) - return data - - -def global_cleaner(clean_func: Callable[[dict], dict]) -> Callable[[dict], dict]: - def global_cleaner(data: dict) -> dict: - cleaned_data: dict[str, Any] = {} - if "hs_object_id" in data: - cleaned_data["id"] = data["hs_object_id"] - del data["hs_object_id"] - - data = rename_dict_keys( - data, - { - "hubspot_owner_id": "owner_id", - "hs_timestamp": "datetime", - "hs_body_preview": "body", - }, - ) - - for key, value in data.items(): - if key in GLOBALLY_IGNORED_FIELDS or value is None: - continue - - if isinstance(value, dict): - cleaned_data[key] = global_cleaner(value) - - elif isinstance(value, (list, tuple, set)): - cleaned_items = [] - for item in value: - if isinstance(item, dict): - cleaned_items.append(global_cleaner(item)) - else: - cleaned_items.append(item) - cleaned_data[key] = cleaned_items - else: - cleaned_data[key] = value - return cleaned_data - - def wrapper(data: dict) -> dict: - return remove_none_values(global_cleaner(clean_func(data["properties"]))) - - return wrapper - - -def clean_data(data: dict, object_type: HubspotObject) -> dict: - _mapping = { - HubspotObject.CALL: clean_call_data, - HubspotObject.COMPANY: clean_company_data, - HubspotObject.CONTACT: clean_contact_data, - HubspotObject.DEAL: clean_deal_data, - HubspotObject.EMAIL: clean_email_data, - HubspotObject.MEETING: clean_meeting_data, - HubspotObject.NOTE: clean_note_data, - HubspotObject.TASK: clean_task_data, - } - try: - return _mapping[object_type](data) - except KeyError: - raise ValueError(f"Unsupported object type: {object_type}") - - -@global_cleaner -def clean_company_data(data: dict) -> dict: - data["object_type"] = HubspotObject.COMPANY.value - data["website"] = data.get("website", data.get("domain")) - data.pop("domain", None) - - data["address"] = { - "street": data.get("address"), - "city": data.get("city"), - "state": data.get("state"), - "zip": data.get("zip"), - "country": data.get("country"), - } - data.pop("address", None) - data.pop("city", None) - data.pop("state", None) - data.pop("zip", None) - data.pop("country", None) - - data["annual_revenue"] = { - "value": data.get("annualrevenue"), - "currency": data.get("hs_annual_revenue_currency_code"), - } - data.pop("annualrevenue", None) - data.pop("hs_annual_revenue_currency_code", None) - - rename = { - "linkedin_company_page": "linkedin_url", - "numberofemployees": "employee_count", - "type": "company_type", - "annualrevenue": "annual_revenue", - "lifecyclestage": "lifecycle_stage", - "hs_lead_status": "lead_status", - } - data = rename_dict_keys(data, rename) - return data - - -@global_cleaner -def clean_contact_data(data: dict) -> dict: - data["object_type"] = HubspotObject.CONTACT.value - rename = { - "lifecyclestage": "lifecycle_stage", - "hs_lead_status": "lead_status", - "email": "email_address", - } - data = rename_dict_keys(data, rename) - return data - - -@global_cleaner -def clean_deal_data(data: dict) -> dict: - data["object_type"] = HubspotObject.DEAL.value - - if data.get("closedate") or data.get("hs_closed_amount"): - data["close"] = { - "is_closed": data.get("hs_is_closed"), - "date": data.get("closedate"), - "amount": data.get("hs_closed_amount"), - } - - if data.get("hs_is_closed_won") in ["true", True]: - data["close"]["status"] = "won" - data["close"]["status_reason"] = data.get("closed_won_reason") - elif data.get("hs_is_closed_lost") in ["true", True]: - data["close"]["status"] = "lost" - data["close"]["status_reason"] = data.get("closed_lost_reason") - - if data.get("amount"): - data["amount"] = { - "value": data["amount"], - "currency": data.get("deal_currency_code"), - } - - if data.get("hs_forecast_probability"): - data["amount"]["forecast"] = { - "probability": data["hs_forecast_probability"], - "expected_value": data.get("hs_forecast_amount"), - } - - rename = { - "dealname": "name", - "dealstage": "stage", - "dealscore": "score", - "dealtype": "type", - } - data = rename_dict_keys(data, rename) - - data.pop("hs_is_closed", None) - data.pop("closedate", None) - data.pop("hs_closed_amount", None) - data.pop("deal_currency_code", None) - data.pop("close_won_reason", None) - data.pop("close_lost_reason", None) - data.pop("hs_is_closed_won", None) - data.pop("hs_is_closed_lost", None) - data.pop("hs_forecast_probability", None) - data.pop("hs_forecast_amount", None) - - return data - - -@global_cleaner -def clean_email_data(data: dict) -> dict: - data["object_type"] = HubspotObject.EMAIL.value - rename = { - "hs_body_preview": "body", - "hs_email_from_raw": "from", - "hs_email_to_raw": "to", - "hs_email_bcc_raw": "bcc", - "hs_email_cc_raw": "cc", - "hs_email_subject": "subject", - "hs_email_status": "status", - "hs_email_associated_contact_id": "contact_id", - } - data = rename_dict_keys(data, rename) - return data - - -@global_cleaner -def clean_call_data(data: dict) -> dict: - data["object_type"] = HubspotObject.CALL.value - rename = { - "hs_call_direction": "direction", - "hs_call_status": "status", - "hs_call_summary": "summary", - "hs_call_title": "title", - "hs_call_disposition": "outcome", - } - data = rename_dict_keys(data, rename) - return data - - -@global_cleaner -def clean_note_data(data: dict) -> dict: - data["object_type"] = HubspotObject.NOTE.value - return data - - -@global_cleaner -def clean_meeting_data(data: dict) -> dict: - data["object_type"] = HubspotObject.MEETING.value - rename = { - "hs_meeting_outcome": "outcome", - "hs_meeting_location": "location", - "hs_meeting_start_time": "start_time", - "hs_meeting_end_time": "end_time", - } - data = rename_dict_keys(data, rename) - - data["content"] = { - "title": data.get("hs_meeting_title"), - "body": data.get("hs_body_preview"), - } - - data.pop("hs_meeting_title", None) - data.pop("hs_body_preview", None) - - data["schedule"] = { - "start_time": data.get("start_time"), - "end_time": data.get("end_time"), - } - - data.pop("start_time", None) - data.pop("end_time", None) - - return data - - -@global_cleaner -def clean_task_data(data: dict) -> dict: - data["object_type"] = HubspotObject.TASK.value - - if data.get("hs_task_priority") == "NONE": - data["hs_task_priority"] = None - - rename = { - "hs_task_status": "status", - "hs_task_priority": "priority", - "hs_task_missed_due_date": "missed_due_date", - "hs_task_type": "task_type", - "hs_associated_contact_labels": "associated_contact", - } - data = rename_dict_keys(data, rename) - - data["content"] = { - "body": data.get("hs_body_preview"), - "subject": data.get("hs_task_subject"), - } - - data.pop("hs_body_preview", None) - data.pop("hs_task_subject", None) - - data["schedule"] = { - "datetime": data.get("hs_timestamp"), - "is_overdue": data.get("hs_task_is_overdue"), - } - - data.pop("hs_timestamp", None) - data.pop("hs_task_is_overdue", None) - - return data diff --git a/toolkits/hubspot/conftest.py b/toolkits/hubspot/conftest.py deleted file mode 100644 index 88c4d4b4..00000000 --- a/toolkits/hubspot/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_httpx_client(): - with patch("arcade_hubspot.models.httpx") as mock_httpx: - yield mock_httpx.AsyncClient().__aenter__.return_value diff --git a/toolkits/hubspot/evals/eval_crm_companies.py b/toolkits/hubspot/evals/eval_crm_companies.py deleted file mode 100644 index 89d2f573..00000000 --- a/toolkits/hubspot/evals/eval_crm_companies.py +++ /dev/null @@ -1,194 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_hubspot -from arcade_hubspot.tools import get_company_data_by_keywords - -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_hubspot) - - -@tool_eval() -def get_company_data_by_keywords_eval_suite() -> EvalSuite: - """Create an evaluation suite for the get_company_data_by_keywords tool.""" - suite = EvalSuite( - name="Get Company Data by Keywords", - system_message="You are an AI assistant that can interact with Hubspot CRM using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get company data by keywords", - user_message="Get information about the Acme company.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Get company data by keywords with limit", - user_message="Get information of up to 3 companies with the word 'Acme' in their name.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 3, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.6), - BinaryCritic(critic_field="limit", weight=0.3), - BinaryCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Get summary of company activity", - user_message="Get a summary of the latest activities in the Acme company.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Interactions with contacts of an account", - user_message="Get me the interactions with the contacts of the Acme company.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Emails or calls with contacts of an account", - user_message="Were there any emails or calls with contacts of the Acme company this week?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Get company status", - user_message="What's the status of the Acme company?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Get overdue tasks", - user_message="Are there any tasks overdue for the Acme company?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Get company closing likelihood", - user_message="What's the likelihood of the Acme company closing a deal?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_company_data_by_keywords, - args={ - "keywords": "Acme", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - return suite diff --git a/toolkits/hubspot/evals/eval_crm_contacts.py b/toolkits/hubspot/evals/eval_crm_contacts.py deleted file mode 100644 index f3abe0f2..00000000 --- a/toolkits/hubspot/evals/eval_crm_contacts.py +++ /dev/null @@ -1,197 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_hubspot -from arcade_hubspot.custom_critics import ValueInListCritic -from arcade_hubspot.tools import create_contact, get_contact_data_by_keywords - -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_hubspot) - - -@tool_eval() -def get_contact_data_by_keywords_eval_suite() -> EvalSuite: - """Create an evaluation suite for the get_contact_data_by_keywords tool.""" - suite = EvalSuite( - name="Get Contact Data by Keywords", - system_message="You are an AI assistant that can interact with Hubspot CRM using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get contact data by keywords", - user_message="Get information about the Jason Bourne contact.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_contact_data_by_keywords, - args={ - "keywords": "Jason Bourne", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Get contact data by keywords with limit", - user_message="Get information of up to 3 contacts with last name 'Bourne'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_contact_data_by_keywords, - args={ - "keywords": "Bourne", - "limit": 3, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.7), - BinaryCritic(critic_field="limit", weight=0.15), - BinaryCritic(critic_field="next_page_token", weight=0.15), - ], - ) - - suite.add_case( - name="Get summary of contact activity", - user_message="Get a summary of the latest activities with the Jason Bourne contact.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_contact_data_by_keywords, - args={ - "keywords": "Jason Bourne", - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Interactions with contacts of an account", - user_message="Get me the interactions with the Jason Bourne contact from Acme.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_contact_data_by_keywords, - args={ - "keywords": ["Jason Bourne", "Jason Bourne Acme"], - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - ValueInListCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Emails or calls with contacts of an account", - user_message="Were there any emails or calls with the Jason Bourne contact from Acme this week?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_contact_data_by_keywords, - args={ - "keywords": ["Jason Bourne", "Jason Bourne Acme"], - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - ValueInListCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - suite.add_case( - name="Get overdue tasks", - user_message="Are there any tasks overdue for the Jason Bourne contact from Acme?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_contact_data_by_keywords, - args={ - "keywords": ["Jason Bourne", "Jason Bourne Acme"], - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - ValueInListCritic(critic_field="keywords", weight=0.8), - BinaryCritic(critic_field="next_page_token", weight=0.2), - ], - ) - - return suite - - -@tool_eval() -def create_contact_eval_suite() -> EvalSuite: - """Create an evaluation suite for the create_contact tool.""" - suite = EvalSuite( - name="Create Contact", - system_message="You are an AI assistant that can interact with Hubspot CRM using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create contact", - user_message="Create a contact with the following information: first name 'Jason', " - "last name 'Bourne', email 'jason.bourne@acme.com', phone '+1234567890', " - "and job title 'Unbeatable', and associated with company id '1234567890'", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "company_id": "1234567890", - "first_name": "Jason", - "last_name": "Bourne", - "email": "jason.bourne@acme.com", - "phone": "+1234567890", - "job_title": "Unbeatable", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="company_id", weight=1 / 6), - BinaryCritic(critic_field="first_name", weight=1 / 6), - BinaryCritic(critic_field="last_name", weight=1 / 6), - BinaryCritic(critic_field="email", weight=1 / 6), - BinaryCritic(critic_field="phone", weight=1 / 6), - BinaryCritic(critic_field="job_title", weight=1 / 6), - ], - ) - - return suite diff --git a/toolkits/hubspot/pyproject.toml b/toolkits/hubspot/pyproject.toml deleted file mode 100644 index ced25ea6..00000000 --- a/toolkits/hubspot/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_hubspot" -version = "0.2.3" -description = "Arcade tools designed for LLMs to interact with Hubspot" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_hubspot/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_hubspot",] diff --git a/toolkits/hubspot/tests/test_hubspot_client.py b/toolkits/hubspot/tests/test_hubspot_client.py deleted file mode 100644 index 0dcb0f91..00000000 --- a/toolkits/hubspot/tests/test_hubspot_client.py +++ /dev/null @@ -1,326 +0,0 @@ -from copy import deepcopy -from unittest.mock import AsyncMock, MagicMock, call, patch - -import httpx -import pytest - -from arcade_hubspot.enums import HubspotObject -from arcade_hubspot.exceptions import HubspotToolExecutionError, NotFoundError -from arcade_hubspot.models import HubspotCrmClient - - -@pytest.mark.asyncio -async def test_get_success(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = {"data": []} - mock_httpx_client.get.return_value = mock_response - - response = await client.get("objects/contacts", params={"id": "123"}) - - assert response == {"data": []} - mock_httpx_client.get.assert_called_once_with( - url="https://api.hubapi.com/crm/v3/objects/contacts", - params={"id": "123"}, - headers={"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}"}, - ) - - -@pytest.mark.asyncio -async def test_get_not_found_error(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 404 - mock_response.json.return_value = {"message": "Not Found", "errors": [{"message": "Not Found"}]} - mock_httpx_client.get.return_value = mock_response - - with pytest.raises(NotFoundError) as exc_info: - await client.get("objects/contacts", params={"id": "123"}) - - assert exc_info.value.message == "Not Found" - assert exc_info.value.developer_message == '[{"message": "Not Found"}]' - - -@pytest.mark.asyncio -async def test_get_internal_server_error(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 500 - mock_response.json.return_value = { - "message": "Internal Server Error", - "errors": [{"message": "Internal Server Error"}], - } - mock_httpx_client.get.return_value = mock_response - - with pytest.raises(HubspotToolExecutionError) as exc_info: - await client.get("objects/contacts", params={"id": "123"}) - - assert exc_info.value.message == "Internal Server Error" - assert exc_info.value.developer_message == '[{"message": "Internal Server Error"}]' - - -@pytest.mark.asyncio -async def test_get_with_invalid_json_error_response(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 500 - mock_response.text = "Text error message" - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_httpx_client.get.return_value = mock_response - - with pytest.raises(HubspotToolExecutionError) as exc_info: - await client.get("objects/contacts", params={"id": "123"}) - - assert exc_info.value.message == "Text error message" - assert exc_info.value.developer_message is None - - -@pytest.mark.asyncio -async def test_post_success(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = {"data": []} - mock_httpx_client.post.return_value = mock_response - - response = await client.post("objects/contacts", json_data={"id": "123"}) - - assert response == {"data": []} - mock_httpx_client.post.assert_called_once_with( - url="https://api.hubapi.com/crm/v3/objects/contacts", - json={"id": "123"}, - headers={ - "Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}", - "Content-Type": "application/json", - }, - ) - - -@pytest.mark.asyncio -async def test_post_not_found_error(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 404 - mock_response.json.return_value = {"message": "Not Found", "errors": [{"message": "Not Found"}]} - mock_httpx_client.post.return_value = mock_response - - with pytest.raises(NotFoundError) as exc_info: - await client.post("objects/contacts", json_data={"id": "123"}) - - assert exc_info.value.message == "Not Found" - assert exc_info.value.developer_message == '[{"message": "Not Found"}]' - - -@pytest.mark.asyncio -async def test_post_internal_server_error(mock_context, mock_httpx_client): - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 500 - mock_response.json.return_value = { - "message": "Internal Server Error", - "errors": [{"message": "Internal Server Error"}], - } - mock_httpx_client.post.return_value = mock_response - - with pytest.raises(HubspotToolExecutionError) as exc_info: - await client.post("objects/contacts", json_data={"id": "123"}) - - assert exc_info.value.message == "Internal Server Error" - assert exc_info.value.developer_message == '[{"message": "Internal Server Error"}]' - - -@pytest.mark.asyncio -@patch("arcade_hubspot.models.clean_data") -async def test_batch_get_objects(mock_clean_data, mock_context, mock_httpx_client): - mock_results = [MagicMock(spec=dict) for _ in range(3)] - - clean_data_response = [MagicMock(spec=dict) for _ in range(3)] - mock_clean_data.side_effect = clean_data_response - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = { - "results": mock_results, - } - mock_httpx_client.post.return_value = mock_response - - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - response = await client.batch_get_objects( - HubspotObject.CONTACT, ["123", "456"], ["email", "firstname"] - ) - - assert response == clean_data_response - mock_clean_data.assert_has_calls([ - call(result, HubspotObject.CONTACT) for result in mock_results - ]) - - mock_httpx_client.post.assert_called_once_with( - url="https://api.hubapi.com/crm/v3/objects/contacts/batch/read", - json={ - "inputs": [{"id": "123"}, {"id": "456"}], - "properties": ["email", "firstname"], - }, - headers={ - "Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}", - "Content-Type": "application/json", - }, - ) - - -@pytest.mark.asyncio -@patch("arcade_hubspot.models.clean_data") -async def test_get_associated_objects(mock_clean_data, mock_context, mock_httpx_client): - # Mock stuff from the batch_get_objects function - mock_batch_results = [MagicMock(spec=dict) for _ in range(3)] - clean_data_response = [MagicMock(spec=dict) for _ in range(3)] - mock_clean_data.side_effect = clean_data_response - mock_post_response = MagicMock(spec=httpx.Response) - mock_post_response.status_code = 200 - mock_post_response.json.return_value = { - "results": mock_batch_results, - } - mock_httpx_client.post.return_value = mock_post_response - - # Mock stuff from the get_associated_objects function - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = { - "results": [{"toObjectId": "123"}, {"toObjectId": "456"}], - } - mock_httpx_client.get.return_value = mock_response - - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - response = await client.get_associated_objects( - parent_object=HubspotObject.COMPANY, - parent_id="parent_id", - associated_object=HubspotObject.CONTACT, - limit=5, - after=5, - properties=["email", "firstname"], - ) - - assert response == clean_data_response - mock_clean_data.assert_has_calls([ - call(result, HubspotObject.CONTACT) for result in mock_batch_results - ]) - - mock_httpx_client.get.assert_called_once_with( - url="https://api.hubapi.com/crm/v4/objects/company/parent_id/associations/contact", - params={ - "limit": 5, - "after": 5, - }, - headers={ - "Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}", - }, - ) - - mock_httpx_client.post.assert_called_once_with( - url="https://api.hubapi.com/crm/v3/objects/contacts/batch/read", - json={ - "inputs": [{"id": "123"}, {"id": "456"}], - "properties": ["email", "firstname"], - }, - headers={ - "Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}", - "Content-Type": "application/json", - }, - ) - - -@pytest.mark.asyncio -@patch("arcade_hubspot.models.prepare_api_search_response") -@patch("arcade_hubspot.models.get_object_properties") -async def test_search_by_keywords( - mock_get_object_properties, - mock_prepare_api_search_response, - mock_context, - mock_httpx_client, -): - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_httpx_client.post.return_value = mock_response - - mock_prepared_api_response = { - "contacts": [ - { - "id": "123", - "first_name": "Jason", - "last_name": "Bourne", - "email": "jason.bourne@acme.com", - } - ] - } - mock_prepare_api_search_response.return_value = mock_prepared_api_response - mock_get_object_properties.return_value = ["email", "firstname"] - - mock_calls = [{"id": f"call_{i}"} for i in range(3)] - mock_emails = [{"id": f"email_{i}"} for i in range(3)] - mock_notes = [{"id": f"note_{i}"} for i in range(3)] - - mock_get_associated_objects = AsyncMock() - mock_get_associated_objects.side_effect = [mock_calls, mock_emails, mock_notes] - - client = HubspotCrmClient(mock_context.get_auth_token_or_empty()) - client.get_associated_objects = mock_get_associated_objects - - response = await client.search_by_keywords( - object_type=HubspotObject.CONTACT, - keywords="test", - limit=10, - associations=[ - HubspotObject.CALL, - HubspotObject.EMAIL, - HubspotObject.NOTE, - ], - ) - - expected_response = deepcopy(mock_prepared_api_response) - expected_response["contacts"][0]["calls"] = mock_calls - expected_response["contacts"][0]["emails"] = mock_emails - expected_response["contacts"][0]["notes"] = mock_notes - - assert response == expected_response - - mock_httpx_client.post.assert_called_once_with( - url="https://api.hubapi.com/crm/v3/objects/contacts/search", - headers={ - "Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}", - "Content-Type": "application/json", - }, - json={ - "query": "test", - "limit": 10, - "sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "DESCENDING"}], - "properties": ["email", "firstname"], - }, - ) - - mock_get_associated_objects.assert_has_calls([ - call( - parent_object=HubspotObject.CONTACT, - parent_id="123", - associated_object=HubspotObject.CALL, - limit=10, - ), - call( - parent_object=HubspotObject.CONTACT, - parent_id="123", - associated_object=HubspotObject.EMAIL, - limit=10, - ), - call( - parent_object=HubspotObject.CONTACT, - parent_id="123", - associated_object=HubspotObject.NOTE, - limit=10, - ), - ]) diff --git a/toolkits/jira/.pre-commit-config.yaml b/toolkits/jira/.pre-commit-config.yaml deleted file mode 100644 index b7d80f28..00000000 --- a/toolkits/jira/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/jira/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/jira/.ruff.toml b/toolkits/jira/.ruff.toml deleted file mode 100644 index 9519fe6c..00000000 --- a/toolkits/jira/.ruff.toml +++ /dev/null @@ -1,44 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/jira/LICENSE b/toolkits/jira/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/jira/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/jira/Makefile b/toolkits/jira/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/jira/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/jira/arcade_jira/__init__.py b/toolkits/jira/arcade_jira/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/jira/arcade_jira/client.py b/toolkits/jira/arcade_jira/client.py deleted file mode 100644 index dc52150f..00000000 --- a/toolkits/jira/arcade_jira/client.py +++ /dev/null @@ -1,197 +0,0 @@ -import asyncio -import json -import json.decoder -from dataclasses import dataclass -from typing import cast - -import httpx -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError - -from arcade_jira.constants import JIRA_API_VERSION, JIRA_BASE_URL, JIRA_MAX_CONCURRENT_REQUESTS -from arcade_jira.exceptions import NotFoundError - - -@dataclass -class JiraClient: - context: ToolContext - cloud_id: str | None - base_url: str = JIRA_BASE_URL - api_version: str = JIRA_API_VERSION - max_concurrent_requests: int = JIRA_MAX_CONCURRENT_REQUESTS - _semaphore: asyncio.Semaphore | None = None - - @property - def auth_token(self) -> str | None: - return self.context.get_auth_token_or_empty() - - def __post_init__(self) -> None: - if not self._semaphore: - cached_semaphore = getattr(self.context, "_global_jira_client_semaphore", None) - - # If a semaphore was already cached in the context, we use it. Some tools - # may call other tools. Each tool will instantiate its own JiraClient. - # This is necessary to ensure that all instances will respect the - # concurrency limit. - if cached_semaphore: - self._semaphore = cached_semaphore - else: - self._semaphore = asyncio.Semaphore(self.max_concurrent_requests) - self.context._global_jira_client_semaphore = self._semaphore # type: ignore[attr-defined] - - self.base_url = self.base_url.rstrip("/") - self.api_version = self.api_version.strip("/") - - async def _build_url(self, endpoint: str) -> str: - return ( - f"{self.base_url}/{self.cloud_id or ''}" - f"/rest/api/{self.api_version}/{endpoint.lstrip('/')}" - ) - - def _build_error_messages(self, response: httpx.Response) -> tuple[str, str | None]: - try: - data = response.json() - developer_message = None - - if "errorMessages" in data: - if len(data["errorMessages"]) == 1: - error_message = cast(str, data["errorMessages"][0]) - elif "errors" in data: - error_message = json.dumps(data["errors"]) - else: - error_message = "Unknown error" - - elif "message" in data: - error_message = cast(str, data["message"]) - - else: - error_message = json.dumps(data) - - except Exception as e: - error_message = "Failed to parse Jira error response" - developer_message = ( - f"Failed to parse Jira error response: {type(e).__name__}: {e!s}. " - f"API Response: {response.text}" - ) - - return error_message, developer_message - - async def _raise_for_status(self, response: httpx.Response) -> None: - if response.status_code < 300: - return - - error_message, developer_message = self._build_error_messages(response) - - if response.status_code == 404: - raise NotFoundError(error_message, developer_message) - - raise ToolExecutionError(error_message, developer_message) - - def _set_request_body(self, kwargs: dict, data: dict | None, json_data: dict | None) -> dict: - if data and json_data: - raise ValueError("Cannot provide both data and json_data") # noqa: TRY003 - - if data: - kwargs["data"] = data - - elif json_data: - kwargs["json"] = json_data - - return kwargs - - def _format_response_dict(self, response: httpx.Response) -> dict: - try: - return cast(dict, response.json()) - except (UnicodeDecodeError, json.decoder.JSONDecodeError): - return {"text": response.text} - - async def get( - self, - endpoint: str, - params: dict | None = None, - headers: dict | None = None, - ) -> dict: - default_headers = { - "Authorization": f"Bearer {self.auth_token}", - "Accept": "application/json", - } - headers = {**default_headers, **(headers or {})} - - kwargs = { - "url": await self._build_url(endpoint), - "headers": headers, - } - - if params: - kwargs["params"] = params - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.get(**kwargs) # type: ignore[arg-type] - await self._raise_for_status(response) - - return self._format_response_dict(response) - - async def post( - self, - endpoint: str, - data: dict | None = None, - json_data: dict | None = None, - files: dict | None = None, - headers: dict | None = None, - ) -> dict: - default_headers = { - "Authorization": f"Bearer {self.auth_token}", - "Accept": "application/json", - } - - if files is None and json_data is not None: - default_headers["Content-Type"] = "application/json" - - headers = {**default_headers, **(headers or {})} - - kwargs = { - "url": await self._build_url(endpoint), - "headers": headers, - } - - if files is not None: - kwargs["files"] = files - if data is not None: - kwargs["data"] = data - else: - kwargs = self._set_request_body(kwargs, data, json_data) - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.post(**kwargs) # type: ignore[arg-type] - await self._raise_for_status(response) - - return self._format_response_dict(response) - - async def put( - self, - endpoint: str, - data: dict | None = None, - json_data: dict | None = None, - params: dict | None = None, - headers: dict | None = None, - ) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - headers["Content-Type"] = "application/json" - headers["Accept"] = "application/json" - - kwargs = { - "url": await self._build_url(endpoint), - "headers": headers, - } - - kwargs = self._set_request_body(kwargs, data, json_data) - - if params: - kwargs["params"] = params - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.put(**kwargs) # type: ignore[arg-type] - await self._raise_for_status(response) - - return self._format_response_dict(response) diff --git a/toolkits/jira/arcade_jira/constants.py b/toolkits/jira/arcade_jira/constants.py deleted file mode 100644 index 2d30f168..00000000 --- a/toolkits/jira/arcade_jira/constants.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -from enum import Enum - -JIRA_BASE_URL = "https://api.atlassian.com/ex/jira" -JIRA_API_VERSION = "3" - -try: - JIRA_MAX_CONCURRENT_REQUESTS = max(1, int(os.getenv("JIRA_MAX_CONCURRENT_REQUESTS", 3))) -except Exception: - JIRA_MAX_CONCURRENT_REQUESTS = 3 - -try: - JIRA_API_REQUEST_TIMEOUT = int(os.getenv("JIRA_API_REQUEST_TIMEOUT", 30)) -except Exception: - JIRA_API_REQUEST_TIMEOUT = 30 - - -STOP_WORDS = [ - "a", - "an", - "and", - "are", - "as", - "at", - "be", - "but", - "by", - "for", - "if", - "in", - "into", - "is", - "it", - "no", - "not", - "of", - "on", - "or", - "such", - "that", - "the", - "their", - "then", - "there", - "these", - "they", - "this", - "to", - "was", - "will", - "with", - "+", - "-", - "&", - "|", - "!", - "(", - ")", - "{", - "}", - "[", - "]", - "^", - "~", - "*", - "?", - "\\", - ":", -] - - -class IssueCommentOrderBy(Enum): - CREATED_DATE_ASCENDING = "created_date_ascending" - CREATED_DATE_DESCENDING = "created_date_descending" - - def to_api_value(self) -> str: - _map: dict[IssueCommentOrderBy, str] = { - IssueCommentOrderBy.CREATED_DATE_ASCENDING: "+created", - IssueCommentOrderBy.CREATED_DATE_DESCENDING: "-created", - } - return _map[self] - - -class PrioritySchemeOrderBy(Enum): - NAME_ASCENDING = "name ascending" - NAME_DESCENDING = "name descending" - - def to_api_value(self) -> str: - _map: dict[PrioritySchemeOrderBy, str] = { - PrioritySchemeOrderBy.NAME_ASCENDING: "+name", - PrioritySchemeOrderBy.NAME_DESCENDING: "-name", - } - return _map[self] diff --git a/toolkits/jira/arcade_jira/critics.py b/toolkits/jira/arcade_jira/critics.py deleted file mode 100644 index 6f53c81b..00000000 --- a/toolkits/jira/arcade_jira/critics.py +++ /dev/null @@ -1,91 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from arcade_evals.critic import BinaryCritic - - -@dataclass -class HasSubstringCritic(BinaryCritic): - """A critic for checking whether the argument value contains a substring.""" - - def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]: - """ - Evaluates whether the actual value contains the expected value. - - Args: - expected: The expected value. - actual: The actual value to compare, cast to the type of expected. - - Returns: - dict: A dictionary containing the match status and score. - """ - if not isinstance(actual, str) or not isinstance(expected, str): - return {"match": False, "score": 0.0} - - try: - actual_casted = self.cast_actual(expected, actual) - except TypeError: - actual_casted = actual - - match = expected in actual_casted - return {"match": match, "score": self.weight if match else 0.0} - - -@dataclass -class CaseInsensitiveBinaryCritic(BinaryCritic): - """A critic for checking whether actual and expected values are the same, case insensitive.""" - - def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]: - """ - Evaluates whether the actual value is the same as the expected value, case insensitive. - - Args: - expected: The expected value. - actual: The actual value to compare, cast to the type of expected. - - Returns: - dict: A dictionary containing the match status and score. - """ - if not isinstance(actual, str) or not isinstance(expected, str): - return {"match": False, "score": 0.0} - - try: - actual_casted = self.cast_actual(expected, actual) - except TypeError: - actual_casted = actual - - match = expected.casefold() in actual_casted.casefold() - return {"match": match, "score": self.weight if match else 0.0} - - -@dataclass -class CaseInsensitiveListOfStringsBinaryCritic(BinaryCritic): - """Checks that all strings match in Actual and Expected list of strings, case insensitive""" - - def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]: - """ - Checks that all strings match in Actual and Expected list of strings, case insensitive - - Args: - expected: The expected value. - actual: The actual value to compare, cast to the type of expected. - - Returns: - dict: A dictionary containing the match status and score. - """ - if not isinstance(actual, list) or not isinstance(expected, list): - return {"match": False, "score": 0.0} - - all_actual_str = all(isinstance(item, str) for item in actual) - all_expected_str = all(isinstance(item, str) for item in expected) - - if not all_actual_str or not all_expected_str: - return {"match": False, "score": 0.0} - - actual_folded = [item.casefold() for item in actual] - expected_folded = [item.casefold() for item in expected] - - match = len(actual) == len(expected) and all( - item in actual_folded for item in expected_folded - ) - return {"match": match, "score": self.weight if match else 0.0} diff --git a/toolkits/jira/arcade_jira/exceptions.py b/toolkits/jira/arcade_jira/exceptions.py deleted file mode 100644 index af3ba4a6..00000000 --- a/toolkits/jira/arcade_jira/exceptions.py +++ /dev/null @@ -1,13 +0,0 @@ -from arcade_tdk.errors import ToolExecutionError - - -class JiraToolExecutionError(ToolExecutionError): - pass - - -class NotFoundError(JiraToolExecutionError): - pass - - -class MultipleItemsFoundError(JiraToolExecutionError): - pass diff --git a/toolkits/jira/arcade_jira/tools/__init__.py b/toolkits/jira/arcade_jira/tools/__init__.py deleted file mode 100644 index c7bebed7..00000000 --- a/toolkits/jira/arcade_jira/tools/__init__.py +++ /dev/null @@ -1,83 +0,0 @@ -from arcade_jira.tools.attachments import ( - attach_file_to_issue, - download_attachment, - get_attachment_metadata, - list_issue_attachments_metadata, -) -from arcade_jira.tools.cloud import get_available_atlassian_clouds -from arcade_jira.tools.comments import ( - add_comment_to_issue, - get_comment_by_id, - get_issue_comments, -) -from arcade_jira.tools.issues import ( - add_labels_to_issue, - create_issue, - get_issue_by_id, - get_issue_type_by_id, - get_issues_without_id, - list_issue_types_by_project, - remove_labels_from_issue, - search_issues_with_jql, - update_issue, -) -from arcade_jira.tools.labels import list_labels -from arcade_jira.tools.priorities import ( - get_priority_by_id, - list_priorities_associated_with_a_priority_scheme, - list_priorities_available_to_a_project, - list_priorities_available_to_an_issue, - list_priority_schemes, - list_projects_associated_with_a_priority_scheme, -) -from arcade_jira.tools.projects import get_project_by_id, search_projects -from arcade_jira.tools.transitions import ( - get_transition_by_status_name, - get_transitions_available_for_issue, - transition_issue_to_new_status, -) -from arcade_jira.tools.users import get_user_by_id, get_users_without_id, list_users - -__all__ = [ - # Attachments tools - "attach_file_to_issue", - "download_attachment", - "get_attachment_metadata", - "list_issue_attachments_metadata", - # Cloud tools - "get_available_atlassian_clouds", - # Comments tools - "add_comment_to_issue", - "get_comment_by_id", - "get_issue_comments", - # Issues tools - "add_labels_to_issue", - "create_issue", - "get_issue_by_id", - "get_issue_type_by_id", - "get_issues_without_id", - "list_issue_types_by_project", - "remove_labels_from_issue", - "search_issues_with_jql", - "update_issue", - # Labels tools - "list_labels", - # Priorities tools - "get_priority_by_id", - "list_priority_schemes", - "list_priorities_associated_with_a_priority_scheme", - "list_projects_associated_with_a_priority_scheme", - "list_priorities_available_to_a_project", - "list_priorities_available_to_an_issue", - # Projects tools - "get_project_by_id", - "search_projects", - # Transitions tools - "get_transition_by_status_name", - "get_transitions_available_for_issue", - "transition_issue_to_new_status", - # Users tools - "get_user_by_id", - "get_users_without_id", - "list_users", -] diff --git a/toolkits/jira/arcade_jira/tools/attachments.py b/toolkits/jira/arcade_jira/tools/attachments.py deleted file mode 100644 index aefb72ed..00000000 --- a/toolkits/jira/arcade_jira/tools/attachments.py +++ /dev/null @@ -1,177 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian -from arcade_tdk.errors import ToolExecutionError - -from arcade_jira.client import JiraClient -from arcade_jira.exceptions import NotFoundError -from arcade_jira.utils import build_file_data, clean_attachment_dict, resolve_cloud_id - - -@tool(requires_auth=Atlassian(scopes=["write:jira-work"])) -async def attach_file_to_issue( - context: ToolContext, - issue: Annotated[str, "The issue ID or key to add the attachment to"], - filename: Annotated[ - str, - "The name of the file to add as an attachment. The filename should contain the " - "file extension (e.g. 'test.txt', 'report.pdf'), but it is not mandatory.", - ], - file_content_str: Annotated[ - str | None, - "The string content of the file to attach. Use this if the file is a text file. " - "Defaults to None.", - ] = None, - file_content_base64: Annotated[ - str | None, - "The base64-encoded binary contents of the file. " - "Use this for binary files like images or PDFs. Defaults to None.", - ] = None, - file_encoding: Annotated[ - str, - "The encoding of the file to attach. Only used with file_content_str. Defaults to 'utf-8'.", - ] = "utf-8", - file_type: Annotated[ - str | None, - "The type of the file to attach. E.g. 'application/pdf', 'text', 'image/png'. " - "If not provided, the tool will try to infer the type from the filename. " - "If the filename is not recognized, it will attach the file without specifying a type. " - "Defaults to None (infer from filename or attach without type).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Metadata about the attachment"]: - """Add an attachment to an issue. - - Must provide exactly one of file_content_str or file_content_base64. - """ - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - file_contents = [file_content_str, file_content_base64] - - if not any(file_contents) or all(file_contents): - raise ToolExecutionError( - message="Must provide exactly one of file_content_str or file_content_base64." - ) - - if not filename: - raise ToolExecutionError(message="Must provide a filename.") - - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - response = await client.post( - f"/issue/{issue}/attachments", - headers={ - "X-Atlassian-Token": "no-check", - }, - files=build_file_data( - filename=filename, - file_content_str=file_content_str, - file_content_base64=file_content_base64, - file_type=file_type, - file_encoding=file_encoding, - ), - ) - - return { - "status": { - "success": True, - "message": f"Attachment '{filename}' successfully added to the issue '{issue}'", - }, - "attachment": clean_attachment_dict(response[0]), - } - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def list_issue_attachments_metadata( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to retrieve"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "Information about the issue"]: - """Get the metadata about the files attached to an issue. - - This tool does NOT return the actual file contents. To get a file content, - use the `Jira.DownloadAttachment` tool. - """ - from arcade_jira.tools.issues import get_issue_by_id # Avoid circular imports - - response = await get_issue_by_id( - context=context, - issue=issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if response.get("error"): - return cast(dict, response) - return { - "issue": { - "id": response["issue"]["id"], - "key": response["issue"]["key"], - "attachments": response["issue"]["attachments"], - } - } - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_attachment_metadata( - context: ToolContext, - attachment_id: Annotated[str, "The ID of the attachment to retrieve"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The metadata of the attachment"]: - """Get the metadata of an attachment.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - try: - response = await client.get(f"/attachment/{attachment_id}") - except NotFoundError: - return {"error": f"Attachment not found with ID '{attachment_id}'."} - - return {"attachment": clean_attachment_dict(response)} - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def download_attachment( - context: ToolContext, - attachment_id: Annotated[str, "The ID of the attachment to download"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The content of the attachment"]: - """Download the contents of an attachment associated with an issue.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - attachment = await get_attachment_metadata( - context=context, - attachment_id=attachment_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - - if attachment.get("error"): - return cast(dict, attachment) - - try: - content = await client.get( - f"/attachment/content/{attachment_id}", - params={ - "redirect": False, - }, - ) - except NotFoundError: - return {"error": f"Attachment not found with ID '{attachment_id}'."} - - attachment["attachment"]["content"] = content["text"] - - return cast(dict, attachment) diff --git a/toolkits/jira/arcade_jira/tools/cloud.py b/toolkits/jira/arcade_jira/tools/cloud.py deleted file mode 100644 index b54fd675..00000000 --- a/toolkits/jira/arcade_jira/tools/cloud.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_jira.constants import JIRA_MAX_CONCURRENT_REQUESTS -from arcade_jira.utils import check_if_cloud_is_authorized - - -@tool(requires_auth=Atlassian(scopes=["read:jira-user"])) -async def get_available_atlassian_clouds( - context: ToolContext, -) -> Annotated[dict[str, list[dict[str, str]]], "Available Atlassian Clouds"]: - """Get available Atlassian Clouds.""" - async with httpx.AsyncClient() as client: - response = await client.get( - "https://api.atlassian.com/oauth/token/accessible-resources", - headers={"Authorization": f"Bearer {context.get_auth_token_or_empty()}"}, - ) - - verified_clouds = response.json() - cloud_ids_seen = set() - unique_clouds = [] - - for cloud in verified_clouds: - if cloud["id"] not in cloud_ids_seen: - unique_clouds.append({ - "atlassian_cloud_id": cloud["id"], - "atlassian_cloud_name": cloud["name"], - "atlassian_cloud_url": cloud["url"], - }) - cloud_ids_seen.add(cloud["id"]) - - semaphore = asyncio.Semaphore(JIRA_MAX_CONCURRENT_REQUESTS) - - verified_clouds = await asyncio.gather(*[ - check_if_cloud_is_authorized(context, cloud, semaphore) for cloud in unique_clouds - ]) - - return { - "clouds_available": [ - cloud_available for cloud_available in verified_clouds if cloud_available is not False - ] - } diff --git a/toolkits/jira/arcade_jira/tools/comments.py b/toolkits/jira/arcade_jira/tools/comments.py deleted file mode 100644 index 94940e15..00000000 --- a/toolkits/jira/arcade_jira/tools/comments.py +++ /dev/null @@ -1,194 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian -from arcade_tdk.errors import ToolExecutionError - -from arcade_jira.client import JiraClient -from arcade_jira.constants import IssueCommentOrderBy -from arcade_jira.exceptions import MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import ( - add_pagination_to_response, - build_adf_doc, - clean_comment_dict, - find_multiple_unique_users, - remove_none_values, - resolve_cloud_id, -) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_comment_by_id( - context: ToolContext, - issue_id: Annotated[str, "The ID or key of the issue to retrieve the comment from."], - comment_id: Annotated[str, "The ID of the comment to retrieve"], - include_adf_content: Annotated[ - bool, - "Whether to include the ADF (Atlassian Document Format) content of the comment in the " - "response. Defaults to False (return only the HTML rendered content).", - ] = False, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the comment"]: - """Get a comment by its ID.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - response = await client.get( - f"issue/{issue_id}/comment/{comment_id}", - params={"expand": "renderedBody"}, - ) - - if not response: - return { - "comment": None, - "message": f"No comment found with ID '{comment_id}' in the issue '{issue_id}'.", - "query": {"issue_id": issue_id, "comment_id": comment_id}, - } - - return {"comment": clean_comment_dict(response, include_adf_content)} - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_issue_comments( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to retrieve"], - limit: Annotated[ - int, - "The maximum number of comments to retrieve. Min 1, max 100, default 100.", - ] = 100, - offset: Annotated[ - int, - "The number of comments to skip. Defaults to 0 (start from the first comment).", - ] = 0, - order_by: Annotated[ - IssueCommentOrderBy | None, - "The order in which to return the comments. " - f"Defaults to '{IssueCommentOrderBy.CREATED_DATE_DESCENDING.value}' (most recent first).", - ] = IssueCommentOrderBy.CREATED_DATE_DESCENDING, - include_adf_content: Annotated[ - bool, - "Whether to include the ADF (Atlassian Document Format) content of the comment in the " - "response. Defaults to False (return only the HTML rendered content).", - ] = False, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the issue comments"]: - """Get the comments of a Jira issue by its ID.""" - limit = max(min(limit, 100), 1) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - f"issue/{issue}/comment", - params=remove_none_values({ - "expand": "renderedBody", - "maxResults": limit, - "startAt": offset, - "orderBy": order_by.to_api_value() if order_by else None, - }), - ) - comments = [ - clean_comment_dict(comment, include_adf_content) - for comment in api_response["comments"][:limit] - ] - response = { - "issue": issue, - "comments": comments, - "isLast": api_response.get("isLast"), - } - return add_pagination_to_response(response, comments, limit, offset) - - -@tool( - requires_auth=Atlassian( - scopes=[ - "write:jira-work", # Needed to add the comment - "read:jira-work", # Needed to get the issue data - "read:jira-user", # Needed to resolve user ID from name or email (mention_users) - ], - ), -) -async def add_comment_to_issue( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to comment on."], - body: Annotated[str, "The body of the comment to add to the issue."], - reply_to_comment: Annotated[ - str | None, - "Quote a previous comment as a reply to it. Provide the comment's ID. " - "Must be a comment from the same issue. Defaults to None (no quoted comment).", - ] = None, - mention_users: Annotated[ - list[str] | None, - "The users to mention in the comment. Provide the user display name, email address, or ID. " - "Ex: 'John Doe' or 'john.doe@example.com'. Defaults to None (no user mentions).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the comment created"]: - """Add a comment to a Jira issue.""" - if not body: - raise ToolExecutionError(message="Comment body cannot be empty.") - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - adf_body = build_adf_doc(body) - - if mention_users: - try: - users = await find_multiple_unique_users( - context=context, - user_identifiers=mention_users, - exact_match=True, - atlassian_cloud_id=atlassian_cloud_id, - ) - except (NotFoundError, MultipleItemsFoundError) as exc: - return {"error": f"Failed to mention user: {exc.message}"} - mentions = [ - { - "type": "mention", - "attrs": {"accessLevel": "", "id": user["id"], "text": f"@{user['name']}"}, - } - for user in users - ] - adf_body["content"][0]["content"] = mentions + adf_body["content"][0]["content"] - - if reply_to_comment: - quote_comment = await get_comment_by_id( - context=context, - issue_id=issue, - comment_id=reply_to_comment, - include_adf_content=True, - atlassian_cloud_id=atlassian_cloud_id, - ) - if not quote_comment["comment"]: - raise ToolExecutionError( - message=f"Cannot quote comment. No comment found with ID '{reply_to_comment}'." - ) - quote = { - "type": "blockquote", - "content": quote_comment["comment"]["adf_body"]["content"], - } - adf_body["content"] = [quote] + adf_body["content"] - - response = await client.post( - f"issue/{issue}/comment", - json_data={ - "expand": "renderedBody", - "body": adf_body, - }, - ) - - return { - "success": True, - "message": f"Comment successfully created for the issue '{issue}'.", - "comment": {"id": response["id"], "created_at": response["created"]}, - } diff --git a/toolkits/jira/arcade_jira/tools/issues.py b/toolkits/jira/arcade_jira/tools/issues.py deleted file mode 100644 index 89670873..00000000 --- a/toolkits/jira/arcade_jira/tools/issues.py +++ /dev/null @@ -1,898 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_jira.client import JiraClient -from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import ( - add_pagination_to_response, - build_adf_doc, - build_issue_update_request_body, - build_search_issues_jql, - clean_issue_dict, - clean_issue_type_dict, - clean_labels, - convert_date_string_to_date, - extract_id, - find_unique_project, - get_single_project, - remove_none_values, - resolve_cloud_id, - resolve_issue_users, - validate_issue_args, -) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def list_issue_types_by_project( - context: ToolContext, - project: Annotated[ - str, - "The project to get issue types for. Provide a project name, key, or ID. If a " - "project name is provided, the tool will try to find a unique exact match among the " - "available projects.", - ], - limit: Annotated[ - int, - "The maximum number of issue types to retrieve. Min of 1, max of 200. Defaults to 200.", - ] = 200, - offset: Annotated[ - int, - "The number of issue types to skip. Defaults to 0 (start from the first issue type).", - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[ - dict[str, Any], "Information about the issue types available for the specified project." -]: - """Get the list of issue types (e.g. 'Task', 'Epic', etc.) available to a given project.""" - limit = max(1, min(limit, 200)) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - try: - project_data = await find_unique_project( - context=context, - project_identifier=project, - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as error: - return {"error": error.message} - - project_id = project_data["id"] - - api_response = await client.get( - f"/issue/createmeta/{project_id}/issuetypes", - params={ - "maxResults": limit, - "startAt": offset, - }, - ) - issue_types = [clean_issue_type_dict(issue_type) for issue_type in api_response["issueTypes"]] - response = { - "project": { - "id": project_data["id"], - "key": project_data["key"], - "name": project_data["name"], - }, - "issue_types": issue_types, - "isLast": api_response.get("isLast"), - } - return add_pagination_to_response(response, issue_types, limit, offset) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_issue_type_by_id( - context: ToolContext, - issue_type_id: Annotated[str, "The ID of the issue type to retrieve"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "Information about the issue type"]: - """Get the details of a Jira issue type by its ID.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - try: - response = await client.get(f"issuetype/{issue_type_id}") - except NotFoundError: - return {"error": f"Issue type not found with ID '{issue_type_id}'."} - return {"issue_type": clean_issue_type_dict(response)} - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_issue_by_id( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to retrieve"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the issue"]: - """Get the details of a Jira issue by its ID.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - try: - response = await client.get( - f"issue/{issue}", - params={"expand": "renderedFields"}, - ) - except NotFoundError: - return {"error": f"Issue not found with ID/key '{issue}'."} - - return {"issue": clean_issue_dict(response)} - - -# NOTE: This is not named `search_issues` because sometimes LLM's won't realize they can -# search for an issue if they don't have the ID (hence the `without_id` in the name). There's -# an alias for this tool named `search_issues_without_jql`, and also another tool to search using -# JQL, named `search_issues_with_jql`. -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to search for issues - "read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter) - "manage:jira-configuration", # Needed to resolve priority ID from name - ] - ) -) -async def get_issues_without_id( - context: ToolContext, - keywords: Annotated[ - str | None, - "Keywords to search for issues. Matches against the issue " - "name, description, comments, and any custom field of type text. " - "Defaults to None (no keywords filtering).", - ] = None, - due_from: Annotated[ - str | None, - "Match issues due on or after this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (no due date filtering).", - ] = None, - due_until: Annotated[ - str | None, - "Match issues due on or before this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (no due date filtering).", - ] = None, - status: Annotated[ - str | None, - "Match issues that are in this status. Provide a status name. " - "Ex: 'To Do', 'In Progress', 'Done'. Defaults to None (any status).", - ] = None, - priority: Annotated[ - str | None, - "Match issues that have this priority. Provide a priority name. E.g. 'Highest'. " - "Defaults to None (any priority).", - ] = None, - assignee: Annotated[ - str | None, - "Match issues that are assigned to this user. Provide the user's name or email address. " - "Ex: 'John Doe' or 'john.doe@example.com'. Defaults to None (any assignee).", - ] = None, - project: Annotated[ - str | None, - "Match issues that are associated with this project. Provide the project's name, ID, or " - "key. If a project name is provided, the tool will try to find a unique exact match among " - "the available projects. Defaults to None (search across all projects).", - ] = None, - issue_type: Annotated[ - str | None, - "Match issues that are of this issue type. Provide an issue type name or ID. " - "E.g. 'Task', 'Epic', '12345'. If a name is provided, the tool will try to find a unique " - "exact match among the available issue types. Defaults to None (any issue type).", - ] = None, - labels: Annotated[ - list[str] | None, - "Match issues that are in these labels. Defaults to None (any label).", - ] = None, - parent_issue: Annotated[ - str | None, - "Match issues that are a child of this issue. Provide the issue's ID or key. " - "Defaults to None (no parent issue filtering).", - ] = None, - limit: Annotated[ - int, - "The maximum number of issues to retrieve. Min 1, max 100, default 50.", - ] = 50, - next_page_token: Annotated[ - str | None, - "The token to use to get the next page of issues. Defaults to None (first page).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]: - """Search for Jira issues when you don't have the issue ID(s). - - All text-based arguments (keywords, assignee, project, labels) are case-insensitive. - - ALWAYS PREFER THIS TOOL OVER THE `Jira.SearchIssuesWithJql` TOOL, UNLESS IT'S ABSOLUTELY - NECESSARY TO USE A JQL QUERY TO FILTER IN A WAY THAT IS NOT SUPPORTED BY THIS TOOL. - """ - limit = max(1, min(limit, 100)) - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - due_from_date = convert_date_string_to_date(due_from) if due_from else None - due_until_date = convert_date_string_to_date(due_until) if due_until else None - - jql = build_search_issues_jql( - keywords=keywords, - due_from=due_from_date, - due_until=due_until_date, - status=status, - priority=priority, - assignee=assignee, - project=project, - issue_type=issue_type, - labels=labels, - parent_issue=parent_issue, - ) - - if not jql: - raise JiraToolExecutionError( - message="No search criteria provided. Please provide at least one argument." - ) - - body = { - "jql": jql, - "maxResults": limit, - "nextPageToken": next_page_token, - "fields": ["*all"], - "expand": "renderedFields", - } - response = await client.post("search/jql", json_data=body) - - pagination = { - "limit": limit, - "total_results": len(response["issues"]), - } - - if response.get("nextPageToken"): - pagination["next_page_token"] = response["nextPageToken"] - - return { - "issues": [clean_issue_dict(issue) for issue in response["issues"]], - "pagination": pagination, - } - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to list issues - "read:jira-user", # Required by the `get_issues_without_id` tool - "manage:jira-configuration", # Required by the `get_issues_without_id` tool - ], - ), -) -async def list_issues( - context: ToolContext, - project: Annotated[ - str | None, - "The project to get issues for. Provide a project ID, key or name. If a project " - "is not provided and 1) the user has only one project, the tool will use that; 2) the " - "user has multiple projects, the tool will raise an error listing the available " - "projects to choose from.", - ] = None, - limit: Annotated[ - int, - "The maximum number of issues to retrieve. Min 1, max 100, default 50.", - ] = 50, - next_page_token: Annotated[ - str | None, - "The token to use to get the next page of issues. Defaults to None (first page).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]: - """Get the issues for a given project.""" - if not project: - project_data = await get_single_project( - context=context, - atlassian_cloud_id=atlassian_cloud_id, - ) - project = project_data["id"] - - return cast( - dict[str, Any], - await get_issues_without_id( - context=context, - project=project, - limit=limit, - next_page_token=next_page_token, - atlassian_cloud_id=atlassian_cloud_id, - ), - ) - - -# NOTE: This is an alias for `Jira.GetIssuesWithoutId`. Sometimes LLM's won't realize they can -# search for an issue if they don't have the ID. Other times, they don't realize they can search -# without using JQL. The two names are important to cover those cases. -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to search for issues - "read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter) - "manage:jira-configuration", # Needed to resolve priority ID from name - ], - ), -) -async def search_issues_without_jql( - context: ToolContext, - keywords: Annotated[ - str | None, - "Keywords to search for issues. Matches against the issue " - "name, description, comments, and any custom field of type text. " - "Defaults to None (no keywords filtering).", - ] = None, - due_from: Annotated[ - str | None, - "Match issues due on or after this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (no due date filtering).", - ] = None, - due_until: Annotated[ - str | None, - "Match issues due on or before this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (no due date filtering).", - ] = None, - status: Annotated[ - str | None, - "Match issues that are in this status. Provide a status name. " - "Ex: 'To Do', 'In Progress', 'Done'. Defaults to None (any status).", - ] = None, - priority: Annotated[ - str | None, - "Match issues that have this priority. Provide a priority name. E.g. 'Highest'. " - "Defaults to None (any priority).", - ] = None, - assignee: Annotated[ - str | None, - "Match issues that are assigned to this user. Provide the user's name or email address. " - "Ex: 'John Doe' or 'john.doe@example.com'. Defaults to None (any assignee).", - ] = None, - project: Annotated[ - str | None, - "Match issues that are associated with this project. Provide the project's name, ID, or " - "key. If a project name is provided, the tool will try to find a unique exact match among " - "the available projects. Defaults to None (search across all projects).", - ] = None, - issue_type: Annotated[ - str | None, - "Match issues that are of this issue type. Provide an issue type name or ID. " - "E.g. 'Task', 'Epic', '12345'. If a name is provided, the tool will try to find a unique " - "exact match among the available issue types. Defaults to None (any issue type).", - ] = None, - labels: Annotated[ - list[str] | None, - "Match issues that are in these labels. Defaults to None (any label).", - ] = None, - parent_issue: Annotated[ - str | None, - "Match issues that are a child of this issue. Provide the issue's ID or key. " - "Defaults to None (no parent issue filtering).", - ] = None, - limit: Annotated[ - int, - "The maximum number of issues to retrieve. Min 1, max 100, default 50.", - ] = 50, - next_page_token: Annotated[ - str | None, - "The token to use to get the next page of issues. Defaults to None (first page).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]: - """Parameterized search for Jira issues (without having to provide a JQL query). - - THIS TOOL RELEASES LESS CO2 THAN THE `Jira_SearchIssuesWithJql` TOOL. ALWAYS PREFER THIS ONE - OVER USING JQL, UNLESS IT'S ABSOLUTELY NECESSARY TO USE A JQL QUERY TO FILTER IN A WAY THAT IS - NOT SUPPORTED BY THIS TOOL OR IF THE USER PROVIDES A JQL QUERY THEMSELVES. - """ - return cast( - dict[str, Any], - await get_issues_without_id( - context=context, - keywords=keywords, - due_from=due_from, - due_until=due_until, - status=status, - priority=priority, - assignee=assignee, - project=project, - issue_type=issue_type, - labels=labels, - parent_issue=parent_issue, - limit=limit, - next_page_token=next_page_token, - atlassian_cloud_id=atlassian_cloud_id, - ), - ) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def search_issues_with_jql( - context: ToolContext, - jql: Annotated[str, "The JQL (Jira Query Language) query to search for issues"], - limit: Annotated[ - int, - "The maximum number of issues to retrieve. Min of 1, max of 100. Defaults to 50.", - ] = 50, - next_page_token: Annotated[ - str | None, - "The token to use to get the next page of issues. Defaults to None (first page).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]: - """Search for Jira issues using a JQL (Jira Query Language) query. - - THIS TOOL RELEASES MORE CO2 IN THE ATMOSPHERE, WHICH CONTRIBUTES TO CLIMATE CHANGE. ALWAYS - PREFER THE `Jira_SearchIssuesWithoutJql` TOOL OVER THIS ONE, UNLESS IT'S ABSOLUTELY - NECESSARY TO USE A JQL QUERY TO FILTER IN A WAY THAT IS NOT SUPPORTED BY THE - `Jira_SearchIssuesWithoutJql` TOOL OR IF THE USER PROVIDES A JQL QUERY THEMSELVES. - """ - limit = max(1, min(limit, 100)) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.post( - "search/jql", - json_data={ - "jql": jql, - "maxResults": limit, - "nextPageToken": next_page_token, - "fields": ["*all"], - "expand": "renderedFields", - }, - ) - - response: dict[str, Any] = { - "issues": [clean_issue_dict(issue) for issue in api_response["issues"]] - } - - if api_response.get("isLast") is not False and api_response.get("nextPageToken"): - response["pagination"] = { - "limit": limit, - "total_results": len(response["issues"]), - "next_page_token": api_response.get("nextPageToken"), - } - else: - response["pagination"] = {"is_last_page": True} - - return response - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to get the current issue data - "write:jira-work", # Needed to create the issue - "read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter) - "manage:jira-configuration", # Needed to resolve priority ID from name - ], - ), -) -async def create_issue( - context: ToolContext, - title: Annotated[ - str, - "The title of the issue.", - ], - issue_type: Annotated[ - str, - "The name or ID of the issue type. If a name is provided, the tool will try to find a " - "unique exact match among the available issue types.", - ], - project: Annotated[ - str | None, - "The ID, key or name of the project to associate the issue with. If a name is provided, " - "the tool will try to find a unique exact match among the available projects. " - "Defaults to None (no project). If `project` and `parent_issue` are not provided, " - "the tool will select the single project available. If the user has multiple, an " - "error will be returned with the available projects to choose from.", - ] = None, - due_date: Annotated[ - str | None, - "The due date of the issue. Format: YYYY-MM-DD. Ex: '2025-01-01'. " - "Defaults to None (no due date).", - ] = None, - description: Annotated[ - str | None, - "The description of the issue. Defaults to None (no description).", - ] = None, - environment: Annotated[ - str | None, - "The environment of the issue. Defaults to None (no environment).", - ] = None, - labels: Annotated[ - list[str] | None, - "The labels of the issue. Defaults to None (no labels). A label cannot contain spaces. " - "If a label is provided with spaces, they will be trimmed and replaced by underscores.", - ] = None, - parent_issue: Annotated[ - str | None, - "The ID or key of the parent issue. Defaults to None (no parent issue). " - "Must provide at least one of `parent_issue` or `project` arguments.", - ] = None, - priority: Annotated[ - str | None, - "The ID or name of the priority to use for the issue. If a name is provided, the tool " - "will try to find a unique exact match among the available priorities. Defaults to None " - "(the issue is created with Jira's default priority for the specified project).", - ] = None, - assignee: Annotated[ - str | None, - "The name, email or ID of the user to assign the issue to. If a name or email is provided, " - "the tool will try to find a unique exact match among the available users. " - "Defaults to None (no assignee).", - ] = None, - reporter: Annotated[ - str | None, - "The name, email or ID of the user who is the reporter of the issue. If a name or email is " - "provided, the tool will try to find a unique exact match among the available users. " - "Defaults to None (no reporter).", - ] = None, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "The created issue"]: - """Create a new Jira issue. - - Provide a value to one of `project` or `parent_issue` arguments. If `project` and - `parent_issue` are not provided, the tool will select the single project available. - If the user has multiple, an error will be returned with the available projects to choose from. - - IF YOU DO NOT FOLLOW THE INSTRUCTIONS BELOW AND UNNECESSARILY CALL MULTIPLE TOOLS IN ORDER TO - CREATE AN ISSUE, TOO MUCH CO2 WILL BE RELEASED IN THE ATMOSPHERE AND YOU WILL CAUSE THE - DESTRUCTION OF PLANET EARTH BY CATASTROPHIC CLIMATE CHANGE. - - If you have an issue type name, or a project key/name, a priority name, an assignee - name/key/email, or a reporter name/key/email, DO NOT CALL OTHER TOOLS only to list available - projects, priorities, issue types, or users. Provide the name, key, or email and the tool - will figure out the ID, WITHOUT CAUSING CATASTROPHIC CLIMATE CHANGE. - """ - project_data: dict[str, Any] | None = None - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - - if project is None and parent_issue is None: - try: - project_data = await get_single_project( - context=context, - atlassian_cloud_id=atlassian_cloud_id, - ) - except (NotFoundError, MultipleItemsFoundError) as exc: - return {"error": str(exc)} - else: - project = project_data["id"] - - ( - error, - project_data, - issue_type_data, - priority_data, - parent_data, - ) = await validate_issue_args( - context=context, - due_date=due_date, - project=project, - issue_type=issue_type, - priority=priority, - parent_issue=parent_issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return error - - error, assignee_data, reporter_data = await resolve_issue_users( - context=context, - assignee=assignee, - reporter=reporter, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return error - - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - request_body = { - "fields": remove_none_values({ - "summary": title, - "labels": clean_labels(labels), - "duedate": due_date, - "parent": extract_id(parent_data), - "project": extract_id(project_data), - "priority": extract_id(priority_data), - "assignee": extract_id(assignee_data), - "reporter": extract_id(reporter_data), - "issuetype": extract_id(issue_type_data), - }), - } - - if environment: - request_body["fields"]["environment"] = build_adf_doc(environment) - - if description: - request_body["fields"]["description"] = build_adf_doc(description) - - response = await client.post("issue", json_data=request_body) - - return { - "status": { - "success": True, - "message": "Issue successfully created.", - }, - "issue": { - "id": response["id"], - "key": response["key"], - }, - } - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to get the current issue labels - "write:jira-work", # Needed to update the issue - "read:jira-user", # Required by the `update_issue` tool - "manage:jira-configuration", # Required by the `update_issue` tool - ], - ), -) -async def add_labels_to_issue( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to update"], - labels: Annotated[ - list[str], - "The labels to add to the issue. A label cannot contain spaces. " - "If a label is provided with spaces, they will be trimmed and replaced by underscores.", - ], - notify_watchers: Annotated[ - bool, - "Whether to notify the issue's watchers. Defaults to True (notifies watchers).", - ] = True, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "The updated issue"]: - """Add labels to an existing Jira issue.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - issue_data = await get_issue_by_id( - context=context, - issue=issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if issue_data.get("error"): - return cast(dict, issue_data) - - labels = cast(list[str], clean_labels(labels)) - current_labels = issue_data["issue"]["labels"] - response = await update_issue( - context=context, - issue=issue_data["issue"]["id"], - labels=current_labels + labels, - notify_watchers=notify_watchers, - atlassian_cloud_id=atlassian_cloud_id, - ) - return cast(dict, response) - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to get the current issue labels - "write:jira-work", # Needed to update the issue - "read:jira-user", # Required by the `update_issue` tool - "manage:jira-configuration", # Required by the `update_issue` tool - ], - ), -) -async def remove_labels_from_issue( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to update"], - labels: Annotated[list[str], "The labels to remove from the issue (case-insensitive)"], - notify_watchers: Annotated[ - bool, - "Whether to notify the issue's watchers. Defaults to True (notifies watchers).", - ] = True, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The updated issue"]: - """Remove labels from an existing Jira issue.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - issue_data = await get_issue_by_id(context, issue, atlassian_cloud_id=atlassian_cloud_id) - if issue_data.get("error"): - return cast(dict, issue_data) - - lowercase_labels = [label.casefold() for label in labels] - current_labels = issue_data["issue"]["labels"] - new_labels = [label for label in current_labels if label.casefold() not in lowercase_labels] - response = await update_issue( - context=context, - issue=issue_data["issue"]["id"], - labels=new_labels, - notify_watchers=notify_watchers, - atlassian_cloud_id=atlassian_cloud_id, - ) - return cast(dict, response) - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to get the current issue data - "write:jira-work", # Needed to update the issue - "read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter) - "manage:jira-configuration", # Needed to resolve priority ID from name - ], - ), -) -async def update_issue( - context: ToolContext, - issue: Annotated[str, "The key or ID of the issue to update"], - title: Annotated[ - str | None, - "The new issue title. Provide an empty string to clear the title. " - "Defaults to None (does not change the title).", - ] = None, - description: Annotated[ - str | None, - "The new issue description. Provide an empty string to clear the description. " - "Defaults to None (does not change the description).", - ] = None, - environment: Annotated[ - str | None, - "The new issue environment. Provide an empty string to clear the environment. " - "Defaults to None (does not change the environment).", - ] = None, - due_date: Annotated[ - str | None, - "The new issue due date. Format: YYYY-MM-DD. Ex: '2025-01-01'. Provide an empty string " - "to clear the due date. Defaults to None (does not change the due date).", - ] = None, - issue_type: Annotated[ - str | None, - "The new issue type name or ID. If a name is provided, the tool will try to find a unique " - "exact match among the available issue types. Defaults to None (does not change the " - "issue type).", - ] = None, - priority: Annotated[ - str | None, - "The name or ID of the new issue priority. If a name is provided, the tool will try to " - "find a unique exact match among the available priorities. Defaults to None " - "(does not change the priority).", - ] = None, - parent_issue: Annotated[ - str | None, - "The ID or key of the parent issue. A parent cannot be removed by providing an empty " - "string. It is possible to change the parent issue by providing a new issue ID or key, " - "or to leave it unchanged. Defaults to None (does not change the parent issue).", - ] = None, - assignee: Annotated[ - str | None, - "The new issue assignee name, email, or ID. If a name or email is provided, the tool will " - "try to find a unique exact match among the available users. Provide an empty string to " - "remove the assignee. Defaults to None (does not change the assignee).", - ] = None, - reporter: Annotated[ - str | None, - "The new issue reporter name, email, or ID. If a name or email is provided, the tool will " - "try to find a unique exact match among the available users. Provide an empty string to " - "remove the reporter. Defaults to None (does not change the reporter).", - ] = None, - labels: Annotated[ - list[str] | None, - "The new issue labels. This argument will replace all labels with the new list. " - "Providing an empty list will remove all labels. To add or remove a subset of " - f"labels, use the `Jira.{add_labels_to_issue.__tool_name__}` or the " - f"`Jira.{remove_labels_from_issue.__tool_name__}` tools. " - "Defaults to None (does not change the labels). A label cannot contain spaces. " - "If a label is provided with spaces, they will be trimmed and replaced by underscores.", - ] = None, - notify_watchers: Annotated[ - bool, - "Whether to notify the issue's watchers. Defaults to True (notifies watchers).", - ] = True, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The updated issue"]: - """Update an existing Jira issue. - - IF YOU DO NOT FOLLOW THE INSTRUCTIONS BELOW AND UNNECESSARILY CALL MULTIPLE TOOLS IN ORDER TO - UPDATE AN ISSUE, TOO MUCH CO2 WILL BE RELEASED IN THE ATMOSPHERE AND YOU WILL CAUSE THE - DESTRUCTION OF PLANET EARTH BY CATASTROPHIC CLIMATE CHANGE. - - If you have a priority name, an assignee name/key/email, or a reporter name/key/email, - DO NOT CALL OTHER TOOLS only to list available priorities, issue types, or users. - Provide the name, key, or email and the tool will figure out the ID. - """ - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - issue_data = await get_issue_by_id( - context=context, - issue=issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if issue_data.get("error"): - return cast(dict, issue_data) - - project = issue_data["issue"]["project"]["id"] - - error, _, issue_type_data, priority_data, parent_issue_data = await validate_issue_args( - context=context, - due_date=due_date, - project=project, - issue_type=issue_type, - priority=priority, - parent_issue=parent_issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return cast(dict, error) - - error, assignee_data, reporter_data = await resolve_issue_users( - context=context, - assignee=assignee, - reporter=reporter, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return cast(dict, error) - - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - params = {"notifyWatchers": notify_watchers, "expand": "renderedFields"} - request_body = build_issue_update_request_body( - title=title, - description=description, - environment=environment, - due_date=due_date, - parent_issue=parent_issue_data, - issue_type=issue_type_data, - priority=priority_data, - assignee=assignee_data, - reporter=reporter_data, - labels=clean_labels(labels), - ) - - if not request_body["fields"] and not request_body["update"]: - raise JiraToolExecutionError( - message="No changes provided. Please provide at least one argument to update the issue." - ) - - await client.put(f"/issue/{issue}", json_data=request_body, params=params) - - return { - "issue": { - "id": issue_data["issue"]["id"], - "key": issue_data["issue"]["key"], - }, - "status": "success", - "message": "Issue updated successfully.", - } diff --git a/toolkits/jira/arcade_jira/tools/labels.py b/toolkits/jira/arcade_jira/tools/labels.py deleted file mode 100644 index 2d315a38..00000000 --- a/toolkits/jira/arcade_jira/tools/labels.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_jira.client import JiraClient -from arcade_jira.utils import add_pagination_to_response, resolve_cloud_id - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def list_labels( - context: ToolContext, - limit: Annotated[ - int, "The maximum number of labels to return. Min of 1, Max of 200. Defaults to 200." - ] = 200, - offset: Annotated[ - int, "The number of labels to skip. Defaults to 0 (starts from the first label)" - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The existing labels (tags) in the user's Jira instance"]: - """Get the existing labels (tags) in the user's Jira instance.""" - limit = max(min(limit, 200), 1) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - "/label", - params={ - "maxResults": limit, - "startAt": offset, - }, - ) - response = { - "labels": api_response["values"], - "total": api_response["total"], - } - return add_pagination_to_response(response, api_response["values"], limit, offset) diff --git a/toolkits/jira/arcade_jira/tools/priorities.py b/toolkits/jira/arcade_jira/tools/priorities.py deleted file mode 100644 index 37f2cde5..00000000 --- a/toolkits/jira/arcade_jira/tools/priorities.py +++ /dev/null @@ -1,259 +0,0 @@ -import asyncio -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_jira.client import JiraClient -from arcade_jira.constants import JIRA_API_REQUEST_TIMEOUT, PrioritySchemeOrderBy -from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import ( - add_pagination_to_response, - clean_priority_dict, - clean_priority_scheme_dict, - clean_project_dict, - find_priorities_by_project, - find_unique_project, - remove_none_values, - resolve_cloud_id, -) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_priority_by_id( - context: ToolContext, - priority_id: Annotated[str, "The ID of the priority to retrieve."], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The priority"]: - """Get the details of a priority by its ID.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - try: - response = await client.get(f"/priority/{priority_id}") - except NotFoundError: - return {"error": f"Priority not found with id '{priority_id}'"} - return {"priority": clean_priority_dict(response)} - - -@tool(requires_auth=Atlassian(scopes=["manage:jira-configuration"])) -async def list_priority_schemes( - context: ToolContext, - scheme_name: Annotated[ - str | None, "Filter by scheme name. Defaults to None (returns all scheme names)." - ] = None, - limit: Annotated[ - int, - "The maximum number of priority schemes to return. Min of 1, max of 50. Defaults to 50.", - ] = 50, - offset: Annotated[ - int, "The number of priority schemes to skip. Defaults to 0 (start from the first scheme)." - ] = 0, - order_by: Annotated[ - PrioritySchemeOrderBy, - "The order in which to return the priority schemes. Defaults to name ascending.", - ] = PrioritySchemeOrderBy.NAME_ASCENDING, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The priority schemes available"]: - """Browse the priority schemes available in Jira.""" - limit = max(min(limit, 50), 1) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - "/priorityscheme", - params=remove_none_values({ - "startAt": offset, - "maxResults": limit, - "schemeName": scheme_name, - "orderBy": order_by.to_api_value(), - }), - ) - - schemes = [clean_priority_scheme_dict(scheme) for scheme in api_response["values"]] - response = { - "priority_schemes": schemes, - "isLast": api_response.get("isLast"), - } - return add_pagination_to_response(response, schemes, limit, offset) - - -@tool(requires_auth=Atlassian(scopes=["manage:jira-configuration"])) -async def list_priorities_associated_with_a_priority_scheme( - context: ToolContext, - scheme_id: Annotated[str, "The ID of the priority scheme to retrieve priorities for."], - limit: Annotated[ - int, - "The maximum number of priority schemes to return. Min of 1, max of 50. Defaults to 50.", - ] = 50, - offset: Annotated[ - int, "The number of priority schemes to skip. Defaults to 0 (start from the first scheme)." - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The priorities associated with the priority scheme"]: - """Browse the priorities associated with a priority scheme.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - f"/priorityscheme/{scheme_id}/priorities", - params={ - "startAt": offset, - "maxResults": limit, - }, - ) - priorities = [clean_priority_dict(priority) for priority in api_response["values"]] - response = { - "priorities": priorities, - "isLast": api_response.get("isLast"), - } - return add_pagination_to_response(response, priorities, limit, offset) - - -@tool(requires_auth=Atlassian(scopes=["manage:jira-configuration"])) -async def list_projects_associated_with_a_priority_scheme( - context: ToolContext, - scheme_id: Annotated[str, "The ID of the priority scheme to retrieve projects for."], - project: Annotated[ - str | None, "Filter by project ID, key or name. Defaults to None (returns all projects)." - ] = None, - limit: Annotated[ - int, - "The maximum number of projects to return. Min of 1, max of 50. Defaults to 50.", - ] = 50, - offset: Annotated[ - int, "The number of projects to skip. Defaults to 0 (start from the first project)." - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The projects associated with the priority scheme"]: - """Browse the projects associated with a priority scheme.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - - if project: - try: - project_data = await find_unique_project( - context=context, - project_identifier=project, - atlassian_cloud_id=atlassian_cloud_id, - ) - except (NotFoundError, MultipleItemsFoundError) as exc: - return {"error": exc.message} - else: - project = project_data["id"] - - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - f"/priorityscheme/{scheme_id}/projects", - params=remove_none_values({ - "startAt": offset, - "maxResults": limit, - "projectId": project, - }), - ) - - projects = [clean_project_dict(project) for project in api_response["values"]] - response = { - "projects": projects, - "isLast": api_response.get("isLast"), - } - return add_pagination_to_response(response, projects, limit, offset) - - -@tool(requires_auth=Atlassian(scopes=["manage:jira-configuration"])) -async def list_priorities_available_to_a_project( - context: ToolContext, - project: Annotated[str, "The ID, key or name of the project to retrieve priorities for."], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[ - dict[str, Any], - "The priorities available to be used in issues in the specified Jira project", -]: - """Browse the priorities available to be used in issues in the specified Jira project. - - This tool may need to loop through several API calls to get all priorities associated with - a specific project. In Jira environments with too many Projects or Priority Schemes, - the search may take too long, and the tool call will timeout. - """ - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - - try: - project_data = await find_unique_project( - context=context, - project_identifier=project, - atlassian_cloud_id=atlassian_cloud_id, - ) - except (NotFoundError, MultipleItemsFoundError) as exc: - return {"error": exc.message} - - try: - return await asyncio.wait_for( - find_priorities_by_project( - context=context, - project=project_data, - atlassian_cloud_id=atlassian_cloud_id, - ), - timeout=JIRA_API_REQUEST_TIMEOUT, - ) - except asyncio.TimeoutError: - return {"error": f"The operation timed out after {JIRA_API_REQUEST_TIMEOUT} seconds."} - except JiraToolExecutionError as error: - return {"error": error.message} - - -@tool(requires_auth=Atlassian(scopes=["manage:jira-configuration"])) -async def list_priorities_available_to_an_issue( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue to retrieve priorities for."], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The priorities available to be used in the specified Jira issue"]: - """Browse the priorities available to be used in the specified Jira issue.""" - from arcade_jira.tools.issues import get_issue_by_id - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - issue_response = await get_issue_by_id( - context=context, - issue=issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if issue_response.get("error"): - return cast(dict[str, Any], issue_response) - - issue_data = issue_response["issue"] - project = issue_data["project"]["id"] - - response = await list_priorities_available_to_a_project( - context=context, - project=project, - atlassian_cloud_id=atlassian_cloud_id, - ) - - return { - "issue": { - "id": issue_data["id"], - "key": issue_data["key"], - "title": issue_data["title"], - }, - "project": response["project"], - "priorities_available": response["priorities_available"], - } diff --git a/toolkits/jira/arcade_jira/tools/projects.py b/toolkits/jira/arcade_jira/tools/projects.py deleted file mode 100644 index 52456775..00000000 --- a/toolkits/jira/arcade_jira/tools/projects.py +++ /dev/null @@ -1,109 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_jira.client import JiraClient -from arcade_jira.exceptions import NotFoundError -from arcade_jira.utils import ( - add_pagination_to_response, - clean_project_dict, - remove_none_values, - resolve_cloud_id, -) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def list_projects( - context: ToolContext, - limit: Annotated[ - int, "The maximum number of projects to return. Min of 1, Max of 50. Defaults to 50." - ] = 50, - offset: Annotated[ - int, "The number of projects to skip. Defaults to 0 (starts from the first project)" - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the projects"]: - """Browse projects available in Jira.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - return cast( - dict[str, Any], - await search_projects( - context=context, - keywords=None, - limit=limit, - offset=offset, - atlassian_cloud_id=atlassian_cloud_id, - ), - ) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def search_projects( - context: ToolContext, - keywords: Annotated[ - str | None, - "The keywords to search for projects. Matches against project name and key " - "(case insensitive). Defaults to None (no keywords filter).", - ] = None, - limit: Annotated[ - int, "The maximum number of projects to return. Min of 1, Max of 50. Defaults to 50." - ] = 50, - offset: Annotated[ - int, "The number of projects to skip. Defaults to 0 (starts from the first project)" - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the projects"]: - """Get the details of all Jira projects.""" - limit = max(min(limit, 50), 1) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - "/project/search", - params=remove_none_values({ - "expand": ",".join([ - "description", - "url", - ]), - "maxResults": limit, - "startAt": offset, - "query": keywords, - }), - ) - - projects = [clean_project_dict(project) for project in api_response["values"]] - response = { - "projects": projects, - "isLast": api_response.get("isLast"), - } - return add_pagination_to_response(response, projects, limit, offset) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_project_by_id( - context: ToolContext, - project: Annotated[str, "The ID or key of the project to retrieve"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "Information about the project"]: - """Get the details of a Jira project by its ID or key.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - try: - response = await client.get(f"project/{project}") - except NotFoundError: - return {"error": f"Project not found: {project}"} - - return {"project": clean_project_dict(response)} diff --git a/toolkits/jira/arcade_jira/tools/transitions.py b/toolkits/jira/arcade_jira/tools/transitions.py deleted file mode 100644 index 4cafeeea..00000000 --- a/toolkits/jira/arcade_jira/tools/transitions.py +++ /dev/null @@ -1,187 +0,0 @@ -from typing import Annotated, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian - -from arcade_jira.client import JiraClient -from arcade_jira.utils import resolve_cloud_id - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_transition_by_id( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue"], - transition_id: Annotated[str, "The ID of the transition"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "The transition data"]: - """Get a transition by its ID.""" - if not transition_id: - return {"error": "The transition ID is required."} - if not transition_id.isdigit(): - return {"error": "The transition ID must be a numeric string."} - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - response = await client.get( - f"/issue/{issue}/transitions", - params={ - "transitionId": transition_id, - }, - ) - transitions = response["transitions"] - - if len(transitions) == 0: - return { - "error": ( - f"No transition found for the issue '{issue}' with ID '{transition_id}'. " - "To get all transitions available for the issue, use the " - f"`Jira.{get_transitions_available_for_issue.__tool_name__}` tool." - ), - } - - if len(transitions) == 1: - return {"transition": transitions[0]} - - return { - "error": f"Multiple transitions found for the issue '{issue}' with ID '{transition_id}'.", - "transitions": transitions, - } - - -@tool(requires_auth=Atlassian(scopes=["read:jira-work"])) -async def get_transitions_available_for_issue( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "The transitions available and the issue's current status"]: - """Get the transitions available for an existing Jira issue.""" - from arcade_jira.tools.issues import get_issue_by_id # Avoid circular import - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - issue_data = await get_issue_by_id( - context=context, - issue=issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if issue_data.get("error"): - return cast(dict, issue_data) - response = await client.get( - f"/issue/{issue_data['issue']['id']}/transitions", - params={ - "expand": "transitions.fields", - }, - ) - return { - "issue": { - "id": issue_data["issue"]["id"], - "key": issue_data["issue"]["key"], - "current_status": issue_data["issue"]["status"], - }, - "transitions_available": response["transitions"], - } - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to get the transitions available for the issue - "write:jira-work", # Needed to transition the issue - ], - ), -) -async def get_transition_by_status_name( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue"], - transition: Annotated[str, "The name of the transition status"], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "The transition data, including screen fields available"]: - """Get a transition available for an issue by the transition name. - - The response will contain screen fields available for the transition, if any. - """ - transitions = await get_transitions_available_for_issue( - context=context, - issue=issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - for available_transition in transitions["transitions_available"]: - if available_transition["name"].casefold() == transition.casefold(): - return {"issue": issue, "transition": available_transition} - return { - "error": f"Transition '{transition}' not found for the issue '{issue}'", - "transitions_available": transitions["transitions_available"], - } - - -@tool( - requires_auth=Atlassian( - scopes=[ - "read:jira-work", # Needed to get the transition ID by name - "write:jira-work", # Needed to transition the issue - ], - ), -) -async def transition_issue_to_new_status( - context: ToolContext, - issue: Annotated[str, "The ID or key of the issue"], - transition: Annotated[ - str, - "The transition to perform. Provide the transition ID or its name (case insensitive).", - ], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict, "The updated issue"]: - """Transition a Jira issue to a new status.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - # Try to get the transition by ID first - response = await get_transition_by_id( - context=context, - issue=issue, - transition_id=transition, - atlassian_cloud_id=atlassian_cloud_id, - ) - - # If the transition is not found by ID, try to get it by name - if response.get("error"): - response = await get_transition_by_status_name( - context=context, - issue=issue, - transition=transition, - atlassian_cloud_id=atlassian_cloud_id, - ) - if response.get("error"): - return cast(dict, response) - - transition_id = response["transition"]["id"] - transition_name = response["transition"]["name"] - - # The /issue/issue_id/transitions endpoint returns a 204 No Content in case of success - await client.post( - f"/issue/{issue}/transitions", - json_data={ - "transition": {"id": transition_id}, - }, - ) - - return { - "status": "success", - "message": f"Issue '{issue}' successfully transitioned to '{transition_name}'.", - } diff --git a/toolkits/jira/arcade_jira/tools/users.py b/toolkits/jira/arcade_jira/tools/users.py deleted file mode 100644 index 1867a931..00000000 --- a/toolkits/jira/arcade_jira/tools/users.py +++ /dev/null @@ -1,172 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Atlassian -from arcade_tdk.errors import ToolExecutionError - -from arcade_jira.client import JiraClient -from arcade_jira.exceptions import NotFoundError -from arcade_jira.utils import ( - add_pagination_to_response, - clean_user_dict, - remove_none_values, - resolve_cloud_id, -) - - -@tool(requires_auth=Atlassian(scopes=["read:jira-user"])) -async def list_users( - context: ToolContext, - account_type: Annotated[ - str | None, - "The account type of the users to return. Defaults to 'atlassian'. Provide `None` to " - "disable filtering by account type. The account type filter will be applied after " - "retrieving users from Jira API, thus the tool may return less users than the limit and " - "still have more users to paginate. Check the `pagination` key in the response dictionary.", - ] = "atlassian", - limit: Annotated[ - int, - "The maximum number of users to return. Min of 1, max of 50. Defaults to 50.", - ] = 50, - offset: Annotated[ - int, - "The number of users to skip before starting to return users. " - "Defaults to 0 (start from the first user).", - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The information about all users."]: - """Browse users in Jira.""" - limit = max(min(limit, 50), 1) - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - "/users/search", - params={ - "startAt": offset, - "maxResults": limit, - }, - ) - items = cast(list[dict[str, Any]], api_response) - - users = [ - clean_user_dict(user) - for user in api_response - if not account_type or user["accountType"].casefold() == account_type.casefold() - ] - response = add_pagination_to_response({"users": users}, items, limit, offset) - response["pagination"]["total_results"] = len(users) - return response - - -@tool(requires_auth=Atlassian(scopes=["read:jira-user"])) -async def get_user_by_id( - context: ToolContext, - user_id: Annotated[str, "The the user's ID."], - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The user information."]: - """Get user information by their ID.""" - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - - not_found = {"error": "User not found"} - - try: - response = await client.get("user", params={"accountId": user_id}) - except NotFoundError: - return not_found - - if not response: - return not_found - - return {"user": clean_user_dict(response)} - - -@tool(requires_auth=Atlassian(scopes=["read:jira-user"])) -async def get_users_without_id( - context: ToolContext, - name_or_email: Annotated[ - str, - "The user's display name or email address to search for (case-insensitive). The string can " - "match the prefix of the user's attribute. For example, a string of 'john' will match " - "users with a display name or email address that starts with 'john', such as " - "'John Doe', 'Johnson', 'john@example.com', etc.", - ], - enforce_exact_match: Annotated[ - bool, - "Whether to enforce an exact match of the name_or_email against users' display name and " - "email attributes. Defaults to False (return all users that match the prefix). If set to " - "True, before returning results, the tool will filter users with a display name OR email " - "address that match exactly the value of the `name_or_email` argument.", - ] = False, - limit: Annotated[ - int, - "The maximum number of users to return. Min of 1, max of 50. Defaults to 50.", - ] = 50, - offset: Annotated[ - int, - "The number of users to skip before starting to return users. " - "Defaults to 0 (start from the first user).", - ] = 0, - atlassian_cloud_id: Annotated[ - str | None, - "The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has " - "a single cloud authorized, the tool will use that. Otherwise, an error will be raised.", - ] = None, -) -> Annotated[dict[str, Any], "The information about users that match the search criteria."]: - """Get users without their account ID, searching by display name and email address. - - The Jira user search API will return up to 1,000 (one thousand) users for any given name/email - query. If you need to get more users, please use the `Jira.ListAllUsers` tool. - """ - limit = max(min(limit, 1000), 1) - - if limit + offset > 1000: - raise ToolExecutionError( - message="The maximum number of users returned by the Jira search API is 1000. " - f"To get more users use the `Jira.{list_users.__tool_name__}` tool." - ) - - if not name_or_email: - raise ToolExecutionError( - message="The `user_name_or_email` argument is required to search for users." - ) - - atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id) - client = JiraClient(context=context, cloud_id=atlassian_cloud_id) - api_response = await client.get( - "/user/search", - params=remove_none_values({ - "query": name_or_email, - "startAt": offset, - "maxResults": limit, - }), - ) - - users = [clean_user_dict(user) for user in api_response] - - if enforce_exact_match: - users = [ - user - for user in users - if user["name"].casefold() == name_or_email.casefold() - or user["email"].casefold() == name_or_email.casefold() - ] - - response = { - "users": users, - "query": { - "name_or_email": name_or_email, - "enforce_exact_match": enforce_exact_match, - "limit": limit, - "offset": offset, - }, - } - return add_pagination_to_response(response, users, limit, offset, 1000) diff --git a/toolkits/jira/arcade_jira/utils.py b/toolkits/jira/arcade_jira/utils.py deleted file mode 100644 index 50833d37..00000000 --- a/toolkits/jira/arcade_jira/utils.py +++ /dev/null @@ -1,1350 +0,0 @@ -import asyncio -import base64 -import json -import mimetypes -import uuid -from collections.abc import Callable -from contextlib import suppress -from datetime import date, datetime -from typing import Any, cast - -import httpx -from arcade_tdk import ToolContext -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_jira.constants import JIRA_BASE_URL, STOP_WORDS -from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError - - -def remove_none_values(data: dict) -> dict: - """Remove all keys with None values from the dictionary.""" - return {k: v for k, v in data.items() if v is not None} - - -def safe_delete_dict_keys(data: dict, keys: list[str]) -> dict: - for key in keys: - with suppress(KeyError): - del data[key] - return data - - -def convert_date_string_to_date(date_string: str) -> date: - return datetime.strptime(date_string, "%Y-%m-%d").date() - - -def is_valid_date_string(date_string: str) -> bool: - try: - convert_date_string_to_date(date_string) - except ValueError: - return False - - return True - - -def quote(v: str) -> str: - quoted = v.replace('"', r"\"") - return f'"{quoted}"' - - -def build_search_issues_jql( - keywords: str | None = None, - due_from: date | None = None, - due_until: date | None = None, - status: str | None = None, - priority: str | None = None, - assignee: str | None = None, - project: str | None = None, - issue_type: str | None = None, - labels: list[str] | None = None, - parent_issue: str | None = None, -) -> str: - clauses: list[str] = [] - - if keywords: - kw_clauses = [ - f"text ~ {quote(k.casefold())}" - for k in keywords.split() - if k.casefold() not in STOP_WORDS - ] - clauses.append("(" + " AND ".join(kw_clauses) + ")") - - if due_from: - clauses.append(f'dueDate >= "{due_from.isoformat()}"') - if due_until: - clauses.append(f'dueDate <= "{due_until.isoformat()}"') - - if labels: - label_list = ",".join(quote(label) for label in labels) - clauses.append(f"labels IN ({label_list})") - - standard_cases = [ - ("status", status), - ("priority", priority), - ("assignee", assignee), - ("project", project), - ("issuetype", issue_type), - ("parent", parent_issue), - ] - - for field, value in standard_cases: - if value: - clauses.append(f"{field} = {quote(value)}") - - return " AND ".join(clauses) if clauses else "" - - -def clean_issue_dict(issue: dict) -> dict: - fields = cast(dict, issue["fields"]) - rendered_fields = issue.get("renderedFields", {}) - - fields["id"] = issue["id"] - fields["key"] = issue["key"] - fields["title"] = fields["summary"] - - if fields.get("parent"): - fields["parent"] = get_summarized_issue_dict(fields["parent"]) - - if fields["assignee"]: - fields["assignee"] = clean_user_dict(fields["assignee"]) - - if fields["creator"]: - fields["creator"] = clean_user_dict(fields["creator"]) - - if fields["reporter"]: - fields["reporter"] = clean_user_dict(fields["reporter"]) - - if fields.get("description"): - fields["description"] = rendered_fields.get("description") - - if fields.get("environment"): - fields["environment"] = rendered_fields.get("environment") - - if fields.get("worklog"): - fields["worklog"] = { - "items": rendered_fields.get("worklog", {}).get("worklogs", []), - "total": len(rendered_fields.get("worklog", {}).get("worklogs", [])), - } - - if fields.get("attachment"): - fields["attachments"] = [ - clean_attachment_dict(attachment) for attachment in fields.get("attachment", []) - ] - - add_identified_fields_to_issue(fields, ["status", "issuetype", "priority", "project"]) - - safe_delete_dict_keys( - fields, - [ - "subtasks", - "summary", - "assignee", - "creator", - "issuetype", - "lastViewed", - "updated", - "statusCategory", - "statuscategorychangedate", - "votes", - "watches", - "attachment", - "comment", - "self", - ], - ) - - return fields - - -def add_identified_fields_to_issue( - fields_dict: dict[str, Any], - field_names: list[str], -) -> dict[str, Any]: - for field_name in field_names: - if fields_dict.get(field_name): - data = { - "name": fields_dict[field_name]["name"], - "id": fields_dict[field_name]["id"], - } - if "key" in fields_dict[field_name]: - data["key"] = fields_dict[field_name]["key"] - fields_dict[field_name] = data - - return fields_dict - - -def clean_comment_dict(comment: dict, include_adf_content: bool = False) -> dict: - data = { - "id": comment["id"], - "author": { - "name": comment["author"]["displayName"], - "email": comment["author"]["emailAddress"], - }, - "body": comment["renderedBody"], - "created_at": comment["created"], - } - - if include_adf_content: - data["adf_body"] = comment["body"] - - return data - - -def clean_project_dict(project: dict) -> dict: - data = { - "id": project["id"], - "key": project["key"], - "name": project["name"], - } - - if "description" in project: - data["description"] = project["description"] - - if "email" in project: - data["email"] = project["email"] - - if "projectCategory" in project: - data["category"] = project["projectCategory"] - - if "style" in project: - data["style"] = project["style"] - - return data - - -def clean_issue_type_dict(issue_type: dict) -> dict: - data = { - "id": issue_type["id"], - "name": issue_type["name"], - "description": issue_type["description"], - } - - if "scope" in issue_type: - data["scope"] = issue_type["scope"] - - return data - - -def clean_user_dict(user: dict) -> dict: - data = { - "id": user["accountId"], - "name": user["displayName"], - "active": user["active"], - } - - if user.get("emailAddress"): - data["email"] = user["emailAddress"] - - if user.get("accountType"): - data["account_type"] = user["accountType"] - - if user.get("timeZone"): - data["timezone"] = user["timeZone"] - - if user.get("active"): - data["active"] = user["active"] - - return data - - -def clean_attachment_dict(attachment: dict) -> dict: - return { - "id": attachment["id"], - "filename": attachment["filename"], - "mime_type": attachment["mimeType"], - "size": {"bytes": attachment["size"]}, - "author": clean_user_dict(attachment["author"]), - } - - -def clean_priority_scheme_dict(scheme: dict) -> dict: - data = { - "id": scheme["id"], - "name": scheme["name"], - "description": scheme["description"], - "is_default": scheme["isDefault"], - } - - if isinstance(scheme.get("priorities"), dict): - all_priorities = scheme["priorities"].get("isLast", True) - - data["priorities"] = [ - clean_priority_dict(priority) for priority in scheme["priorities"]["values"] - ] - - if not all_priorities: - # Avoid circular import - from arcade_jira.tools.priorities import ( - list_priorities_associated_with_a_priority_scheme, - ) - - data["priorities"]["message"] = ( - "Not all priorities are listed. Paginate the " - f"`Jira.{list_priorities_associated_with_a_priority_scheme.__tool_name__}` tool " - "to get the full list of priorities in this priority scheme." - ) - - if isinstance(scheme.get("projects"), dict): - all_projects = scheme["projects"].get("isLast", True) - data["projects"] = [clean_project_dict(project) for project in scheme["projects"]["values"]] - if not all_projects: - # Avoid circular import - from arcade_jira.tools.priorities import list_projects_associated_with_a_priority_scheme - - data["projects"]["message"] = ( - "Not all projects are listed. Paginate the " - f"`Jira.{list_projects_associated_with_a_priority_scheme.__tool_name__}` tool " - "to get the full list of projects in this priority scheme." - ) - - return data - - -def clean_priority_dict(priority: dict) -> dict: - data = { - "id": priority["id"], - "name": priority["name"], - "description": priority["description"], - } - - if "statusColor" in priority: - data["statusColor"] = priority["statusColor"] - - return data - - -def clean_labels(labels: list[str] | None) -> list[str] | None: - if not labels: - return None - return [label.strip().replace(" ", "_") for label in labels] - - -def get_summarized_issue_dict(issue: dict) -> dict: - fields = issue["fields"] - return { - "id": issue["id"], - "key": issue["key"], - "title": fields.get("summary"), - "status": fields.get("status", {}).get("name"), - "type": fields.get("issuetype", {}).get("name"), - "priority": fields.get("priority", {}).get("name"), - } - - -def add_pagination_to_response( - response: dict[str, Any], - items: list[dict[str, Any]], - limit: int, - offset: int, - max_results: int | None = None, -) -> dict[str, Any]: - next_offset = offset + limit - if max_results: - next_offset = min(next_offset, max_results - limit) - - response["pagination"] = { - "limit": limit, - "total_results": len(items), - } - - if response.get("isLast") is True: - response["pagination"]["is_last_page"] = True - elif response.get("isLast") is False or (len(items) >= limit and next_offset > offset): - response["pagination"]["next_offset"] = next_offset - else: - response["pagination"]["is_last_page"] = True - - with suppress(KeyError): - del response["isLast"] - - return response - - -def simplify_user_dict(user: dict) -> dict: - return { - "id": user["id"], - "name": user["name"], - "email": user["email"], - } - - -async def find_multiple_unique_users( - context: ToolContext, - user_identifiers: list[str], - exact_match: bool = False, - atlassian_cloud_id: str | None = None, -) -> list[dict[str, Any]]: - """ - Find users matching either their display name, email address, or account ID. - - By default, the search will match prefixes. A user_identifier of "john" will match - "John Doe", "Johnson", "john.doe@example.com", etc. - - If `enforce_exact_match` is set to True, the search will only return users that have either - a display name, email address, or account ID that match the exact user_identifier. - """ - from arcade_jira.tools.users import ( # Avoid circular import - get_user_by_id, - get_users_without_id, - ) - - users: list[dict[str, Any]] = [] - - responses = await asyncio.gather(*[ - get_users_without_id( - context=context, - name_or_email=user_identifier, - enforce_exact_match=exact_match, - atlassian_cloud_id=atlassian_cloud_id, - ) - for user_identifier in user_identifiers - ]) - - search_by_id: list[str] = [] - - for response in responses: - user_identifier = response["query"]["name_or_email"] - - if response["pagination"]["total_results"] > 1: - simplified_users = [simplify_user_dict(user) for user in response["users"]] - raise MultipleItemsFoundError( - message=f"Multiple users found with name or email '{user_identifier}'. " - f"Please provide a unique ID: {json.dumps(simplified_users)}" - ) - - elif response["pagination"]["total_results"] == 0: - search_by_id.append(user_identifier) - - else: - users.append(response["users"][0]) - - if search_by_id: - responses = await asyncio.gather(*[ - get_user_by_id( - context=context, - user_id=user_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - for user_id in search_by_id - ]) - for response in responses: - if response["user"]: - users.append(response["user"]) - else: - raise NotFoundError( - message=f"No user found with '{response['query']['user_id']}'.", - ) - - return users - - -async def find_unique_project( - context: ToolContext, - project_identifier: str, - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - """Find a unique project by its ID, key, or name - - Args: - project_identifier: The ID, key, or name of the project to find. - - Returns: - The project found. - """ - # Avoid circular import - from arcade_jira.tools.projects import get_project_by_id, search_projects - - # Try to find project by ID or key - response = await get_project_by_id( - context=context, - project=project_identifier, - atlassian_cloud_id=atlassian_cloud_id, - ) - if response.get("project"): - return cast(dict, response["project"]) - - # If not found, search by name - response = await search_projects( - context=context, - keywords=project_identifier, - atlassian_cloud_id=atlassian_cloud_id, - ) - projects = response["projects"] - if len(projects) == 1: - return cast(dict, projects[0]) - elif len(projects) > 1: - simplified_projects = [ - { - "id": project["id"], - "name": project["name"], - } - for project in projects - ] - raise MultipleItemsFoundError( - message=f"Multiple projects found with name/key/ID '{project_identifier}'. " - f"Please provide a unique ID: {json.dumps(simplified_projects)}" - ) - - raise NotFoundError(message=f"Project not found with name/key/ID '{project_identifier}'") - - -async def find_unique_priority( - context: ToolContext, - priority_identifier: str, - project_id: str, - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - """Find a unique priority by ID or name that is associated with a project - - Args: - priority_identifier: The ID or name of the priority to find. - project_id: The ID of the project to find the priority for. - - Returns: - The priority found. - """ - # Avoid circular import - from arcade_jira.tools.priorities import ( - get_priority_by_id, - list_priorities_available_to_a_project, - ) - - # Try to get the priority by ID first - response = await get_priority_by_id( - context=context, - priority_id=priority_identifier, - atlassian_cloud_id=atlassian_cloud_id, - ) - if response.get("priority"): - return cast(dict, response["priority"]) - - # If not found, search by name - response = await list_priorities_available_to_a_project( - context=context, - project=project_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - - if response.get("error"): - raise JiraToolExecutionError(response["error"]) - - priorities = response["priorities_available"] - matches: list[dict[str, Any]] = [] - - for priority in priorities: - if priority["name"].casefold() == priority_identifier.casefold(): - matches.append(priority) - - if len(matches) == 1: - return cast(dict, matches[0]) - elif len(matches) > 1: - simplified_matches = [ - { - "id": match["id"], - "name": match["name"], - } - for match in matches - ] - raise MultipleItemsFoundError( - message=f"Multiple priorities found with name '{priority_identifier}'. " - f"Please provide a unique ID: {json.dumps(simplified_matches)}" - ) - - raise NotFoundError(message=f"Priority not found with ID or name '{priority_identifier}'") - - -async def find_unique_issue_type( - context: ToolContext, - issue_type_identifier: str, - project_id: str, - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - """Find a unique issue type by its ID or name that is associated with a project - - Args: - issue_type_identifier: The ID or name of the issue type to find. - project_id: The ID of the project to find the issue type for. - - Returns: - The issue type found. - """ - # Avoid circular import - from arcade_jira.tools.issues import get_issue_type_by_id, list_issue_types_by_project - - # Try to get the issue type by ID first - response = await get_issue_type_by_id( - context=context, - issue_type_id=issue_type_identifier, - atlassian_cloud_id=atlassian_cloud_id, - ) - if response.get("issue_type"): - return cast(dict, response["issue_type"]) - - # If not found, search by name - response = await list_issue_types_by_project( - context=context, - project=project_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - - if response.get("error"): - raise JiraToolExecutionError(response["error"]) - - issue_types = response["issue_types"] - matches: list[dict[str, Any]] = [] - - for issue_type in issue_types: - if issue_type["name"].casefold() == issue_type_identifier.casefold(): - matches.append(issue_type) - - if len(matches) == 1: - return cast(dict, matches[0]) - elif len(matches) > 1: - simplified_matches = [ - { - "id": match["id"], - "name": match["name"], - } - for match in matches - ] - raise MultipleItemsFoundError( - message=f"Multiple issue types found with name '{issue_type_identifier}'. " - f"Please provide a unique ID: {json.dumps(simplified_matches)}" - ) - - available_issue_types = json.dumps([ - { - "id": issue_type["id"], - "name": issue_type["name"], - } - for issue_type in issue_types - ]) - - raise NotFoundError( - message=f"Issue type not found with ID or name '{issue_type_identifier}'. " - f"These are the issue types available for the project: {available_issue_types}" - ) - - -async def find_unique_user( - context: ToolContext, - user_identifier: str, - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - """Find a unique user by their ID, key, email address, or display name.""" - # Avoid circular import - from arcade_jira.tools.users import get_user_by_id, get_users_without_id - - # Try to get the user by ID - response = await get_user_by_id( - context=context, - user_id=user_identifier, - atlassian_cloud_id=atlassian_cloud_id, - ) - if response.get("user"): - return cast(dict, response["user"]) - - # Search for the user name or email, if not found by ID - response = await get_users_without_id( - context=context, - name_or_email=user_identifier, - enforce_exact_match=True, - atlassian_cloud_id=atlassian_cloud_id, - ) - users = response["users"] - - if len(users) == 1: - return cast(dict, users[0]) - elif len(users) > 1: - simplified_users = [ - { - "id": user["id"], - "name": user["name"], - "email": user["email"], - } - for user in users - ] - raise MultipleItemsFoundError( - message=f"Multiple users found with name or email '{user_identifier}'. " - f"Please provide a unique ID: {json.dumps(simplified_users)}" - ) - - raise NotFoundError(message=f"User not found with ID, name or email '{user_identifier}'") - - -async def get_single_project( - context: ToolContext, - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - from arcade_jira.tools.projects import list_projects - - projects = await paginate_all_items( - context=context, - tool=list_projects, - response_items_key="projects", - atlassian_cloud_id=atlassian_cloud_id, - ) - - if len(projects) == 0: - raise NotFoundError(message="No projects found in this account.") - - if len(projects) == 1: - return cast(dict[str, Any], projects[0]) - - available_projects_str = json.dumps([ - { - "id": project["id"], - "name": project["name"], - } - for project in projects - ]) - - raise MultipleItemsFoundError(message=f"Multiple projects found: {available_projects_str}") - - -def build_file_data( - filename: str, - file_content_str: str | None, - file_content_base64: str | None, - file_type: str | None = None, - file_encoding: str = "utf-8", -) -> dict[str, tuple]: - if file_content_str is not None: - try: - file_content = file_content_str.encode(file_encoding) - except LookupError as exc: - raise ToolExecutionError(message=f"Unknown encoding: {file_encoding}") from exc - except Exception as exc: - raise ToolExecutionError( - message=f"Failed to encode file content string with {file_encoding} " - f"encoding: {exc!s}" - ) from exc - elif file_content_base64 is not None: - try: - file_content = base64.b64decode(file_content_base64) - except Exception as exc: - raise ToolExecutionError( - message=f"Failed to decode base64 file content: {exc!s}" - ) from exc - - if not file_type: - # guess_type returns None if the file type is not recognized - file_type = mimetypes.guess_type(filename)[0] - - if file_type: - return {"file": (filename, file_content, file_type)} - - return {"file": (filename, file_content)} - - -def build_adf_doc(text: str) -> dict: - return { - "type": "doc", - "version": 1, - "content": [ - { - "type": "paragraph", - "content": [{"type": "text", "text": text}], - } - for text in text.split("\n") - ], - } - - -async def paginate_all_items( - context: ToolContext, - tool: Callable, - response_items_key: str, - limit: int | None = None, - offset: int | None = None, - **kwargs: Any, -) -> list[Any]: - """Paginate all items from a tool.""" - keep_paginating = True - items: list[Any] = [] - - if limit is not None: - kwargs["limit"] = limit - - if offset is not None: - kwargs["offset"] = offset - - while keep_paginating: - response = await tool(context, **kwargs) - - if response.get("error"): - raise JiraToolExecutionError(response["error"]) - - next_offset = response["pagination"].get("next_offset") - kwargs["offset"] = next_offset - keep_paginating = isinstance(next_offset, int) - items.extend(response[response_items_key]) - - return items - - -async def paginate_all_priority_schemes( - context: ToolContext, - atlassian_cloud_id: str | None = None, -) -> list[dict]: - """Get all priority schemes.""" - # Avoid circular import - from arcade_jira.tools.priorities import list_priority_schemes - - return await paginate_all_items( - context=context, - tool=list_priority_schemes, - response_items_key="priority_schemes", - atlassian_cloud_id=atlassian_cloud_id, - ) - - -async def paginate_all_priorities_by_priority_scheme( - context: ToolContext, - scheme_id: str, - atlassian_cloud_id: str | None = None, -) -> list[dict]: - """Get all priorities associated with a priority scheme.""" - # Avoid circular import - from arcade_jira.tools.priorities import list_priorities_associated_with_a_priority_scheme - - return await paginate_all_items( - context, - list_priorities_associated_with_a_priority_scheme, - "priorities", - scheme_id=scheme_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - - -async def validate_issue_args( - context: ToolContext, - due_date: str | None, - project: str | None, - issue_type: str | None, - priority: str | None, - parent_issue: str | None, - atlassian_cloud_id: str | None = None, -) -> tuple[dict | None, dict | None, str | dict | None, str | dict | None, dict | None]: - if due_date and not is_valid_date_string(due_date): - return ( - {"error": f"Invalid `due_date` format: '{due_date}'. Please use YYYY-MM-DD."}, - None, - None, - None, - None, - ) - - if not project and not parent_issue: - return ( - {"error": "Must provide either `project` or `parent_issue` argument."}, - None, - None, - None, - None, - ) - - error: dict[str, Any] | None = None - project_data = await get_project_by_project_identifier_or_by_parent_issue( - context=context, - project=project, - parent_issue_id=parent_issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - issue_type_data: str | dict[str, Any] | None = None - priority_data: str | dict[str, Any] | None = None - parent_issue_data: dict[str, Any] | None = None - - if project_data.get("error"): - error = project_data - return error, None, issue_type_data, priority_data, parent_issue_data - - error, issue_type_data = await resolve_issue_type( - context=context, - issue_type=issue_type, - project_data=project_data, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return error, project_data, issue_type_data, priority_data, parent_issue_data - - error, priority_data = await resolve_issue_priority( - context=context, - priority=priority, - project_data=project_data, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return error, project_data, issue_type_data, priority_data, parent_issue_data - - error, parent_issue_data = await resolve_parent_issue( - context=context, - parent_issue=parent_issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - if error: - return error, project_data, issue_type_data, priority_data, parent_issue_data - - return None, project_data, issue_type_data, priority_data, parent_issue_data - - -async def resolve_issue_type( - context: ToolContext, - issue_type: str | None, - project_data: dict, - atlassian_cloud_id: str | None = None, -) -> tuple[dict[str, Any] | None, str | dict[str, Any] | None]: - if issue_type == "": - return None, "" - elif issue_type: - try: - response = await find_unique_issue_type( - context=context, - issue_type_identifier=issue_type, - project_id=project_data["id"], - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as exc: - return {"error": exc.message}, None - else: - return None, response - - return None, None - - -async def resolve_issue_priority( - context: ToolContext, - priority: str | None, - project_data: dict, - atlassian_cloud_id: str | None = None, -) -> tuple[dict[str, Any] | None, str | dict[str, Any] | None]: - if priority == "": - return None, "" - elif priority: - try: - priority_data = await find_unique_priority( - context=context, - priority_identifier=priority, - project_id=project_data["id"], - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as exc: - return {"error": exc.message}, None - else: - return None, priority_data - - return None, None - - -async def resolve_parent_issue( - context: ToolContext, - parent_issue: str | None, - atlassian_cloud_id: str | None = None, -) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: - if parent_issue == "": - return {"error": "Parent issue cannot be empty"}, None - elif parent_issue: - from arcade_jira.tools.issues import get_issue_by_id # Avoid circular import - - try: - parent_issue_data = await get_issue_by_id( - context=context, - issue=parent_issue, - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as exc: - return {"error": exc.message}, None - else: - return None, parent_issue_data["issue"] - - return None, None - - -async def get_project_by_project_identifier_or_by_parent_issue( - context: ToolContext, - project: str | None, - parent_issue_id: str | None, - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - from arcade_jira.tools.issues import get_issue_by_id # Avoid circular import - - if not project and not parent_issue_id: - return {"error": "Must provide either `project` or `parent_issue_id` argument."} - - if not project: - parent_issue_data = await get_issue_by_id( - context=context, - issue=parent_issue_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - if parent_issue_data.get("error"): - return {"error": f"Parent issue not found with ID {parent_issue_id}."} - project = cast(str, parent_issue_data["project"]["id"]) - - try: - project_data = await find_unique_project( - context=context, - project_identifier=project, - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as exc: - return {"error": exc.message} - - return project_data - - -async def resolve_issue_users( - context: ToolContext, - assignee: str | None, - reporter: str | None, - atlassian_cloud_id: str | None = None, -) -> tuple[dict | None, str | dict | None, str | dict | None]: - assignee_data: str | dict | None = None - reporter_data: str | dict | None = None - - if (not assignee and assignee != "") and (not reporter and reporter != ""): - return None, None, None - - if assignee == "": - assignee_data = "" - elif assignee: - try: - assignee_data = await find_unique_user( - context=context, - user_identifier=assignee, - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as exc: - return {"error": exc.message}, assignee_data, reporter_data - - if reporter == "": - reporter_data = "" - elif reporter: - try: - reporter_data = await find_unique_user( - context=context, - user_identifier=reporter, - atlassian_cloud_id=atlassian_cloud_id, - ) - except JiraToolExecutionError as exc: - return {"error": exc.message}, assignee_data, reporter_data - - return None, assignee_data, reporter_data - - -async def find_priorities_by_project( - context: ToolContext, - project: dict[str, Any], - atlassian_cloud_id: str | None = None, -) -> dict[str, Any]: - # Avoid circular import - from arcade_jira.tools.priorities import list_projects_associated_with_a_priority_scheme - - scheme_ids: set[str] = set() - priority_ids: set[str] = set() - priorities: list[dict[str, Any]] = [] - - priority_schemes = await paginate_all_priority_schemes( - context=context, - atlassian_cloud_id=atlassian_cloud_id, - ) - - if not priority_schemes: - raise NotFoundError("No priority schemes found") # noqa: TRY003 - - projects_by_scheme = await asyncio.gather(*[ - list_projects_associated_with_a_priority_scheme( - context=context, - scheme_id=scheme["id"], - project=project["id"], - atlassian_cloud_id=atlassian_cloud_id, - ) - for scheme in priority_schemes - ]) - - for scheme_index, scheme_projects in enumerate(projects_by_scheme): - if scheme_projects.get("error"): - return cast(dict, scheme_projects) - - for scheme_project in scheme_projects["projects"]: - if scheme_project["id"] == project["id"]: - scheme = priority_schemes[scheme_index] - scheme_ids.add(scheme["id"]) - break - - if not scheme_ids: - return {"error": f"No priority schemes found for the project {project['id']}"} - - priorities_by_scheme = await asyncio.gather(*[ - paginate_all_priorities_by_priority_scheme( - context=context, - scheme_id=scheme_id, - atlassian_cloud_id=atlassian_cloud_id, - ) - for scheme_id in scheme_ids - ]) - - for priorities_available in priorities_by_scheme: - for priority in priorities_available: - if priority["id"] in priority_ids: - continue - priority_ids.add(priority["id"]) - priorities.append(priority) - - return { - "project": { - "id": project["id"], - "key": project["key"], - "name": project["name"], - }, - "priorities_available": priorities, - } - - -def build_issue_update_request_body( - title: str | None, - description: str | None, - environment: str | None, - due_date: str | None, - parent_issue: dict | None, - issue_type: str | dict | None, - priority: str | dict | None, - assignee: str | dict | None, - reporter: str | dict | None, - labels: list[str] | None, -) -> dict[str, Any]: - body: dict[str, dict[str, Any]] = {"fields": {}, "update": {}} - - build_issue_update_text_fields(body, title, description, environment) - build_issue_update_classifier_fields(body, issue_type, priority) - build_issue_update_user_fields(body, assignee, reporter) - build_issue_update_hierarchy_fields(body, parent_issue) - build_issue_update_date_fields(body, due_date) - - if labels == []: - body["update"]["labels"] = [{"set": None}] - elif labels: - body["fields"]["labels"] = labels - - return body - - -def build_issue_update_text_fields( - body: dict, - title: str | None, - description: str | None, - environment: str | None, -) -> dict[str, dict[str, Any]]: - if title == "": - raise ValueError("Title cannot be empty") # noqa: TRY003 - elif title: - body["fields"]["summary"] = title - - if description == "": - body["update"]["description"] = [{"set": None}] - elif description: - body["fields"]["description"] = build_adf_doc(description) - - if environment == "": - body["update"]["environment"] = [{"set": None}] - elif environment: - body["fields"]["environment"] = build_adf_doc(environment) - - return body - - -def build_issue_update_user_fields( - body: dict, - assignee: str | dict | None, - reporter: str | dict | None, -) -> dict[str, dict[str, Any]]: - if assignee == "": - body["update"]["assignee"] = [{"set": None}] - elif isinstance(assignee, dict): - body["fields"]["assignee"] = {"id": assignee["id"]} - elif assignee is not None: - raise ValueError(f"Invalid assignee: '{assignee}'") # noqa: TRY003 - - if reporter == "": - body["update"]["reporter"] = [{"set": None}] - elif isinstance(reporter, dict): - body["fields"]["reporter"] = {"id": reporter["id"]} - elif reporter is not None: - raise ValueError(f"Invalid reporter: '{reporter}'") # noqa: TRY003 - - return body - - -def build_issue_update_classifier_fields( - body: dict, - issue_type: str | dict | None, - priority: str | dict | None, -) -> dict[str, dict[str, Any]]: - if issue_type == "": - raise ValueError("Issue type cannot be empty") # noqa: TRY003 - elif isinstance(issue_type, dict): - body["fields"]["issuetype"] = {"id": issue_type["id"]} - elif issue_type is not None: - raise ValueError(f"Invalid issue type: '{issue_type}'") # noqa: TRY003 - - if priority == "": - raise ValueError("Priority cannot be empty") # noqa: TRY003 - elif isinstance(priority, dict): - body["fields"]["priority"] = {"id": priority["id"]} - elif priority is not None: - raise ValueError(f"Invalid priority: '{priority}'") # noqa: TRY003 - - return body - - -def build_issue_update_hierarchy_fields( - body: dict, - parent_issue: dict | None, -) -> dict[str, dict[str, Any]]: - if parent_issue: - body["fields"]["parent"] = {"id": parent_issue["id"]} - - return body - - -def build_issue_update_date_fields( - body: dict, - due_date: str | None, -) -> dict[str, dict[str, Any]]: - if due_date == "": - body["update"]["duedate"] = [{"set": None}] - elif due_date: - body["fields"]["duedate"] = due_date - - return body - - -def extract_id(field: Any) -> dict[str, str] | None: - return {"id": field["id"]} if isinstance(field, dict) else None - - -async def resolve_cloud_id(context: ToolContext, cloud_id: str | None) -> str: - try: - uuid.UUID(cloud_id) - except (AttributeError, TypeError, ValueError): - is_valid_uuid = False - else: - is_valid_uuid = True - - # If this is already a valid Cloud ID, return it - if is_valid_uuid: - return cast(str, cloud_id) - - # If not, it's possibly a Cloud name, so we try to match that. - if isinstance(cloud_id, str) and cloud_id != "": - return await get_cloud_id_by_cloud_name(context, cloud_name=cloud_id) - - # As a last resort, try to get a unique Cloud ID from the available Atlassian Clouds - return await get_unique_cloud_id(context) - - -async def get_cloud_id_by_cloud_name(context: ToolContext, cloud_name: str) -> str: - from arcade_jira.tools.cloud import get_available_atlassian_clouds # Avoid circular import - - response = await get_available_atlassian_clouds(context) - clouds = response["clouds_available"] - - for cloud in clouds: - if ( - # Case-insensitive match in case of cloud names. - cloud["atlassian_cloud_name"].casefold() == cloud_name.casefold() - # Match the ID as well just in case. Who knows, Atlassian may start - # using some weird values as cloud IDs. If the value provided matches - # an ID in the list of clouds, then it's a match. - or cloud["atlassian_cloud_id"] == cloud_name - ): - return cast(str, cloud["atlassian_cloud_id"]) - - message = f"No Atlassian Cloud found matching '{cloud_name}'" - available_clouds_str = f"Available Atlassian Clouds:\n\n```json\n{json.dumps(clouds)}\n```" - - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=available_clouds_str, - ) - - -async def get_unique_cloud_id(context: ToolContext) -> str: - from arcade_jira.tools.cloud import get_available_atlassian_clouds # Avoid circular import - - response = await get_available_atlassian_clouds(context) - clouds = response["clouds_available"] - - if len(clouds) == 0: - message = "No Atlassian Cloud is available. Please authorize an Atlassian Cloud." - raise ToolExecutionError( - message=message, - developer_message=message, - ) - - if len(clouds) > 1: - message = ( - "Multiple Atlassian Clouds are available. One Cloud ID has to be selected and provided " - "in the tool call using the `atlassian_cloud_id` argument." - ) - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=( - f"Available Atlassian Clouds:\n\n```json\n{json.dumps(clouds)}\n```" - ), - ) - - return cast(str, clouds[0]["atlassian_cloud_id"]) - - -async def check_if_cloud_is_authorized( - context: ToolContext, - cloud: dict[str, Any], - semaphore: asyncio.Semaphore, -) -> dict[str, Any] | bool: - """Confirm whether an Atlassian Cloud is authorized for the current auth token. - - The Atlassian available-resources endpoint may return Clouds that have not been - authorized by the current user. This is a known Atlassian OAuth2 API bug [1]. - - We run this check against the '/myself' endpoint to confirm whether the Cloud - was actually authorized for the current auth token. - - [1] Reference about the Atlassian API bug: - https://community.developer.atlassian.com/t/urgent-api-accessible-resources-endpoint-returns-sites-resources-that-are-not-permitted-by-the-user/66899 - Archived (2025-07-22): https://archive.is/0noNX - """ - cloud_id = cloud["atlassian_cloud_id"] - - try: - async with semaphore, httpx.AsyncClient() as client: - response = await client.get( - f"{JIRA_BASE_URL}/{cloud_id}/rest/api/3/myself", - headers={"Authorization": f"Bearer {context.get_auth_token_or_empty()}"}, - ) - - if response.status_code == 200: - return cloud - - elif response.status_code == 429 or response.status_code >= 500: - response.raise_for_status() - - else: - return False - - except Exception as exc: - message = ( - f"An error occurred while checking if the Atlassian Cloud with ID '{cloud_id}' " - "is authorized." - ) - developer_message = f"{message} Error info: {type(exc).__name__}: {exc!s}" - - raise ToolExecutionError( - message=message, - developer_message=developer_message, - ) from exc - - # This is necessary otherwise mypy will complain - else: - return False diff --git a/toolkits/jira/conftest.py b/toolkits/jira/conftest.py deleted file mode 100644 index 8a955b65..00000000 --- a/toolkits/jira/conftest.py +++ /dev/null @@ -1,228 +0,0 @@ -import random -import string -import uuid -from collections.abc import Callable, Generator -from typing import Any -from unittest.mock import MagicMock, patch - -import httpx -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def fake_auth_token(generate_random_str: Callable) -> str: - return generate_random_str() - - -@pytest.fixture -def fake_cloud_id() -> str: - return str(uuid.uuid4()) - - -@pytest.fixture -def fake_cloud_name(generate_random_str: Callable) -> str: - return generate_random_str() - - -@pytest.fixture -def generate_random_str() -> Callable[[int], str]: - def random_str_builder(length: int = 10) -> str: - return "".join(random.choices(string.ascii_letters + string.digits, k=length)) # noqa: S311 - - return random_str_builder - - -@pytest.fixture -def generate_random_email(generate_random_str: Callable) -> Callable[[str | None, str | None], str]: - def random_email_generator(name: str | None = None, domain: str | None = None) -> str: - name = name or generate_random_str() - domain = domain or f"{generate_random_str()}.com" - return f"{name}@{domain}" - - return random_email_generator - - -@pytest.fixture -def generate_random_url(generate_random_str: Callable) -> Callable[[str], str]: - def random_url_generator(base_url: str | None = None) -> str: - base_url = base_url or f"https://{generate_random_str()}.com" - return f"{base_url}/{generate_random_str()}" - - return random_url_generator - - -@pytest.fixture -def mock_context(fake_auth_token: str) -> ToolContext: - mock_auth = ToolAuthorizationContext(token=fake_auth_token) - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_httpx_client(): - with patch("arcade_jira.client.httpx") as mock_httpx: - yield mock_httpx.AsyncClient().__aenter__.return_value - - -@pytest.fixture -def mock_httpx_response() -> Callable[[int, dict], httpx.Response]: - def generate_mock_httpx_response(status_code: int, json_data: dict) -> httpx.Response: - response = MagicMock(spec=httpx.Response) - response.status_code = status_code - response.json.return_value = json_data - return response - - return generate_mock_httpx_response - - -@pytest.fixture(autouse=True) -def mock_get_available_atlassian_clouds_globally( - fake_cloud_id: str, - fake_cloud_name: str, -) -> Generator[None, None, None]: - """Mock get_available_atlassian_clouds for all tests.""" - - def mock_func(context: ToolContext) -> list[dict]: - return { - "clouds_available": [ - { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - ] - } - - with patch( - "arcade_jira.tools.cloud.get_available_atlassian_clouds", - side_effect=mock_func, - ): - yield - - -@pytest.fixture -def build_user_dict( - generate_random_str: Callable[[int], str], - generate_random_email: Callable[[str | None, str | None], str], -) -> Callable[[str | None, str | None, str | None, bool, str], dict]: - def user_dict_builder( - id_: str | None = None, - email: str | None = None, - display_name: str | None = None, - active: bool = True, - account_type: str = "atlassian", - ) -> dict[str, Any]: - display_name = display_name or generate_random_str() - user = { - "accountId": id_ or generate_random_str(), - "displayName": display_name, - "emailAddress": email or generate_random_email(name=display_name), - "active": active, - "accountType": account_type, - } - - return user - - return user_dict_builder - - -@pytest.fixture -def build_project_dict( - generate_random_str: Callable, - generate_random_url: Callable, -) -> Callable[[str | None, str | None, str | None, str | None, str | None], dict]: - def project_dict_builder( - id_: str | None = None, - key: str | None = None, - name: str | None = None, - description: str | None = None, - url: str | None = None, - ) -> dict[str, Any]: - return { - "id": id_ or generate_random_str(), - "key": key or generate_random_str(), - "name": name or generate_random_str(), - "description": description or generate_random_str(), - "url": url or generate_random_url(), - } - - return project_dict_builder - - -@pytest.fixture -def build_project_search_response_dict() -> Callable[[list[dict], bool], dict]: - def project_search_response_builder(projects: list[dict], is_last: bool = True) -> dict: - return { - "values": projects, - "isLast": is_last, - } - - return project_search_response_builder - - -@pytest.fixture -def build_priority_dict( - generate_random_str: Callable, -) -> Callable[[str | None, str | None, str | None], dict]: - def priority_dict_builder( - id_: str | None = None, - name: str | None = None, - description: str | None = None, - ) -> dict: - return { - "id": id_ or generate_random_str(), - "name": name or generate_random_str(), - "description": description or generate_random_str(), - } - - return priority_dict_builder - - -@pytest.fixture -def build_issue_type_dict( - generate_random_str: Callable, -) -> Callable[[str | None, str | None, str | None], dict]: - def issue_type_dict_builder( - id_: str | None = None, name: str | None = None, description: str | None = None - ) -> dict: - return { - "id": id_ or generate_random_str(), - "name": name or generate_random_str(), - "description": description or generate_random_str(), - } - - return issue_type_dict_builder - - -@pytest.fixture -def build_issue_types_response_dict() -> Callable[[list[dict]], dict]: - def issue_types_response_builder( - issue_types: list[dict], - is_last: bool = True, - ) -> dict: - return { - "issueTypes": issue_types, - "isLast": is_last, - } - - return issue_types_response_builder - - -@pytest.fixture -def build_priority_scheme_dict( - generate_random_str: Callable, -) -> Callable[[str | None, str | None, str | None, bool], dict]: - def priority_scheme_dict_builder( - id_: str | None = None, - name: str | None = None, - description: str | None = None, - is_default: bool = False, - ) -> dict: - return { - "id": id_ or generate_random_str(), - "name": name or generate_random_str(), - "description": description or generate_random_str(), - "isDefault": is_default, - } - - return priority_scheme_dict_builder diff --git a/toolkits/jira/evals/eval_create_update_issues.py b/toolkits/jira/evals/eval_create_update_issues.py deleted file mode 100644 index 59e18ee6..00000000 --- a/toolkits/jira/evals/eval_create_update_issues.py +++ /dev/null @@ -1,389 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_evals.critic import BinaryCritic -from arcade_tdk import ToolCatalog - -import arcade_jira -from arcade_jira.critics import ( - CaseInsensitiveBinaryCritic, - CaseInsensitiveListOfStringsBinaryCritic, -) -from arcade_jira.tools.issues import ( - add_labels_to_issue, - create_issue, - remove_labels_from_issue, - update_issue, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_jira) - - -@tool_eval() -def create_issue_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Create issue eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create issue", - user_message="Create a 'High' priority task for John Doe with the following properties: " - "title: 'Test issue', " - "description: 'This is a test issue', " - "project: 'ENG-123', " - "issue_type: 'Task', " - "due on '2025-06-30'. " - "Label it with Hello and World.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_issue, - args={ - "title": "Test issue", - "description": "This is a test issue", - "project": "ENG-123", - "issue_type": "Task", - "priority": "High", - "assignee": "John Doe", - "due_date": "2025-06-30", - "labels": ["Hello", "World"], - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="title", weight=1 / 8), - CaseInsensitiveBinaryCritic(critic_field="description", weight=1 / 8), - CaseInsensitiveBinaryCritic(critic_field="project", weight=1 / 8), - CaseInsensitiveBinaryCritic(critic_field="issue_type", weight=1 / 8), - CaseInsensitiveBinaryCritic(critic_field="priority", weight=1 / 8), - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=1 / 8), - BinaryCritic(critic_field="due_date", weight=1 / 8), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=1 / 8), - ], - ) - - suite.add_case( - name="Create issue with parent and reporter", - user_message=( - "Create a task for John Doe to 'Implement message queue service' " - "as a child of the issue ENG-321 and reported by Jenifer Bear. " - "It should be due on 2025-06-30. " - "Label it with 'Project XYZ'." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_issue, - args={ - "title": "Implement message queue service", - "parent_issue": "ENG-321", - "issue_type": "Task", - "assignee": "John Doe", - "reporter": "Jenifer Bear", - "due_date": "2025-06-30", - "labels": ["Project XYZ"], - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="title", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="parent_issue", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="issue_type", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="reporter", weight=1 / 7), - BinaryCritic(critic_field="due_date", weight=1 / 7), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=1 / 7), - ], - ) - - return suite - - -@tool_eval() -def labels_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Labels eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Add labels", - user_message="Add the labels 'Hello' and 'World' to the issue ENG-123.", - expected_tool_calls=[ - ExpectedToolCall( - func=add_labels_to_issue, - args={ - "issue": "ENG-123", - "labels": ["Hello", "World"], - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=0.5), - ], - ) - - suite.add_case( - name="Add labels without notifying watchers", - user_message=( - "Add the labels 'Hello' and 'World' to the issue ENG-123. Do not notify watchers." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=add_labels_to_issue, - args={ - "issue": "ENG-123", - "labels": ["Hello", "World"], - "notify_watchers": False, - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=1 / 3), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=1 / 3), - BinaryCritic(critic_field="notify_watchers", weight=1 / 3), - ], - ) - - suite.add_case( - name="Remove labels", - user_message="Remove the labels 'Hello' and 'World' from the issue ENG-123.", - expected_tool_calls=[ - ExpectedToolCall( - func=remove_labels_from_issue, - args={ - "issue": "ENG-123", - "labels": ["Hello", "World"], - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=0.5), - ], - ) - - suite.add_case( - name="Remove labels without notifying watchers", - user_message=( - "Remove the labels 'Hello' and 'World' from the issue ENG-123. Do not notify watchers." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=remove_labels_from_issue, - args={ - "issue": "ENG-123", - "labels": ["Hello", "World"], - "notify_watchers": False, - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=1 / 3), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=1 / 3), - BinaryCritic(critic_field="notify_watchers", weight=1 / 3), - ], - ) - - return suite - - -@tool_eval() -def update_issue_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Update issue eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Update issue with new assignee", - user_message="Change the assignee of the ENG-123 issue to John Doe.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "assignee": "John Doe", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=0.5), - ], - ) - - suite.add_case( - name="Update issue with new priority", - user_message="Set the priority of the ENG-123 issue to high.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "priority": "High", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="priority", weight=0.5), - ], - ) - - suite.add_case( - name="Update issue with new due date", - user_message="Set the due date of the ENG-123 issue to 2025-06-30.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "due_date": "2025-06-30", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="due_date", weight=0.5), - ], - ) - - suite.add_case( - name="Update issue with new labels", - user_message="Change the labels in the ENG-123 issue to 'Hello' and 'World'.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "labels": ["Hello", "World"], - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveListOfStringsBinaryCritic(critic_field="labels", weight=0.5), - ], - ) - - suite.add_case( - name="Update issue with new title and description", - user_message=( - "Change the title and description of the ENG-123 issue to 'Test issue' " - "and 'This is a test issue'." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "title": "Test issue", - "description": "This is a test issue", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=1 / 3), - CaseInsensitiveBinaryCritic(critic_field="title", weight=1 / 3), - CaseInsensitiveBinaryCritic(critic_field="description", weight=1 / 3), - ], - ) - - suite.add_case( - name="Clear due date", - user_message="Clear the due date of the issue ENG-123.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "due_date": "", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="due_date", weight=0.5), - ], - ) - - suite.add_case( - name="Remove assignee", - user_message="Remove the assignee from the issue ENG-123.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "assignee": "", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=0.5), - ], - ) - - suite.add_case( - name="Remove assignee", - user_message="Remove the assignee from the issue ENG-123 without notifying anyone.", - expected_tool_calls=[ - ExpectedToolCall( - func=update_issue, - args={ - "issue": "ENG-123", - "assignee": "", - "notify_watchers": False, - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=1 / 3), - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=1 / 3), - BinaryCritic(critic_field="notify_watchers", weight=1 / 3), - ], - ) - - return suite diff --git a/toolkits/jira/evals/eval_get_issues.py b/toolkits/jira/evals/eval_get_issues.py deleted file mode 100644 index 5efc08b2..00000000 --- a/toolkits/jira/evals/eval_get_issues.py +++ /dev/null @@ -1,443 +0,0 @@ -import json - -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_evals.critic import BinaryCritic -from arcade_tdk import ToolCatalog - -import arcade_jira -from arcade_jira.critics import CaseInsensitiveBinaryCritic, HasSubstringCritic -from arcade_jira.tools.issues import ( - get_issue_by_id, - get_issues_without_id, - list_issues, - search_issues_with_jql, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_jira) - - -@tool_eval() -def get_issue_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Get issue by ID eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get issue by ID", - user_message="Get the issue with ID '10000'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_by_id, - args={ - "issue_id": "10000", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="issue_id", weight=1.0), - ], - ) - - suite.add_case( - name="Get issue by Key", - user_message="Get the issue ENG-103.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_by_id, - args={ - "issue_id": "ENG-103", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="issue_id", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def get_issues_without_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Get issues without an ID", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks. " - "Today is 2025-05-27 (Tuesday)." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get issues by keywords", - user_message="Find the issue about implementing the message queue.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issues_without_id, - args={ - "keywords": "message queue", - }, - ), - ], - rubric=rubric, - critics=[ - HasSubstringCritic(critic_field="keywords", weight=1.0), - ], - ) - - suite.add_case( - name="Get issues by due date", - user_message="Which issues are due this month?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issues_without_id, - args={ - "due_from": "2025-05-01", - "due_until": "2025-05-31", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="due_from", weight=0.5), - BinaryCritic(critic_field="due_until", weight=0.5), - ], - ) - - suite.add_case( - name="Get issues by assignee, due date, status, priority and issue type", - user_message=( - "Find task issues assigned to John Doe that are in progress, " - "with high priority, and due until the end of this month" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_issues_without_id, - args={ - "assignee": "John Doe", - "due_from": None, - "due_until": "2025-05-31", - "status": "in progress", - "priority": "high", - "issue_type": "task", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="due_from", weight=0.1), - BinaryCritic(critic_field="due_until", weight=0.1), - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=0.2), - CaseInsensitiveBinaryCritic(critic_field="status", weight=0.2), - CaseInsensitiveBinaryCritic(critic_field="priority", weight=0.2), - CaseInsensitiveBinaryCritic(critic_field="issue_type", weight=0.2), - ], - ) - - suite.add_case( - name="Get issues by label and project name", - user_message="Find issues labeled with version 2 in the Engineering project", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issues_without_id, - args={ - "project": "Engineering", - "labels": ["version 2"], - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=0.5), - BinaryCritic(critic_field="labels", weight=0.5), - ], - ) - - suite.add_case( - name="Get issues by parent issue", - user_message="Get the children issues of ENG-123", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issues_without_id, - args={ - "parent_issue": "ENG-123", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="parent_issue", weight=1.0), - ], - ) - - suite.add_case( - name="Paginate issues in multiple chat turns", - user_message="Get the next page of issues", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issues_without_id, - args={ - "assignee": "john doe", - "priority": "high", - "status": "in progress", - "issue_type": "task", - "limit": 2, - "offset": 4, - "next_page_token": "1234567890", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="assignee", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="priority", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="status", weight=1 / 7), - CaseInsensitiveBinaryCritic(critic_field="issue_type", weight=1 / 7), - BinaryCritic(critic_field="limit", weight=1 / 7), - BinaryCritic(critic_field="offset", weight=1 / 7), - BinaryCritic(critic_field="next_page_token", weight=1 / 7), - ], - additional_messages=[ - { - "role": "user", - "content": ( - "Find 2 tasks assigned to John Doe that are in progress, with high priority" - ), - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Jira_GetIssuesWithoutId", - "arguments": json.dumps({ - "assignee": "John Doe", - "priority": "high", - "status": "in progress", - "issue_type": "task", - "limit": 2, - "offset": 0, - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "issues": [ - { - "id": "10001", - "key": "ENG-101", - "summary": "Implement the message queue", - "assignee": { - "id": "10010", - "name": "John Doe", - "email": "john.doe@example.com", - }, - "status": { - "id": "10020", - "name": "In Progress", - }, - "priority": { - "id": "10030", - "name": "High", - }, - "issue_type": { - "id": "10040", - "name": "Task", - }, - "project": { - "id": "10050", - "key": "ENG", - "name": "Engineering", - }, - }, - { - "id": "10002", - "key": "ENG-102", - "summary": "Deploy the message queue system", - "assignee": { - "id": "10010", - "name": "John Doe", - "email": "john.doe@example.com", - }, - "status": { - "id": "10020", - "name": "In Progress", - }, - "priority": { - "id": "10030", - "name": "High", - }, - "issue_type": { - "id": "10040", - "name": "Task", - }, - "project": { - "id": "10050", - "key": "ENG", - "name": "Engineering", - }, - }, - ], - "pagination": { - "limit": 2, - "total_results": 2, - "next_page_token": "1234567890", - }, - }), - "tool_call_id": "call_1", - "name": "Jira_GetIssuesWithoutId", - }, - { - "role": "assistant", - "content": ( - "Here are two issues:\n\n" - "1. ENG-101: Implement the message queue\n" - "2. ENG-102: Deploy the message queue system" - ), - }, - ], - ) - - return suite - - -@tool_eval() -def search_issues_with_jql_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Search issues with JQL", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks. " - "Today is 2025-05-27 (Tuesday)." - ), - catalog=catalog, - rubric=rubric, - ) - - jql_query_str = 'text ~ "message queue" AND dueDate <= 2025-05-31' - - suite.add_case( - name="Search issues by keywords", - user_message=f"Search for up to 10 issues using the JQL query: {jql_query_str}", - expected_tool_calls=[ - ExpectedToolCall( - func=search_issues_with_jql, - args={ - "jql": jql_query_str, - "limit": 10, - "offset": 0, - }, - ), - ], - rubric=rubric, - critics=[ - HasSubstringCritic(critic_field="jql", weight=1 / 3), - BinaryCritic(critic_field="limit", weight=1 / 3), - BinaryCritic(critic_field="offset", weight=1 / 3), - ], - ) - - return suite - - -@tool_eval() -def list_issues_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="List issues eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get me any one issue in Jira", - user_message="Get me one issue in Jira.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issues, - args={ - "limit": 1, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="next_page_token", weight=0.5), - ], - ) - - suite.add_case( - name="List 10 issues in Jira", - user_message="List 10 issues in Jira.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issues, - args={ - "limit": 10, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="next_page_token", weight=0.5), - ], - ) - - suite.add_case( - name="List 10 issues in the Arcade project", - user_message="List 10 issues in the Arcade project.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issues, - args={ - "project": "Arcade", - "limit": 50, - "next_page_token": None, - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="project", weight=1 / 3), - BinaryCritic(critic_field="limit", weight=1 / 3), - BinaryCritic(critic_field="next_page_token", weight=1 / 3), - ], - ) - - return suite diff --git a/toolkits/jira/evals/eval_issue_types.py b/toolkits/jira/evals/eval_issue_types.py deleted file mode 100644 index c734fe70..00000000 --- a/toolkits/jira/evals/eval_issue_types.py +++ /dev/null @@ -1,241 +0,0 @@ -import json - -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_evals.critic import BinaryCritic -from arcade_tdk import ToolCatalog - -import arcade_jira -from arcade_jira.tools.issues import ( - get_issue_type_by_id, - list_issue_types_by_project, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_jira) - - -@tool_eval() -def list_issue_types_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="List issue types eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List issue types in a project (ID)", - user_message="List the issue types in the project with ID '1234567890'.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issue_types_by_project, - args={ - "project": "1234567890", - "limit": 200, - "offset": 0, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=0.8), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="offset", weight=0.1), - ], - ) - - suite.add_case( - name="List issue types in a project (Key)", - user_message="List the issue types in the project PRJ-1.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issue_types_by_project, - args={ - "project": "PRJ-1", - "limit": 200, - "offset": 0, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=0.8), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="offset", weight=0.1), - ], - ) - - suite.add_case( - name="List issue types in a project (name)", - user_message="List the issue types in the project 'Engineering'.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issue_types_by_project, - args={ - "project": "Engineering", - "limit": 200, - "offset": 0, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=0.8), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="offset", weight=0.1), - ], - ) - - suite.add_case( - name="List issue types in a project (name) with pagination in the prompt", - user_message="List 10 issue types in the project 'Engineering'. Skip the first 30 items.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issue_types_by_project, - args={ - "project": "Engineering", - "limit": 10, - "offset": 30, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=1 / 3), - BinaryCritic(critic_field="limit", weight=1 / 3), - BinaryCritic(critic_field="offset", weight=1 / 3), - ], - ) - - suite.add_case( - name="List issue types in a project (name) with pagination from chat history", - user_message="Get the next items.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_issue_types_by_project, - args={ - "project": "Engineering", - "limit": 2, - "offset": 2, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=1 / 3), - BinaryCritic(critic_field="limit", weight=1 / 3), - BinaryCritic(critic_field="offset", weight=1 / 3), - ], - additional_messages=[ - { - "role": "user", - "content": "List 2 issue types in the project 'Engineering'.", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Jira_ListIssueTypesByProject", - "arguments": json.dumps({ - "project": "Engineering", - "limit": 2, - "offset": 0, - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "project": { - "id": "10000", - "key": "Eng-1", - "name": "Engineering", - }, - "issue_types": [ - { - "id": "10001", - "name": "Bug", - "description": ( - "A bug is an error or flaw in a software application or website." - ), - }, - { - "id": "10002", - "name": "Task", - "description": "A task is a unit of work that needs to be completed.", - }, - ], - "pagination": { - "limit": 2, - "total_results": 2, - "next_offset": 2, - }, - }), - "tool_call_id": "call_1", - "name": "Jira_ListIssueTypesByProject", - }, - { - "role": "assistant", - "content": ( - "Here are two issue types in the project 'Engineering':\n\n1. Bug\n2. Task" - ), - }, - ], - ) - - return suite - - -@tool_eval() -def get_issue_type_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Get issue type by ID eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get issue type by ID", - user_message="Get the issue type with ID '10001'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_type_by_id, - args={ - "issue_type": "10001", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="project", weight=0.8), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="offset", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/jira/evals/eval_multi_cloud.py b/toolkits/jira/evals/eval_multi_cloud.py deleted file mode 100644 index 9a440c75..00000000 --- a/toolkits/jira/evals/eval_multi_cloud.py +++ /dev/null @@ -1,291 +0,0 @@ -import json -import uuid - -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_evals.critic import BinaryCritic -from arcade_tdk import ToolCatalog - -import arcade_jira -from arcade_jira.tools.comments import get_issue_comments -from arcade_jira.tools.issues import get_issue_by_id - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_jira) - - -@tool_eval() -def multi_cloud_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Atlassian multi-cloud evaluation suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Test calling tool without specifying a cloud id", - user_message="Get the issue with ID '10000'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_by_id, - args={ - "issue_id": "10000", - "atlassian_cloud_id": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="issue_id", weight=0.5), - BinaryCritic(critic_field="atlassian_cloud_id", weight=0.5), - ], - ) - - cloud_id = str(uuid.uuid4()) - - suite.add_case( - name="Test calling tool specifying a cloud id directly with the request", - user_message=f"Get the issue with ID '10000' in the Cloud with ID '{cloud_id}'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_by_id, - args={ - "issue_id": "10000", - "atlassian_cloud_id": cloud_id, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="issue_id", weight=0.5), - BinaryCritic(critic_field="atlassian_cloud_id", weight=0.5), - ], - ) - - cloud_1_id = str(uuid.uuid4()) - cloud_2_id = str(uuid.uuid4()) - available_clouds = [ - { - "atlassian_cloud_id": cloud_1_id, - "atlassian_cloud_name": "Foobar", - "atlassian_cloud_url": "https://foobar.atlassian.com", - }, - { - "atlassian_cloud_id": cloud_2_id, - "atlassian_cloud_name": "Quick Brown Fox", - "atlassian_cloud_url": "https://quickbrownfox.atlassian.com", - }, - ] - available_clouds_str = json.dumps(available_clouds) - - suite.add_case( - name="Test calling tool with multiple clouds error and specifying which cloud to use", - user_message="Let's use the Foobar Cloud", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_by_id, - args={ - "issue_id": "10000", - "atlassian_cloud_id": cloud_1_id, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="issue_id", weight=0.5), - BinaryCritic(critic_field="atlassian_cloud_id", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Get the issue with id '10000' in Jira"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Jira_GetIssueById", - "arguments": json.dumps({ - "issue": "10000", - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "name": "retryable_tool_call_error", - "message": ( - "Multiple Atlassian Clouds are available. One Cloud ID has to be selected " - "and provided in the tool call using the `atlassian_cloud_id` argument.", - ), - "developer_message": ( - "Multiple Atlassian Clouds are available. One Cloud ID has to be selected " - "and provided in the tool call using the `atlassian_cloud_id` argument.", - ), - "additional_prompt_content": ( - f"Available Atlassian Clouds:\n\n```json\n{available_clouds_str}\n```" - ), - }), - "tool_call_id": "call_1", - "name": "Jira_GetIssueById", - }, - { - "role": "assistant", - "content": ( - "Here is the list of available Atlassian clouds:\n\n" - "1. **Name:** Foobar\n" - " - **URL:** https://foobar.atlassian.com\n" - "2. **Name:** Quick Brown Fox\n" - " - **URL:** https://quickbrownfox.atlassian.com\n" - "Please select one of the above Clouds to get the Jira issue." - ), - }, - ], - ) - - suite.add_case( - name="Test calling tool one interaction after specifying a cloud id", - user_message="Get the comments on this issue", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue_comments, - args={ - "issue": "10000", - "atlassian_cloud_id": cloud_1_id, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="issue", weight=0.5), - BinaryCritic(critic_field="atlassian_cloud_id", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "Get the issue with id '10000' in Jira"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Jira_GetIssueById", - "arguments": json.dumps({ - "issue": "10000", - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "name": "retryable_tool_call_error", - "message": ( - "Multiple Atlassian Clouds are available. One Cloud ID has to be selected " - "and provided in the tool call using the `atlassian_cloud_id` argument.", - ), - "developer_message": ( - "Multiple Atlassian Clouds are available. One Cloud ID has to be selected " - "and provided in the tool call using the `atlassian_cloud_id` argument.", - ), - "additional_prompt_content": ( - f"Available Atlassian Clouds:\n\n```json\n{available_clouds_str}\n```" - ), - }), - "tool_call_id": "call_1", - "name": "Jira_GetIssueById", - }, - { - "role": "assistant", - "content": ( - "Here is the list of available Atlassian clouds:\n\n" - "1. **Name:** Foobar\n" - " - **URL:** https://foobar.atlassian.com\n" - "2. **Name:** Quick Brown Fox\n" - " - **URL:** https://quickbrownfox.atlassian.com\n" - "Please select one of the above Clouds to get the Jira issue." - ), - }, - {"role": "user", "content": "Let's use the Foobar Cloud from now on."}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_2", - "type": "function", - "function": { - "name": "Jira_GetIssueById", - "arguments": json.dumps({ - "issue": "10000", - "atlassian_cloud_id": cloud_1_id, - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "id": "10000", - "key": "ENG-101", - "assignee": { - "id": "10010", - "name": "John Doe", - "email": "john.doe@example.com", - }, - "description": "Implement the message queue", - "status": { - "id": "10020", - "name": "In Progress", - }, - "issuetype": { - "id": "10030", - "name": "Task", - }, - "project": { - "id": "10040", - "key": "ENG", - "name": "Engineering", - }, - }), - "tool_call_id": "call_2", - "name": "Jira_GetIssueById", - }, - { - "role": "assistant", - "content": ( - "Here is the issue:\n\n" - "1. **ID:** 10000\n" - " - **Key:** ENG-101\n" - " - **Assignee:** John Doe\n" - " - **Description:** Implement the message queue\n" - " - **Status:** In Progress\n" - " - **Issue Type:** Task\n" - " - **Project:** Engineering" - ), - }, - ], - ) - - return suite diff --git a/toolkits/jira/evals/eval_transitions.py b/toolkits/jira/evals/eval_transitions.py deleted file mode 100644 index 90429348..00000000 --- a/toolkits/jira/evals/eval_transitions.py +++ /dev/null @@ -1,152 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_jira -from arcade_jira.critics import ( - CaseInsensitiveBinaryCritic, -) -from arcade_jira.tools.transitions import ( - get_transition_by_status_name, - get_transitions_available_for_issue, - transition_issue_to_new_status, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_jira) - - -@tool_eval() -def transitions_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Transitions eval suite", - system_message=( - "You are an AI assistant with access to Jira tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get transitions available for issue", - user_message="Get the transitions available for the issue ENG-123.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_transitions_available_for_issue, - args={ - "issue": "ENG-123", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=1), - ], - ) - - suite.add_case( - name="Can I transition an issue to status 'Done'?", - user_message="Can I transition the issue ENG-123 to the status 'Done'?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_transitions_available_for_issue, - args={ - "issue": "ENG-123", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=1), - ], - ) - - suite.add_case( - name="Get transition by status name", - user_message="Get the transition for the issue ENG-123 and status 'Done'.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_transition_by_status_name, - args={ - "issue": "ENG-123", - "transition": "Done", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="transition", weight=0.5), - ], - ) - - suite.add_case( - name="Transition issue to a new status", - user_message="Transition the issue ENG-123 to the status 'Done'.", - expected_tool_calls=[ - ExpectedToolCall( - func=transition_issue_to_new_status, - args={ - "issue": "ENG-123", - "transition": "Done", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="transition", weight=0.5), - ], - ) - - suite.add_case( - name="Mark issue as done", - user_message="Mark the issue ENG-123 as done.", - expected_tool_calls=[ - ExpectedToolCall( - func=transition_issue_to_new_status, - args={ - "issue": "ENG-123", - "transition": "Done", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="transition", weight=0.5), - ], - ) - - suite.add_case( - name="Update issue with new status", - user_message="Update the issue ENG-123 status to in progress.", - expected_tool_calls=[ - ExpectedToolCall( - func=transition_issue_to_new_status, - args={ - "issue": "ENG-123", - "transition": "in progress", - }, - ), - ], - rubric=rubric, - critics=[ - CaseInsensitiveBinaryCritic(critic_field="issue", weight=0.5), - CaseInsensitiveBinaryCritic(critic_field="transition", weight=0.5), - ], - ) - - return suite diff --git a/toolkits/jira/pyproject.toml b/toolkits/jira/pyproject.toml deleted file mode 100644 index da7c96dc..00000000 --- a/toolkits/jira/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_jira" -version = "1.0.0" -description = "Arcade.dev LLM tools for interacting with Atlassian Jira" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_jira/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_jira",] diff --git a/toolkits/jira/tests/test_find_priorities_by_project.py b/toolkits/jira/tests/test_find_priorities_by_project.py deleted file mode 100644 index 9ad08cbd..00000000 --- a/toolkits/jira/tests/test_find_priorities_by_project.py +++ /dev/null @@ -1,225 +0,0 @@ -from collections.abc import Callable - -import httpx -import pytest -from arcade_tdk import ToolContext - -from arcade_jira.exceptions import NotFoundError -from arcade_jira.utils import clean_priority_dict, find_priorities_by_project - - -@pytest.mark.asyncio -async def test_find_priorities_by_project_with_no_priority_schemes( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, -): - list_priority_schemes_response = mock_httpx_response( - 200, - { - "values": [], - "isLast": True, - }, - ) - mock_httpx_client.get.return_value = list_priority_schemes_response - - with pytest.raises(NotFoundError) as exc: - await find_priorities_by_project(mock_context, {}) - - assert "No priority schemes found" in exc.value.message - - -@pytest.mark.asyncio -async def test_find_priorities_by_project_when_project_does_not_exist( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_scheme_dict: Callable, -): - sample_project = build_project_dict() - priority_scheme = build_priority_scheme_dict() - list_priority_schemes_response = mock_httpx_response( - 200, - {"values": [priority_scheme], "isLast": True}, - ) - - find_project_by_id_response = mock_httpx_response(404, {}) - search_projects_response = mock_httpx_response(200, {"values": [], "isLast": True}) - - mock_httpx_client.get.side_effect = [ - list_priority_schemes_response, - find_project_by_id_response, - search_projects_response, - ] - - response = await find_priorities_by_project(mock_context, sample_project) - - assert response == {"error": f"Project not found with name/key/ID '{sample_project['id']}'"} - - -@pytest.mark.asyncio -async def test_find_priorities_by_project_when_project_is_found_but_does_not_match_id( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_scheme_dict: Callable, -): - sample_project = build_project_dict() - other_project = build_project_dict(name=sample_project["name"]) - - priority_scheme = build_priority_scheme_dict() - list_priority_schemes_response = mock_httpx_response( - 200, - {"values": [priority_scheme], "isLast": True}, - ) - - find_project_by_id_response = mock_httpx_response(404, {}) - - search_projects_response = mock_httpx_response( - 200, - {"values": [other_project], "isLast": True}, - ) - - list_projects_response = mock_httpx_response( - 200, - {"values": [other_project], "isLast": True}, - ) - - mock_httpx_client.get.side_effect = [ - list_priority_schemes_response, - find_project_by_id_response, - search_projects_response, - list_projects_response, - ] - - response = await find_priorities_by_project(mock_context, sample_project) - - assert response == { - "error": f"No priority schemes found for the project {sample_project['id']}" - } - - -@pytest.mark.asyncio -async def test_find_priorities_by_project_happy_path( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, - build_priority_scheme_dict: Callable, -): - sample_project = build_project_dict() - other_project = build_project_dict(name=sample_project["name"]) - - priority_scheme = build_priority_scheme_dict() - priority1 = build_priority_dict() - priority2 = build_priority_dict() - - list_priority_schemes_response = mock_httpx_response( - 200, - {"values": [priority_scheme], "isLast": True}, - ) - - find_project_by_id_response = mock_httpx_response(404, {}) - - search_projects_response = mock_httpx_response( - 200, - {"values": [sample_project], "isLast": True}, - ) - - list_projects_response = mock_httpx_response( - 200, - {"values": [sample_project, other_project], "isLast": True}, - ) - - list_priorities_response = mock_httpx_response( - 200, - {"values": [priority1, priority2], "isLast": True}, - ) - - mock_httpx_client.get.side_effect = [ - list_priority_schemes_response, - find_project_by_id_response, - search_projects_response, - list_projects_response, - list_priorities_response, - ] - - response = await find_priorities_by_project(mock_context, sample_project) - - assert response["priorities_available"] == [ - clean_priority_dict(priority1), - clean_priority_dict(priority2), - ] - - -@pytest.mark.asyncio -async def test_find_priorities_by_project_happy_path_with_repeated_priorities_across_schemes( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, - build_priority_scheme_dict: Callable, -): - sample_project = build_project_dict() - other_project = build_project_dict(name=sample_project["name"]) - - priority_scheme1 = build_priority_scheme_dict() - priority_scheme2 = build_priority_scheme_dict() - - priority1 = build_priority_dict() - priority2 = build_priority_dict() - priority3 = build_priority_dict() - - list_priority_schemes_response = mock_httpx_response( - 200, - {"values": [priority_scheme1, priority_scheme2], "isLast": True}, - ) - - find_project_by_id_response = mock_httpx_response(200, sample_project) - - list_projects_by_priority_scheme_response1 = mock_httpx_response( - 200, - {"values": [sample_project, other_project], "isLast": True}, - ) - list_projects_by_priority_scheme_response2 = mock_httpx_response( - 200, - {"values": [sample_project], "isLast": True}, - ) - - list_priorities_by_scheme_response1 = mock_httpx_response( - 200, - {"values": [priority1, priority2], "isLast": True}, - ) - list_priorities_by_scheme_response2 = mock_httpx_response( - 200, - {"values": [priority2, priority3], "isLast": True}, - ) - - def get_httpx_response(url: str, *args, **kwargs) -> httpx.Response: - if url.endswith("/priorityscheme"): - return list_priority_schemes_response - elif url.endswith(f"/project/{sample_project['id']}"): - return find_project_by_id_response - elif url.endswith(f"/priorityscheme/{priority_scheme1['id']}/projects"): - return list_projects_by_priority_scheme_response1 - elif url.endswith(f"/priorityscheme/{priority_scheme2['id']}/projects"): - return list_projects_by_priority_scheme_response2 - elif url.endswith(f"/priorityscheme/{priority_scheme1['id']}/priorities"): - return list_priorities_by_scheme_response1 - elif url.endswith(f"/priorityscheme/{priority_scheme2['id']}/priorities"): - return list_priorities_by_scheme_response2 - else: - raise ValueError(f"Unexpected URL: {url}") # noqa: TRY003 - - mock_httpx_client.get.side_effect = get_httpx_response - - response = await find_priorities_by_project(mock_context, sample_project) - - assert len(response["priorities_available"]) == 3 - assert clean_priority_dict(priority1) in response["priorities_available"] - assert clean_priority_dict(priority2) in response["priorities_available"] - assert clean_priority_dict(priority3) in response["priorities_available"] diff --git a/toolkits/jira/tests/test_find_unique_issue_type.py b/toolkits/jira/tests/test_find_unique_issue_type.py deleted file mode 100644 index 5e57754d..00000000 --- a/toolkits/jira/tests/test_find_unique_issue_type.py +++ /dev/null @@ -1,235 +0,0 @@ -import json -from collections.abc import Callable - -import pytest -from arcade_tdk import ToolContext - -from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import clean_issue_type_dict, find_unique_issue_type - - -@pytest.mark.asyncio -async def test_find_unique_issue_type_by_id_success( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_issue_type_dict: Callable, -): - sample_issue_type = build_issue_type_dict() - issue_type_response = mock_httpx_response(200, sample_issue_type) - mock_httpx_client.get.return_value = issue_type_response - - response = await find_unique_issue_type(mock_context, sample_issue_type["id"], "123") - assert response == clean_issue_type_dict(sample_issue_type) - - -@pytest.mark.asyncio -async def test_find_unique_issue_type_by_name_with_a_single_match( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_issue_type_dict: Callable, - build_project_dict: Callable, - build_issue_types_response_dict: Callable, -): - sample_project = build_project_dict() - sample_issue_type = build_issue_type_dict() - - # It will first try to get the issue type by ID, we simulate a not found response - get_issue_type_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the issue type by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the issue types available to the project - list_issue_types_response = mock_httpx_response( - 200, build_issue_types_response_dict([sample_issue_type], is_last=True) - ) - - mock_httpx_client.get.side_effect = [ - get_issue_type_by_id_response, - get_project_by_id_response, - list_issue_types_response, - ] - - response = await find_unique_issue_type( - mock_context, sample_issue_type["name"].lower(), sample_project["id"] - ) - assert response == clean_issue_type_dict(sample_issue_type) - - -@pytest.mark.asyncio -async def test_find_unique_issue_type_by_name_when_project_does_not_exist( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_issue_type_dict: Callable, - build_issue_types_response_dict: Callable, - build_project_search_response_dict: Callable, -): - sample_project = build_project_dict() - sample_issue_type = build_issue_type_dict() - - # It will first try to get the issue type by ID - get_issue_type_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the project by id, we'll simulate a 404 error - get_project_by_id_response = mock_httpx_response(404, {}) - - # And also simulate no results found from search_projects - search_projects_response = mock_httpx_response(200, build_project_search_response_dict([])) - - mock_httpx_client.get.side_effect = [ - get_issue_type_by_id_response, - get_project_by_id_response, - search_projects_response, - ] - - with pytest.raises(JiraToolExecutionError) as exc: - await find_unique_issue_type( - mock_context, sample_issue_type["name"].lower(), sample_project["id"] - ) - - assert f"Project not found with name/key/ID '{sample_project['id']}'" in exc.value.message - - -@pytest.mark.asyncio -async def test_find_unique_issue_type_by_name_with_multiple_priorities_but_zero_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_issue_type_dict: Callable, - build_issue_types_response_dict: Callable, -): - sample_project = build_project_dict() - - sample_issue_type = build_issue_type_dict() - other_issue_type1 = build_issue_type_dict(name=sample_issue_type["name"] + "1") - other_issue_type2 = build_issue_type_dict(name=sample_issue_type["name"] + "2") - - # It will first try to get the issue type by ID - get_issue_type_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the issue type by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the issue types available to the project - search_issue_types_response = mock_httpx_response( - 200, build_issue_types_response_dict([other_issue_type1, other_issue_type2], is_last=True) - ) - - mock_httpx_client.get.side_effect = [ - get_issue_type_by_id_response, - get_project_by_id_response, - search_issue_types_response, - ] - - with pytest.raises(NotFoundError) as exc: - await find_unique_issue_type( - mock_context, sample_issue_type["name"].lower(), sample_project["id"] - ) - - available_issue_types = json.dumps([ - { - "id": other_issue_type1["id"], - "name": other_issue_type1["name"], - }, - { - "id": other_issue_type2["id"], - "name": other_issue_type2["name"], - }, - ]) - - assert ( - f"Issue type not found with ID or name '{sample_issue_type['name'].lower()}'. " - f"These are the issue types available for the project: {available_issue_types}" - == exc.value.message - ) - - -@pytest.mark.asyncio -async def test_find_unique_issue_type_by_name_with_multiple_issue_types_but_one_match( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_issue_type_dict: Callable, - build_issue_types_response_dict: Callable, -): - sample_project = build_project_dict() - sample_issue_type = build_issue_type_dict() - other_issue_type1 = build_issue_type_dict(name=sample_issue_type["name"] + "1") - other_issue_type2 = build_issue_type_dict(name=sample_issue_type["name"] + "2") - - # It will first try to get the issue type by ID - get_issue_type_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the priority by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the issue types available to the project - search_issue_types_response = mock_httpx_response( - 200, - build_issue_types_response_dict( - [sample_issue_type, other_issue_type1, other_issue_type2], - is_last=True, - ), - ) - - mock_httpx_client.get.side_effect = [ - get_issue_type_by_id_response, - get_project_by_id_response, - search_issue_types_response, - ] - - response = await find_unique_issue_type( - mock_context, sample_issue_type["name"].lower(), sample_project["id"] - ) - assert response == clean_issue_type_dict(sample_issue_type) - - -@pytest.mark.asyncio -async def test_find_unique_issue_type_by_name_with_multiple_issue_types_and_multiple_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_issue_type_dict: Callable, - build_issue_types_response_dict: Callable, -): - sample_project = build_project_dict() - sample_issue_type = build_issue_type_dict() - other_issue_type1 = build_issue_type_dict(name=sample_issue_type["name"]) - other_issue_type2 = build_issue_type_dict() - - # It will first try to get the issue type by ID - get_issue_type_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the priority by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the issue types available to the project - search_issue_types_response = mock_httpx_response( - 200, - build_issue_types_response_dict( - [sample_issue_type, other_issue_type1, other_issue_type2], - is_last=True, - ), - ) - - mock_httpx_client.get.side_effect = [ - get_issue_type_by_id_response, - get_project_by_id_response, - search_issue_types_response, - ] - - with pytest.raises(MultipleItemsFoundError) as exc: - await find_unique_issue_type( - mock_context, sample_issue_type["name"].lower(), sample_project["id"] - ) - - assert sample_issue_type["id"] in exc.value.message - assert other_issue_type1["id"] in exc.value.message - assert other_issue_type2["id"] not in exc.value.message diff --git a/toolkits/jira/tests/test_find_unique_priority.py b/toolkits/jira/tests/test_find_unique_priority.py deleted file mode 100644 index 6749f4c2..00000000 --- a/toolkits/jira/tests/test_find_unique_priority.py +++ /dev/null @@ -1,227 +0,0 @@ -from collections.abc import Callable -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext - -from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import clean_priority_dict, find_unique_priority - - -@pytest.mark.asyncio -async def test_find_unique_priority_by_id_success( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_priority_dict: Callable, -): - sample_priority = build_priority_dict() - priority_response = mock_httpx_response(200, sample_priority) - mock_httpx_client.get.return_value = priority_response - - response = await find_unique_priority(mock_context, sample_priority["id"], "123") - assert response == clean_priority_dict(sample_priority) - - -@pytest.mark.asyncio -@patch("arcade_jira.tools.priorities.find_priorities_by_project") -async def test_find_unique_priority_by_name_with_a_single_match( - mock_find_priorities_by_project, - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, -): - sample_project = build_project_dict() - sample_priority = build_priority_dict() - - # It will first try to get the priority by ID - get_priority_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the priority by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the priorities available to the project - mock_find_priorities_by_project.return_value = { - "project": sample_project, - "priorities_available": [sample_priority], - } - - mock_httpx_client.get.side_effect = [ - get_priority_by_id_response, - get_project_by_id_response, - ] - - response = await find_unique_priority( - mock_context, sample_priority["name"].lower(), sample_project["id"] - ) - assert response == clean_priority_dict(sample_priority) - - -@pytest.mark.asyncio -@patch("arcade_jira.tools.priorities.find_priorities_by_project") -async def test_find_unique_priority_by_name_when_project_does_not_exist( - mock_find_priorities_by_project, - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, - build_project_search_response_dict: Callable, -): - sample_project = build_project_dict() - sample_priority = build_priority_dict() - - # It will first try to get the priority by ID - get_priority_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the project by id, we'll simulate a 404 error - get_project_by_id_response = mock_httpx_response(404, {}) - - # And also simulate no results found from search_projects - search_projects_response = mock_httpx_response(200, build_project_search_response_dict([])) - - # We'll still simulate a find_priorities_by_project response, but this should not be called - mock_find_priorities_by_project.return_value = { - "project": sample_project, - "priorities_available": [sample_priority], - } - - mock_httpx_client.get.side_effect = [ - get_priority_by_id_response, - get_project_by_id_response, - search_projects_response, - ] - - with pytest.raises(JiraToolExecutionError) as exc: - await find_unique_priority( - mock_context, sample_priority["name"].lower(), sample_project["id"] - ) - - mock_find_priorities_by_project.assert_not_called() - assert f"Project not found with name/key/ID '{sample_project['id']}'" in exc.value.message - - -@pytest.mark.asyncio -@patch("arcade_jira.tools.priorities.find_priorities_by_project") -async def test_find_unique_priority_by_name_with_multiple_priorities_but_zero_matches( - mock_find_priorities_by_project, - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, -): - sample_project = build_project_dict() - - sample_priority = build_priority_dict() - other_priority1 = build_priority_dict(name=sample_priority["name"] + "1") - other_priority2 = build_priority_dict(name=sample_priority["name"] + "2") - - # It will first try to get the priority by ID - get_priority_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the priority by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the priorities available to the project - mock_find_priorities_by_project.return_value = { - "project": sample_project, - "priorities_available": [other_priority1, other_priority2], - } - - mock_httpx_client.get.side_effect = [ - get_priority_by_id_response, - get_project_by_id_response, - ] - - with pytest.raises(NotFoundError) as exc: - await find_unique_priority( - mock_context, sample_priority["name"].lower(), sample_project["id"] - ) - - assert ( - f"Priority not found with ID or name '{sample_priority['name'].lower()}'" - == exc.value.message - ) - - -@pytest.mark.asyncio -@patch("arcade_jira.tools.priorities.find_priorities_by_project") -async def test_find_unique_priority_by_name_with_multiple_priorities_but_one_match( - mock_find_priorities_by_project, - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, -): - sample_project = build_project_dict() - sample_priority = build_priority_dict() - other_priority1 = build_priority_dict() - other_priority2 = build_priority_dict() - - # It will first try to get the priority by ID - get_priority_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the priority by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the priorities available to the project - mock_find_priorities_by_project.return_value = { - "project": sample_project, - "priorities_available": [sample_priority, other_priority1, other_priority2], - } - - mock_httpx_client.get.side_effect = [ - get_priority_by_id_response, - get_project_by_id_response, - ] - - response = await find_unique_priority( - mock_context, sample_priority["name"].lower(), sample_project["id"] - ) - assert response == clean_priority_dict(sample_priority) - - -@pytest.mark.asyncio -@patch("arcade_jira.tools.priorities.find_priorities_by_project") -async def test_find_unique_priority_by_name_with_multiple_priorities_and_multiple_matches( - mock_find_priorities_by_project, - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_priority_dict: Callable, -): - sample_project = build_project_dict() - sample_priority = build_priority_dict() - other_priority1 = build_priority_dict(name=sample_priority["name"]) - other_priority2 = build_priority_dict() - - # It will first try to get the priority by ID - get_priority_by_id_response = mock_httpx_response(404, {}) - - # When it tries to get the priority by name, it will first query the project data - get_project_by_id_response = mock_httpx_response(200, sample_project) - - # Then it will query the priorities available to the project - mock_find_priorities_by_project.return_value = { - "project": sample_project, - "priorities_available": [sample_priority, other_priority1, other_priority2], - } - - mock_httpx_client.get.side_effect = [ - get_priority_by_id_response, - get_project_by_id_response, - ] - - with pytest.raises(MultipleItemsFoundError) as exc: - await find_unique_priority( - mock_context, sample_priority["name"].lower(), sample_project["id"] - ) - - assert sample_priority["id"] in exc.value.message - assert other_priority1["id"] in exc.value.message - assert other_priority2["id"] not in exc.value.message diff --git a/toolkits/jira/tests/test_find_unique_project.py b/toolkits/jira/tests/test_find_unique_project.py deleted file mode 100644 index 13fc661e..00000000 --- a/toolkits/jira/tests/test_find_unique_project.py +++ /dev/null @@ -1,93 +0,0 @@ -from collections.abc import Callable - -import pytest -from arcade_tdk import ToolContext - -from arcade_jira.exceptions import MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import clean_project_dict, find_unique_project - - -@pytest.mark.asyncio -async def test_find_unique_project_by_id_success( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, -): - sample_project = build_project_dict() - project_response = mock_httpx_response(200, sample_project) - mock_httpx_client.get.return_value = project_response - - response = await find_unique_project(mock_context, sample_project["id"]) - assert response == clean_project_dict(sample_project) - - -@pytest.mark.asyncio -async def test_find_unique_project_by_name_with_a_single_match( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_project_search_response_dict: Callable, -): - sample_project = build_project_dict() - get_project_by_id_response = mock_httpx_response(404, {}) - get_projects_without_id_response = mock_httpx_response( - 200, build_project_search_response_dict([sample_project]) - ) - mock_httpx_client.get.side_effect = [ - get_project_by_id_response, - get_projects_without_id_response, - ] - - response = await find_unique_project(mock_context, sample_project["name"].lower()) - assert response == clean_project_dict(sample_project) - - -@pytest.mark.asyncio -async def test_find_unique_project_by_name_with_multiple_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_project_search_response_dict: Callable, - generate_random_str: Callable, -): - project_name = generate_random_str() - sample_projects = [ - build_project_dict(name=project_name), - build_project_dict(name=project_name), - ] - get_project_by_id_response = mock_httpx_response(404, {}) - search_projects_response = mock_httpx_response( - 200, build_project_search_response_dict(sample_projects) - ) - mock_httpx_client.get.side_effect = [ - get_project_by_id_response, - search_projects_response, - ] - - with pytest.raises(MultipleItemsFoundError) as exc: - await find_unique_project(mock_context, sample_projects[0]["name"].lower()) - - assert sample_projects[0]["id"] in exc.value.message - assert sample_projects[1]["id"] in exc.value.message - - -@pytest.mark.asyncio -async def test_find_unique_project_by_name_without_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - generate_random_str: Callable, - build_project_search_response_dict: Callable, -): - get_project_by_id_response = mock_httpx_response(404, {}) - search_projects_response = mock_httpx_response(200, build_project_search_response_dict([])) - mock_httpx_client.get.side_effect = [ - get_project_by_id_response, - search_projects_response, - ] - - with pytest.raises(NotFoundError): - await find_unique_project(mock_context, generate_random_str()) diff --git a/toolkits/jira/tests/test_find_unique_user.py b/toolkits/jira/tests/test_find_unique_user.py deleted file mode 100644 index dcd0ac46..00000000 --- a/toolkits/jira/tests/test_find_unique_user.py +++ /dev/null @@ -1,185 +0,0 @@ -from collections.abc import Callable - -import pytest -from arcade_tdk import ToolContext - -from arcade_jira.exceptions import MultipleItemsFoundError, NotFoundError -from arcade_jira.utils import ( - clean_user_dict, - find_multiple_unique_users, - find_unique_user, -) - - -@pytest.mark.asyncio -async def test_find_unique_user_by_id_success( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, -): - sample_user = build_user_dict() - user_response = mock_httpx_response(200, sample_user) - mock_httpx_client.get.return_value = user_response - - response = await find_unique_user(mock_context, sample_user["accountId"]) - assert response == clean_user_dict(sample_user) - - -@pytest.mark.asyncio -async def test_find_unique_user_by_name_with_a_single_match( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, -): - sample_user = build_user_dict() - get_user_by_id_response = mock_httpx_response(404, {}) - get_users_without_id_response = mock_httpx_response(200, [sample_user]) - mock_httpx_client.get.side_effect = [get_user_by_id_response, get_users_without_id_response] - - response = await find_unique_user(mock_context, sample_user["displayName"].lower()) - assert response == clean_user_dict(sample_user) - - -@pytest.mark.asyncio -async def test_find_unique_user_by_name_with_multiple_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, - generate_random_str: Callable, -): - user_name = generate_random_str() - sample_users = [ - build_user_dict(display_name=user_name), - build_user_dict(display_name=user_name), - ] - get_user_by_id_response = mock_httpx_response(404, {}) - get_users_without_id_response = mock_httpx_response(200, sample_users) - mock_httpx_client.get.side_effect = [get_user_by_id_response, get_users_without_id_response] - - with pytest.raises(MultipleItemsFoundError) as exc: - await find_unique_user(mock_context, sample_users[0]["displayName"].lower()) - - assert sample_users[0]["accountId"] in exc.value.message - assert sample_users[1]["accountId"] in exc.value.message - - -@pytest.mark.asyncio -async def test_find_unique_user_by_name_without_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - generate_random_str: Callable, -): - get_user_by_id_response = mock_httpx_response(404, {}) - get_users_without_id_response = mock_httpx_response(200, []) - mock_httpx_client.get.side_effect = [get_user_by_id_response, get_users_without_id_response] - - with pytest.raises(NotFoundError): - await find_unique_user(mock_context, generate_random_str()) - - -@pytest.mark.asyncio -async def test_find_multiple_users_when_all_names_match_one_result( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, -): - user1 = build_user_dict() - user2 = build_user_dict() - - mock_httpx_client.get.side_effect = [ - mock_httpx_response(200, [user1]), - mock_httpx_response(200, [user2]), - ] - - response = await find_multiple_unique_users( - mock_context, [user1["displayName"], user2["displayName"]] - ) - - assert response == [ - clean_user_dict(user1), - clean_user_dict(user2), - ] - - -@pytest.mark.asyncio -async def test_find_multiple_users_when_a_name_match_multiple_results( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, -): - user1 = build_user_dict() - user2 = build_user_dict() - user3 = build_user_dict() - - mock_httpx_client.get.side_effect = [ - mock_httpx_response(200, [user1]), - mock_httpx_response(200, [user2, user3]), - ] - - with pytest.raises(MultipleItemsFoundError) as exc: - await find_multiple_unique_users(mock_context, [user1["displayName"], user2["displayName"]]) - - assert user2["accountId"] in exc.value.message - assert user3["accountId"] in exc.value.message - - -@pytest.mark.asyncio -async def test_find_multiple_users_when_user_is_not_found_by_name_but_found_by_id( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, -): - user1 = build_user_dict() - user2 = build_user_dict() - - mock_httpx_client.get.side_effect = [ - mock_httpx_response(200, [user1]), - mock_httpx_response(200, []), - mock_httpx_response(200, user2), - ] - - response = await find_multiple_unique_users( - mock_context, [user1["displayName"], user2["accountId"]] - ) - - assert response == [ - clean_user_dict(user1), - clean_user_dict(user2), - ] - - -@pytest.mark.asyncio -async def test_find_multiple_users_when_various_users_are_not_found_by_name_but_found_by_id( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_user_dict: Callable, -): - user1 = build_user_dict() - user2 = build_user_dict() - user3 = build_user_dict() - - mock_httpx_client.get.side_effect = [ - mock_httpx_response(200, [user1]), - mock_httpx_response(200, []), - mock_httpx_response(200, []), - mock_httpx_response(200, user2), - mock_httpx_response(200, user3), - ] - - response = await find_multiple_unique_users( - mock_context, [user1["displayName"], user2["accountId"], user3["accountId"]] - ) - - assert response == [ - clean_user_dict(user1), - clean_user_dict(user2), - clean_user_dict(user3), - ] diff --git a/toolkits/jira/tests/test_multi_cloud.py b/toolkits/jira/tests/test_multi_cloud.py deleted file mode 100644 index 2aa3fb50..00000000 --- a/toolkits/jira/tests/test_multi_cloud.py +++ /dev/null @@ -1,275 +0,0 @@ -import asyncio -import json -import uuid -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -from arcade_tdk import ToolContext -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_jira.utils import check_if_cloud_is_authorized, resolve_cloud_id - - -@pytest.fixture -def mock_httpx_client(): - with patch("arcade_jira.utils.httpx") as mock_httpx: - yield mock_httpx.AsyncClient().__aenter__.return_value - - -@patch("arcade_jira.tools.cloud.get_available_atlassian_clouds") -@pytest.mark.asyncio -async def test_resolve_cloud_id_with_value_already_provided( - mock_get_available_atlassian_clouds: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - another_cloud_id = str(uuid.uuid4()) - mock_get_available_atlassian_clouds.return_value = { - "clouds_available": [ - { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - ] - } - - cloud_id = await resolve_cloud_id(mock_context, another_cloud_id) - assert cloud_id == another_cloud_id - - -@patch("arcade_jira.tools.cloud.get_available_atlassian_clouds") -@pytest.mark.asyncio -async def test_resolve_cloud_id_providing_cloud_name( - mock_get_available_atlassian_clouds: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - mock_get_available_atlassian_clouds.return_value = { - "clouds_available": [ - { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - ] - } - - cloud_id = await resolve_cloud_id(mock_context, fake_cloud_name) - assert cloud_id == fake_cloud_id - - -@patch("arcade_jira.tools.cloud.get_available_atlassian_clouds") -@pytest.mark.asyncio -async def test_resolve_cloud_id_with_single_cloud_available( - mock_get_available_atlassian_clouds: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - mock_get_available_atlassian_clouds.return_value = { - "clouds_available": [ - { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - ] - } - - cloud_id = await resolve_cloud_id(mock_context, None) - assert cloud_id == fake_cloud_id - - -@patch("arcade_jira.tools.cloud.get_available_atlassian_clouds") -@pytest.mark.asyncio -async def test_resolve_cloud_id_with_multiple_distinct_clouds_available( - mock_get_available_atlassian_clouds: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - cloud_id_2 = str(uuid.uuid4()) - mock_get_available_atlassian_clouds.return_value = { - "clouds_available": [ - { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - }, - { - "atlassian_cloud_id": cloud_id_2, - "atlassian_cloud_name": "Cloud 2", - "atlassian_cloud_url": "https://cloud2.atlassian.net", - }, - ] - } - - with pytest.raises(RetryableToolError) as exc: - await resolve_cloud_id(mock_context, None) - - assert "Multiple Atlassian Clouds are available" in exc.value.message - assert fake_cloud_id in exc.value.additional_prompt_content - assert fake_cloud_name in exc.value.additional_prompt_content - assert cloud_id_2 in exc.value.additional_prompt_content - assert "Cloud 2" in exc.value.additional_prompt_content - - -@patch("arcade_jira.tools.cloud.get_available_atlassian_clouds") -@pytest.mark.asyncio -async def test_resolve_cloud_id_with_no_clouds_available( - mock_get_available_atlassian_clouds: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - mock_get_available_atlassian_clouds.return_value = {"clouds_available": []} - - with pytest.raises(ToolExecutionError) as exc: - await resolve_cloud_id(mock_context, None) - - assert "No Atlassian Cloud is available" in exc.value.message - - -@pytest.mark.asyncio -async def test_check_if_cloud_is_authorized_success( - mock_httpx_client: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - cloud = { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - fake_user_id = uuid.uuid4() - mock_httpx_client.get.return_value.status_code = 200 - mock_httpx_client.get.return_value.json.return_value = { - "self": f"https://api.atlassian.com/ex/jira/{fake_cloud_id}/rest/api/3/user?accountId={fake_user_id!s}", - "accountId": fake_user_id, - "accountType": "atlassian", - "emailAddress": f"john.doe@{fake_cloud_name}.com", - "displayName": "John Doe", - } - - semaphore = asyncio.Semaphore(1) - - response = await check_if_cloud_is_authorized(mock_context, cloud, semaphore) - - assert response == cloud - - -@pytest.mark.asyncio -async def test_check_if_cloud_is_authorized_returning_401_error( - mock_httpx_client: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - cloud = { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - - mock_httpx_client.get.return_value.status_code = 401 - mock_httpx_client.get.return_value.json.return_value = { - "code": 401, - "message": "Unauthorized", - } - - semaphore = asyncio.Semaphore(1) - - response = await check_if_cloud_is_authorized(mock_context, cloud, semaphore) - - assert response is False - - -@pytest.mark.asyncio -async def test_check_if_cloud_is_authorized_returning_404_no_message_available_error( - mock_httpx_client: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - cloud = { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - - def mock_response_json() -> dict[str, Any]: - return { - "code": 404, - "message": "No message available", - } - - mock_httpx_client.get.return_value.status_code = 404 - mock_httpx_client.get.return_value.json = mock_response_json - - semaphore = asyncio.Semaphore(1) - - response = await check_if_cloud_is_authorized(mock_context, cloud, semaphore) - - assert response is False - - -@pytest.mark.asyncio -async def test_check_if_cloud_is_authorized_returning_404_unrecognized_error( - mock_httpx_client: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - cloud = { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - - response_data = { - "code": 404, - "message": "Something else was not found", - } - - def mock_response_json() -> dict[str, Any]: - return response_data - - mock_httpx_client.get.return_value.status_code = 404 - mock_httpx_client.get.return_value.text = json.dumps(response_data) - mock_httpx_client.get.return_value.json = mock_response_json - - semaphore = asyncio.Semaphore(1) - - response = await check_if_cloud_is_authorized(mock_context, cloud, semaphore) - - assert response is False - - -@pytest.mark.asyncio -async def test_check_if_cloud_is_authorized_raising_unexpected_exception( - mock_httpx_client: MagicMock, - mock_context: ToolContext, - fake_cloud_id: str, - fake_cloud_name: str, -): - cloud = { - "atlassian_cloud_id": fake_cloud_id, - "atlassian_cloud_name": fake_cloud_name, - "atlassian_cloud_url": f"https://{fake_cloud_name}.atlassian.net", - } - - mock_httpx_client.get.side_effect = Exception("Something went wrong") - - semaphore = asyncio.Semaphore(1) - - with pytest.raises(ToolExecutionError) as exc: - await check_if_cloud_is_authorized(mock_context, cloud, semaphore) - - assert fake_cloud_id in exc.value.message - assert fake_cloud_id in exc.value.developer_message - assert "Something went wrong" in exc.value.developer_message diff --git a/toolkits/jira/tests/test_pagination_helpers.py b/toolkits/jira/tests/test_pagination_helpers.py deleted file mode 100644 index 0ddaf310..00000000 --- a/toolkits/jira/tests/test_pagination_helpers.py +++ /dev/null @@ -1,162 +0,0 @@ -from collections.abc import Callable - -import pytest -from arcade_tdk import ToolContext - -from arcade_jira.exceptions import JiraToolExecutionError -from arcade_jira.tools.priorities import list_projects_associated_with_a_priority_scheme -from arcade_jira.utils import add_pagination_to_response, clean_project_dict, paginate_all_items - - -@pytest.mark.asyncio -async def test_paginate_all_items_with_zero_matches( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_search_response_dict: Callable, -): - response = mock_httpx_response(200, build_project_search_response_dict([], is_last=True)) - mock_httpx_client.get.return_value = response - - response = await paginate_all_items( - context=mock_context, - tool=list_projects_associated_with_a_priority_scheme, - response_items_key="projects", - scheme_id="123", - ) - assert response == [] - - -@pytest.mark.asyncio -async def test_paginate_all_items_with_one_page( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_project_search_response_dict: Callable, -): - projects = [build_project_dict(), build_project_dict()] - response = mock_httpx_response(200, build_project_search_response_dict(projects, is_last=True)) - mock_httpx_client.get.return_value = response - - response = await paginate_all_items( - context=mock_context, - tool=list_projects_associated_with_a_priority_scheme, - response_items_key="projects", - scheme_id="123", - ) - assert response == [clean_project_dict(project) for project in projects] - - -@pytest.mark.asyncio -async def test_paginate_all_items_with_multiple_pages( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_dict: Callable, - build_project_search_response_dict: Callable, -): - page1 = [build_project_dict(), build_project_dict()] - page2 = [build_project_dict(), build_project_dict()] - page3 = [build_project_dict()] - - response1 = mock_httpx_response(200, build_project_search_response_dict(page1, is_last=False)) - response2 = mock_httpx_response(200, build_project_search_response_dict(page2, is_last=False)) - response3 = mock_httpx_response(200, build_project_search_response_dict(page3, is_last=True)) - - mock_httpx_client.get.side_effect = [response1, response2, response3] - - response = await paginate_all_items( - context=mock_context, - tool=list_projects_associated_with_a_priority_scheme, - response_items_key="projects", - scheme_id="123", - limit=2, - ) - assert response == [clean_project_dict(project) for project in page1 + page2 + page3] - - -@pytest.mark.asyncio -async def test_paginate_all_items_when_tool_returns_error( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response: Callable, - build_project_search_response_dict: Callable, -): - project_id = "456" - get_project_by_id_response = mock_httpx_response(404, {}) - search_projects_response = mock_httpx_response(200, build_project_search_response_dict([])) - mock_httpx_client.get.side_effect = [get_project_by_id_response, search_projects_response] - - with pytest.raises(JiraToolExecutionError) as exc: - await paginate_all_items( - context=mock_context, - tool=list_projects_associated_with_a_priority_scheme, - response_items_key="projects", - scheme_id="123", - project=project_id, - limit=2, - ) - - assert exc.value.message == f"Project not found with name/key/ID '{project_id}'" - - -def test_add_pagination_to_response_with_zero_items(): - response = {"items": []} - items = [] - limit = 10 - offset = 0 - add_pagination_to_response(response, items, limit, offset) - assert response["pagination"] == {"is_last_page": True, "limit": limit, "total_results": 0} - - -def test_add_pagination_to_response_with_last_page_false(): - items = [{"id": "123"}, {"id": "456"}] - response = {"items": items, "isLast": False} - limit = 2 - offset = 0 - add_pagination_to_response(response, items, limit, offset) - assert response["pagination"] == { - "limit": limit, - "total_results": 2, - "next_offset": 2, - } - - -def test_add_pagination_to_response_with_last_page_true(): - items = [{"id": "123"}, {"id": "456"}] - response = {"items": items, "isLast": True} - limit = 2 - offset = 0 - add_pagination_to_response(response, items, limit, offset) - assert response["pagination"] == { - "limit": limit, - "total_results": 2, - "is_last_page": True, - } - - -def test_add_pagination_to_response_without_last_page_and_limit_equal_items(): - items = [{"id": "123"}, {"id": "456"}] - response = {"items": items} - limit = 2 - offset = 0 - add_pagination_to_response(response, items, limit, offset) - assert response["pagination"] == { - "limit": limit, - "total_results": 2, - "next_offset": 2, - } - - -def test_add_pagination_to_response_without_last_page_and_less_items_than_limit(): - items = [{"id": "123"}] - response = {"items": items} - limit = 2 - offset = 0 - add_pagination_to_response(response, items, limit, offset) - assert response["pagination"] == { - "limit": limit, - "total_results": 1, - "is_last_page": True, - } diff --git a/toolkits/linear/.pre-commit-config.yaml b/toolkits/linear/.pre-commit-config.yaml deleted file mode 100644 index f5edef4b..00000000 --- a/toolkits/linear/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/linear/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/linear/.ruff.toml b/toolkits/linear/.ruff.toml deleted file mode 100644 index 671aba96..00000000 --- a/toolkits/linear/.ruff.toml +++ /dev/null @@ -1,45 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003"] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/linear/LICENSE b/toolkits/linear/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/linear/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/linear/Makefile b/toolkits/linear/Makefile deleted file mode 100644 index 87bd05f6..00000000 --- a/toolkits/linear/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ Linear Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/linear/arcade_linear/__init__.py b/toolkits/linear/arcade_linear/__init__.py deleted file mode 100644 index 1dce0cd1..00000000 --- a/toolkits/linear/arcade_linear/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Linear Toolkit for Arcade AI""" - -from arcade_linear.models import DateRange -from arcade_linear.tools import ( - get_issue, # Get specific issue details - get_teams, # Get team information -) - -__all__ = [ - "DateRange", - "get_issue", - "get_teams", -] diff --git a/toolkits/linear/arcade_linear/client.py b/toolkits/linear/arcade_linear/client.py deleted file mode 100644 index 4f97b9ab..00000000 --- a/toolkits/linear/arcade_linear/client.py +++ /dev/null @@ -1,316 +0,0 @@ -import asyncio -import json -from dataclasses import dataclass -from typing import Any, cast - -import httpx -from arcade_tdk.errors import ToolExecutionError - -from arcade_linear.constants import ( - LINEAR_API_URL, - LINEAR_MAX_CONCURRENT_REQUESTS, - LINEAR_MAX_TIMEOUT_SECONDS, -) - - -@dataclass -class LinearClient: - """Client for interacting with Linear's GraphQL API""" - - auth_token: str - api_url: str = LINEAR_API_URL - max_concurrent_requests: int = LINEAR_MAX_CONCURRENT_REQUESTS - timeout_seconds: int = LINEAR_MAX_TIMEOUT_SECONDS - _semaphore: asyncio.Semaphore | None = None - - def __post_init__(self) -> None: - self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests) - - def _build_headers(self, additional_headers: dict[str, str] | None = None) -> dict[str, str]: - """Build headers for GraphQL requests""" - headers = { - "Authorization": f"Bearer {self.auth_token}", - "Content-Type": "application/json", - "Accept": "application/json", - } - if additional_headers: - headers.update(additional_headers) - return headers - - def _build_error_message(self, response: httpx.Response) -> tuple[str, str]: - """Build user-friendly and developer error messages from response""" - try: - data = response.json() - - if data.get("errors"): - errors = data["errors"] - if len(errors) == 1: - error = errors[0] - user_message = error.get("message", "Unknown GraphQL error") - dev_message = ( - f"GraphQL error: {json.dumps(error)} (HTTP {response.status_code})" - ) - else: - error_messages = [err.get("message", "Unknown error") for err in errors] - user_message = f"Multiple errors: {'; '.join(error_messages)}" - dev_message = ( - f"Multiple GraphQL errors: {json.dumps(errors)} " - f"(HTTP {response.status_code})" - ) - else: - user_message = f"HTTP {response.status_code}: {response.reason_phrase}" - dev_message = f"HTTP {response.status_code}: {response.text}" - - except Exception as e: - user_message = "Failed to parse Linear API error response" - dev_message = ( - f"Failed to parse error response: {type(e).__name__}: {e!s} | " - f"Raw response: {response.text}" - ) - - return user_message, dev_message - - def _raise_for_status(self, response: httpx.Response) -> None: - """Raise appropriate errors for non-200 responses""" - if response.status_code < 300: - # Check for GraphQL errors in successful HTTP responses - try: - data = response.json() - if data.get("errors"): - user_message, dev_message = self._build_error_message(response) - raise ToolExecutionError(user_message, developer_message=dev_message) - except (ValueError, KeyError): - # Response isn't JSON or doesn't have expected structure - pass - return - - user_message, dev_message = self._build_error_message(response) - raise ToolExecutionError(user_message, developer_message=dev_message) - - async def execute_query( - self, query: str, variables: dict[str, Any] | None = None, operation_name: str | None = None - ) -> dict[str, Any]: - """Execute a GraphQL query""" - payload: dict[str, Any] = { - "query": query.strip(), - } - - if variables: - payload["variables"] = variables - - if operation_name: - payload["operationName"] = operation_name - - headers = self._build_headers() - - async with self._semaphore, httpx.AsyncClient(timeout=self.timeout_seconds) as client: # type: ignore[union-attr] - response = await client.post( - self.api_url, - json=payload, - headers=headers, - ) - self._raise_for_status(response) - return cast(dict[str, Any], response.json()) - - async def get_teams( - self, - first: int = 50, - after: str | None = None, - include_archived: bool = False, - name_filter: str | None = None, - ) -> dict[str, Any]: - """Get teams with optional filtering""" - query = """ - query GetTeams($first: Int!, $after: String, $filter: TeamFilter) { - teams(first: $first, after: $after, filter: $filter) { - nodes { - id - key - name - description - private - archivedAt - createdAt - updatedAt - icon - color - cyclesEnabled - issueEstimationType - organization { - id - name - } - members { - nodes { - id - name - email - displayName - avatarUrl - } - } - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - } - } - """ - - # Build filter - team_filter = {} - if name_filter: - team_filter["name"] = {"containsIgnoreCase": name_filter} - - variables = {"first": first, "after": after, "filter": team_filter if team_filter else None} - - result = await self.execute_query(query, variables) - return cast(dict[str, Any], result["data"]["teams"]) - - async def get_issue_by_id(self, issue_id: str) -> dict[str, Any]: - """Get a single issue by ID""" - query = """ - query GetIssue($id: String!) { - issue(id: $id) { - id - identifier - title - description - priority - priorityLabel - estimate - sortOrder - createdAt - updatedAt - completedAt - canceledAt - dueDate - url - branchName - - creator { - id - name - email - displayName - avatarUrl - } - - assignee { - id - name - email - displayName - avatarUrl - } - - state { - id - name - type - color - position - } - - team { - id - key - name - } - - project { - id - name - description - state - progress - startDate - targetDate - } - - cycle { - id - number - name - description - startsAt - endsAt - completedAt - progress - } - - parent { - id - identifier - title - } - - labels { - nodes { - id - name - color - description - } - } - - attachments { - nodes { - id - title - subtitle - url - metadata - createdAt - } - } - - comments { - nodes { - id - body - createdAt - updatedAt - user { - id - name - email - displayName - } - } - } - - children { - nodes { - id - identifier - title - state { - id - name - type - } - } - } - - relations { - nodes { - id - type - relatedIssue { - id - identifier - title - } - } - } - } - } - """ - - variables = {"id": issue_id} - result = await self.execute_query(query, variables) - return cast(dict[str, Any], result["data"]["issue"]) diff --git a/toolkits/linear/arcade_linear/constants.py b/toolkits/linear/arcade_linear/constants.py deleted file mode 100644 index 8a55d07f..00000000 --- a/toolkits/linear/arcade_linear/constants.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -LINEAR_API_URL = "https://api.linear.app/graphql" - -try: - LINEAR_MAX_CONCURRENT_REQUESTS = int(os.getenv("LINEAR_MAX_CONCURRENT_REQUESTS", 3)) -except ValueError: - LINEAR_MAX_CONCURRENT_REQUESTS = 3 - -try: - LINEAR_MAX_TIMEOUT_SECONDS = int(os.getenv("LINEAR_MAX_TIMEOUT_SECONDS", 30)) -except ValueError: - LINEAR_MAX_TIMEOUT_SECONDS = 30 diff --git a/toolkits/linear/arcade_linear/models.py b/toolkits/linear/arcade_linear/models.py deleted file mode 100644 index 2763fd51..00000000 --- a/toolkits/linear/arcade_linear/models.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Linear toolkit models and enums""" - -from datetime import datetime, timedelta, timezone -from enum import Enum -from typing import Optional - - -class DateRange(Enum): - """Date range enum for Linear datetime queries - - Provides consistent datetime range handling. - All ranges are calculated in UTC and return timezone-aware datetime objects. - """ - - TODAY = "today" - YESTERDAY = "yesterday" - THIS_WEEK = "this_week" - LAST_WEEK = "last_week" - THIS_MONTH = "this_month" - LAST_MONTH = "last_month" - THIS_YEAR = "this_year" - LAST_YEAR = "last_year" - LAST_7_DAYS = "last_7_days" - LAST_30_DAYS = "last_30_days" - - def to_datetime_range( - self, reference_time: datetime | None = None - ) -> tuple[datetime, datetime]: - """Convert DateRange to start and end datetime objects - - Args: - reference_time: Optional reference time for calculations. Defaults to now in UTC. - - Returns: - Tuple of (start_datetime, end_datetime) - both timezone-aware in UTC - """ - now = reference_time or datetime.now(timezone.utc) - - # Map enum values to their corresponding helper methods - range_methods = { - DateRange.TODAY: self._get_today_range, - DateRange.YESTERDAY: self._get_yesterday_range, - DateRange.THIS_WEEK: self._get_this_week_range, - DateRange.LAST_WEEK: self._get_last_week_range, - DateRange.THIS_MONTH: self._get_this_month_range, - DateRange.LAST_MONTH: self._get_last_month_range, - DateRange.THIS_YEAR: self._get_this_year_range, - DateRange.LAST_YEAR: self._get_last_year_range, - DateRange.LAST_7_DAYS: lambda n: self._get_last_n_days_range(n, 7), - DateRange.LAST_30_DAYS: lambda n: self._get_last_n_days_range(n, 30), - } - - if self in range_methods: - return range_methods[self](now) - else: - raise ValueError("Invalid DateRange enum value") - - def _get_today_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get today's start and end datetime.""" - start = now.replace(hour=0, minute=0, second=0, microsecond=0) - end = now.replace(hour=23, minute=59, second=59, microsecond=999999) - return start, end - - def _get_yesterday_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get yesterday's start and end datetime.""" - yesterday = now - timedelta(days=1) - start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) - end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999) - return start, end - - def _get_this_week_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get this week's start and end datetime.""" - # Start of current week (Monday) - start = now - timedelta(days=now.weekday()) - start = start.replace(hour=0, minute=0, second=0, microsecond=0) - end = start + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) - return start, end - - def _get_last_week_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get last week's start and end datetime.""" - # Start of last week (Monday) - start = now - timedelta(days=now.weekday() + 7) - start = start.replace(hour=0, minute=0, second=0, microsecond=0) - end = start + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) - return start, end - - def _get_this_month_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get this month's start and end datetime.""" - start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - end = now - return start, end - - def _get_last_month_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get last month's start and end datetime.""" - # First day of current month - first_of_current_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - # Last day of previous month - end = first_of_current_month - timedelta(microseconds=1) - # First day of previous month - start = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - return start, end - - def _get_this_year_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get this year's start and end datetime.""" - start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) - end = now - return start, end - - def _get_last_year_range(self, now: datetime) -> tuple[datetime, datetime]: - """Get last year's start and end datetime.""" - last_year = now.year - 1 - start = now.replace( - year=last_year, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - end = now.replace( - year=last_year, - month=12, - day=31, - hour=23, - minute=59, - second=59, - microsecond=999999, - ) - return start, end - - def _get_last_n_days_range(self, now: datetime, days: int) -> tuple[datetime, datetime]: - """Get range for last N days.""" - start = now - timedelta(days=days) - end = now - return start, end - - def to_start_datetime(self, reference_time: datetime | None = None) -> datetime: - """Get just the start datetime for this range - - Args: - reference_time: Optional reference time for calculations. Defaults to now in UTC. - - Returns: - Start datetime for this range - """ - start, _ = self.to_datetime_range(reference_time) - return start - - def to_end_datetime(self, reference_time: datetime | None = None) -> datetime: - """Get just the end datetime for this range - - Args: - reference_time: Optional reference time for calculations. Defaults to now in UTC. - - Returns: - End datetime for this range - """ - _, end = self.to_datetime_range(reference_time) - return end - - def to_iso_strings(self, reference_time: datetime | None = None) -> tuple[str, str]: - """Get start and end as ISO format strings - - Args: - reference_time: Optional reference time for calculations. Defaults to now in UTC. - - Returns: - Tuple of (start_iso, end_iso) strings - """ - start, end = self.to_datetime_range(reference_time) - return start.isoformat(), end.isoformat() - - @classmethod - def from_string(cls, date_str: str) -> Optional["DateRange"]: - """Create DateRange from string if it matches a known value - - Args: - date_str: String representation of date range - - Returns: - DateRange enum or None if no match found - """ - normalized = date_str.lower().strip() - - # Direct mapping - value_map = { - "today": cls.TODAY, - "yesterday": cls.YESTERDAY, - "this week": cls.THIS_WEEK, - "last week": cls.LAST_WEEK, - "this month": cls.THIS_MONTH, - "last month": cls.LAST_MONTH, - "this year": cls.THIS_YEAR, - "last year": cls.LAST_YEAR, - "last 7 days": cls.LAST_7_DAYS, - "last 30 days": cls.LAST_30_DAYS, - } - - return value_map.get(normalized) diff --git a/toolkits/linear/arcade_linear/tools/__init__.py b/toolkits/linear/arcade_linear/tools/__init__.py deleted file mode 100644 index b58cbf66..00000000 --- a/toolkits/linear/arcade_linear/tools/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Linear tools for Arcade AI""" - -from arcade_linear.tools.issues import get_issue -from arcade_linear.tools.teams import get_teams - -__all__ = [ - "get_issue", - "get_teams", -] diff --git a/toolkits/linear/arcade_linear/tools/issues.py b/toolkits/linear/arcade_linear/tools/issues.py deleted file mode 100644 index 7f1174f6..00000000 --- a/toolkits/linear/arcade_linear/tools/issues.py +++ /dev/null @@ -1,88 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Linear - -from arcade_linear.client import LinearClient -from arcade_linear.utils import clean_issue_data, parse_date_string - - -@tool(requires_auth=Linear(scopes=["read"])) -async def get_issue( - context: ToolContext, - issue_id: Annotated[ - str, "The Linear issue ID or identifier (e.g. 'FE-123', 'API-456') to retrieve." - ], - include_comments: Annotated[ - bool, "Whether to include comments in the response. Defaults to True." - ] = True, - include_attachments: Annotated[ - bool, "Whether to include attachments in the response. Defaults to True." - ] = True, - include_relations: Annotated[ - bool, - "Whether to include issue relations (blocks, dependencies) in the response. " - "Defaults to True.", - ] = True, - include_children: Annotated[ - bool, "Whether to include sub-issues in the response. Defaults to True." - ] = True, -) -> Annotated[dict[str, Any], "Complete issue details with related data"]: - """Get detailed information about a specific Linear issue - - This tool retrieves complete information about a single Linear issue when you have - its specific ID or identifier. It's purely for reading and viewing data. - - What this tool provides: - - Complete issue details (title, description, status, assignee, etc.) - - Comments and discussion history (if requested) - - File attachments (if requested) - - Related issues and dependencies (if requested) - - Sub-issues and hierarchical relationships (if requested) - - When to use this tool: - - When you need to examine the full details of a specific issue - - When you want to read issue content, comments, or relationships - - When you need to analyze or compare issue information - - When you have an issue ID and need to understand its current state - - When NOT to use this tool: - - Do NOT use this if you need to change, modify, or update anything - - Do NOT use this if you're trying to create new issues - - Do NOT use this if you're searching for multiple issues - - This tool is READ-ONLY - it cannot make any changes to issues. - """ - - client = LinearClient(context.get_auth_token_or_empty()) - - # Get issue data - issue_data = await client.get_issue_by_id(issue_id) - - if not issue_data: - return {"error": f"Issue not found: {issue_id}"} - - # Clean and format the issue data - cleaned_issue = clean_issue_data(issue_data) - - # Optionally remove certain fields based on parameters - if not include_comments: - cleaned_issue.pop("comments", None) - - if not include_attachments: - cleaned_issue.pop("attachments", None) - - if not include_relations: - cleaned_issue.pop("relations", None) - - if not include_children: - cleaned_issue.pop("children", None) - - # Get current timestamp for retrieval time - current_time = parse_date_string("now") - retrieved_at = current_time.isoformat() if current_time else None - - return { - "issue": cleaned_issue, - "retrieved_at": retrieved_at, - } diff --git a/toolkits/linear/arcade_linear/tools/teams.py b/toolkits/linear/arcade_linear/tools/teams.py deleted file mode 100644 index e8a24438..00000000 --- a/toolkits/linear/arcade_linear/tools/teams.py +++ /dev/null @@ -1,107 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Linear - -from arcade_linear.client import LinearClient -from arcade_linear.utils import ( - add_pagination_info, - clean_team_data, - parse_date_string, - validate_date_format, -) - - -@tool(requires_auth=Linear(scopes=["read"])) -async def get_teams( - context: ToolContext, - team_name: Annotated[ - str | None, - "Filter by team name. Provide specific team name (e.g. 'Frontend', 'Product Web') " - "or partial name. Use this to find specific teams or check team membership. " - "Defaults to None (all teams).", - ] = None, - include_archived: Annotated[ - bool, "Whether to include archived teams in results. Defaults to False." - ] = False, - created_after: Annotated[ - str | None, - "Filter teams created after this date. Can be:\n" - "- Relative date string (e.g. 'last month', 'this week', 'yesterday')\n" - "- ISO date string (e.g. 'YYYY-MM-DD')\n" - "Defaults to None (all time).", - ] = None, - limit: Annotated[ - int, "Maximum number of teams to return. Min 1, max 100. Defaults to 50." - ] = 50, - end_cursor: Annotated[ - str | None, - "Cursor for pagination - get teams after this cursor. Use the 'end_cursor' " - "from previous response. Defaults to None (start from beginning).", - ] = None, -) -> Annotated[dict[str, Any], "Teams in the workspace with member information"]: - """Get Linear teams and team information including team members - - This tool retrieves team information from your Linear workspace, including team details, - settings, and member information. Use this tool for team discovery and team membership queries. - - What this tool provides: - - Team basic information (name, key, description) - - Team members and their roles - - Team settings and configuration - - Team creation and status information - - Team hierarchy and relationships - - This tool is the primary way to get team information. - """ - - # Validate inputs - limit = max(1, min(limit, 100)) - - # Parse and validate date - created_after_date = None - if created_after: - # Validate and parse string (handles DateRange enum strings internally) - validate_date_format("created_after", created_after) - created_after_date = parse_date_string(created_after) - - client = LinearClient(context.get_auth_token_or_empty()) - - # Get teams with filtering - teams_response = await client.get_teams( - first=limit, - after=end_cursor, - include_archived=include_archived, - name_filter=team_name, - ) - - # Apply additional filtering if needed - teams = teams_response["nodes"] - - # Filter by creation date if specified - if created_after_date: - filtered_teams = [] - for team in teams: - team_created_at = parse_date_string(team.get("createdAt", "")) - if team_created_at and team_created_at >= created_after_date: - filtered_teams.append(team) - teams = filtered_teams - - # Clean and format teams - cleaned_teams = [clean_team_data(team) for team in teams] - - response = { - "teams": cleaned_teams, - "total_count": len(cleaned_teams), - "filters": { - "team_name": team_name, - "include_archived": include_archived, - "created_after": created_after, - }, - } - - # Add pagination info - if "pageInfo" in teams_response and teams_response["pageInfo"].get("hasNextPage"): - add_pagination_info(response, teams_response["pageInfo"]) - - return response diff --git a/toolkits/linear/arcade_linear/utils.py b/toolkits/linear/arcade_linear/utils.py deleted file mode 100644 index f69adab6..00000000 --- a/toolkits/linear/arcade_linear/utils.py +++ /dev/null @@ -1,332 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, cast - -import dateparser -from arcade_tdk.errors import ToolExecutionError - -from arcade_linear.models import DateRange - -# Error message constants -INVALID_DATE_FORMAT_ERROR = "Invalid date format for {field}: '{value}'" - - -def remove_none_values(data: dict[str, Any]) -> dict[str, Any]: - """Remove None values from a dictionary""" - return {k: v for k, v in data.items() if v is not None} - - -def parse_date_string(date_str: str) -> datetime | None: - """Parse a date string into a timezone-aware datetime object - - First tries to match against DateRange enum for consistent relative dates, - then falls back to dateparser for flexible parsing. - """ - if not date_str: - return None - - # Check if it's a relative time expression that matches our DateRange enum - date_range = DateRange.from_string(date_str) - if date_range: - # For relative dates, return the start of the range - # This maintains backward compatibility with existing usage - return date_range.to_start_datetime() - - # Fall back to dateparser for other formats (ISO dates, etc.) - try: - parsed_date = dateparser.parse(date_str) - if parsed_date is None: - return None - - # Cast to datetime to satisfy type checker - parsed_datetime = cast(datetime, parsed_date) - - # Ensure all datetimes are timezone-aware (UTC) - if parsed_datetime.tzinfo is None: - return parsed_datetime.replace(tzinfo=timezone.utc) - else: - return parsed_datetime - except Exception: - return None - - -def parse_date_range(date_str: str) -> tuple[datetime, datetime] | None: - """Parse a date string into a datetime range tuple if it matches a DateRange enum - - Args: - date_str: String that might represent a date range - - Returns: - Tuple of (start_datetime, end_datetime) or None if not a valid range - """ - if not date_str: - return None - - date_range = DateRange.from_string(date_str) - if date_range: - return date_range.to_datetime_range() - - return None - - -def validate_date_format(field_name: str, date_str: str | None) -> None: - """Validate date format and raise error if invalid""" - if not date_str: - return - - parsed_date = parse_date_string(date_str) - if parsed_date is None: - raise ToolExecutionError(INVALID_DATE_FORMAT_ERROR.format(field=field_name, value=date_str)) - - -def clean_user_data(user_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format user data""" - if not user_data: - return {} - - return remove_none_values({ - "id": user_data.get("id"), - "name": user_data.get("name"), - "email": user_data.get("email"), - "display_name": user_data.get("displayName"), - "avatar_url": user_data.get("avatarUrl"), - }) - - -def clean_team_data(team_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format team data""" - if not team_data: - return {} - - cleaned = { - "id": team_data.get("id"), - "key": team_data.get("key"), - "name": team_data.get("name"), - "description": team_data.get("description"), - "private": team_data.get("private"), - "archived_at": team_data.get("archivedAt"), - "created_at": team_data.get("createdAt"), - "updated_at": team_data.get("updatedAt"), - "icon": team_data.get("icon"), - "color": team_data.get("color"), - } - - if team_data.get("members") and team_data["members"].get("nodes"): - cleaned["members"] = [clean_user_data(member) for member in team_data["members"]["nodes"]] - - return remove_none_values(cleaned) - - -def clean_state_data(state_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format workflow state data""" - if not state_data: - return {} - - return remove_none_values({ - "id": state_data.get("id"), - "name": state_data.get("name"), - "type": state_data.get("type"), - "color": state_data.get("color"), - "position": state_data.get("position"), - }) - - -def clean_project_data(project_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format project data""" - if not project_data: - return {} - - return remove_none_values({ - "id": project_data.get("id"), - "name": project_data.get("name"), - "description": project_data.get("description"), - "state": project_data.get("state"), - "progress": project_data.get("progress"), - "start_date": project_data.get("startDate"), - "target_date": project_data.get("targetDate"), - "url": project_data.get("url"), - }) - - -def clean_label_data(label_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format label data""" - if not label_data: - return {} - - return remove_none_values({ - "id": label_data.get("id"), - "name": label_data.get("name"), - "color": label_data.get("color"), - "description": label_data.get("description"), - }) - - -def clean_relation_data(relation_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format issue relation data""" - if not relation_data: - return {} - - cleaned = { - "id": relation_data.get("id"), - "type": relation_data.get("type"), - } - - # Clean related issue data - if relation_data.get("relatedIssue"): - cleaned["related_issue"] = { - "id": relation_data["relatedIssue"].get("id"), - "identifier": relation_data["relatedIssue"].get("identifier"), - "title": relation_data["relatedIssue"].get("title"), - } - - return remove_none_values(cleaned) - - -def clean_comment_data(comment_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format comment data""" - if not comment_data: - return {} - - cleaned = { - "id": comment_data.get("id"), - "body": comment_data.get("body"), - "created_at": comment_data.get("createdAt"), - "updated_at": comment_data.get("updatedAt"), - } - - # Clean user data for comment author - if comment_data.get("user"): - cleaned["user"] = clean_user_data(comment_data["user"]) - - return remove_none_values(cleaned) - - -def clean_attachment_data(attachment_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format attachment data""" - if not attachment_data: - return {} - - return remove_none_values({ - "id": attachment_data.get("id"), - "title": attachment_data.get("title"), - "subtitle": attachment_data.get("subtitle"), - "url": attachment_data.get("url"), - "metadata": attachment_data.get("metadata"), - "created_at": attachment_data.get("createdAt"), - }) - - -def _clean_issue_relations(issue_data: dict[str, Any]) -> list[dict[str, Any]]: - """Clean issue relations data""" - relations = issue_data.get("relations", {}) - if not relations or not relations.get("nodes"): - return [] - - cleaned_relations = [] - for relation in relations["nodes"]: - if relation and relation.get("relatedIssue"): - cleaned_relations.append({ - "id": relation.get("id"), - "type": relation.get("type"), - "related_issue": { - "id": relation["relatedIssue"].get("id"), - "identifier": relation["relatedIssue"].get("identifier"), - "title": relation["relatedIssue"].get("title"), - }, - }) - return cleaned_relations - - -def _clean_issue_children(issue_data: dict[str, Any]) -> list[dict[str, Any]]: - """Clean issue children data""" - children = issue_data.get("children", {}) - if not children or not children.get("nodes"): - return [] - - cleaned_children = [] - for child in children["nodes"]: - if child: - cleaned_children.append({ - "id": child.get("id"), - "identifier": child.get("identifier"), - "title": child.get("title"), - "state": clean_state_data(child.get("state", {})), - }) - return cleaned_children - - -def _clean_issue_labels(issue_data: dict[str, Any]) -> list[dict[str, Any]]: - """Clean issue labels data""" - labels = issue_data.get("labels", {}) - if not labels or not labels.get("nodes"): - return [] - - return [clean_label_data(label) for label in labels["nodes"] if label] - - -def _clean_issue_comments(issue_data: dict[str, Any]) -> list[dict[str, Any]]: - """Clean issue comments data""" - comments = issue_data.get("comments", {}) - if not comments or not comments.get("nodes"): - return [] - - return [clean_comment_data(comment) for comment in comments["nodes"] if comment] - - -def _clean_issue_attachments(issue_data: dict[str, Any]) -> list[dict[str, Any]]: - """Clean issue attachments data""" - attachments = issue_data.get("attachments", {}) - if not attachments or not attachments.get("nodes"): - return [] - - return [clean_attachment_data(attachment) for attachment in attachments["nodes"] if attachment] - - -def clean_issue_data(issue_data: dict[str, Any]) -> dict[str, Any]: - """Clean and format issue data for consistent output""" - if not issue_data: - return {} - - # Clean basic issue data - cleaned_issue = { - "id": issue_data.get("id"), - "identifier": issue_data.get("identifier"), - "title": issue_data.get("title"), - "description": issue_data.get("description"), - "priority": issue_data.get("priority"), - "priority_label": issue_data.get("priorityLabel"), - "estimate": issue_data.get("estimate"), - "sort_order": issue_data.get("sortOrder"), - "created_at": issue_data.get("createdAt"), - "updated_at": issue_data.get("updatedAt"), - "completed_at": issue_data.get("completedAt"), - "canceled_at": issue_data.get("canceledAt"), - "due_date": issue_data.get("dueDate"), - "url": issue_data.get("url"), - "branch_name": issue_data.get("branchName"), - "creator": clean_user_data(issue_data.get("creator", {})), - "assignee": clean_user_data(issue_data.get("assignee", {})), - "state": clean_state_data(issue_data.get("state", {})), - "team": clean_team_data(issue_data.get("team", {})), - "project": clean_project_data(issue_data.get("project", {})), - "parent": clean_issue_data(issue_data.get("parent", {})) - if issue_data.get("parent") - else None, - "labels": _clean_issue_labels(issue_data), - "comments": _clean_issue_comments(issue_data), - "attachments": _clean_issue_attachments(issue_data), - "relations": _clean_issue_relations(issue_data), - "children": _clean_issue_children(issue_data), - } - - return remove_none_values(cleaned_issue) - - -def add_pagination_info(response: dict[str, Any], page_info: dict[str, Any]) -> dict[str, Any]: - """Add pagination information to response""" - response["pagination"] = { - "has_next_page": page_info.get("hasNextPage", False), - "has_previous_page": page_info.get("hasPreviousPage", False), - "start_cursor": page_info.get("startCursor"), - "end_cursor": page_info.get("endCursor"), - } - return response diff --git a/toolkits/linear/conftest.py b/toolkits/linear/conftest.py deleted file mode 100644 index 16bd92ad..00000000 --- a/toolkits/linear/conftest.py +++ /dev/null @@ -1,335 +0,0 @@ -import json -import random -import string -from collections.abc import Callable -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - -# Seed random generator for deterministic tests -random.seed(42) - -# Hardcoded email list for deterministic testing with varied domains -TEST_EMAILS = [ - "alice.smith@testcorp.com", - "bob.jones@acme.org", - "charlie.brown@techstart.io", - "diana.wilson@example.net", - "eve.davis@startup.co", - "frank.miller@bigtech.com", - "grace.taylor@innovation.ai", - "henry.anderson@devteam.dev", - "iris.johnson@design.studio", - "jack.white@cloudops.tech", - "karen.thomas@product.team", - "liam.jackson@engineering.co", - "mia.harris@marketing.agency", - "noah.martin@sales.pro", - "olivia.garcia@support.help", - "peter.rodriguez@finance.biz", - "quinn.lewis@legal.firm", - "rachel.lee@hr.people", - "sam.walker@operations.work", - "tina.hall@consulting.group", -] - -_email_counter = 0 - - -@pytest.fixture -def fake_auth_token() -> str: - return generate_random_str() - - -def generate_random_str(length: int = 8) -> str: - """Generate a deterministic random string for testing""" - return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(length)) # noqa: S311 - - -def generate_random_int(min_val: int = 1, max_val: int = 9999) -> int: - """Generate a deterministic random integer for testing""" - return random.randint(min_val, max_val) # noqa: S311 - - -def get_test_email() -> str: - """Get the next email from the hardcoded list, cycling through them""" - global _email_counter - email = TEST_EMAILS[_email_counter % len(TEST_EMAILS)] - _email_counter += 1 - return email - - -@pytest.fixture -def generate_random_email() -> Callable[[str | None, str | None], str]: - def random_email_generator(name: str | None = None, domain: str | None = None) -> str: - # If specific name/domain provided, use them, otherwise use hardcoded emails - if name is None and domain is None: - return get_test_email() - - name = name or generate_random_str() - domain = domain or "example.com" - return f"{name}@{domain}" - - return random_email_generator - - -@pytest.fixture -def mock_context(fake_auth_token: str) -> ToolContext: - mock_auth = ToolAuthorizationContext(token=fake_auth_token) - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_httpx_client(): - """Mock httpx.AsyncClient for GraphQL requests""" - with patch("arcade_linear.client.httpx.AsyncClient") as mock_client_class: - # Create an async mock for the client instance - mock_client_instance = MagicMock() - - # Mock the async context manager methods - mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client_instance) - mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) - - # Make the post method async - mock_client_instance.post = AsyncMock() - - yield mock_client_instance - - -@pytest.fixture -def mock_httpx_response() -> Callable[[int, dict], httpx.Response]: - """Create mock httpx.Response objects""" - - def generate_mock_httpx_response(status_code: int, json_data: dict) -> httpx.Response: - response = MagicMock(spec=httpx.Response) - response.status_code = status_code - response.json.return_value = json_data - response.reason_phrase = "OK" if status_code == 200 else "Error" - response.text = json.dumps(json_data) - return response - - return generate_mock_httpx_response - - -# Linear-specific test data builders -@pytest.fixture -def build_user_dict( - generate_random_email: Callable[[str | None, str | None], str], -) -> Callable: - def user_dict_builder( - id_: str | None = None, - email: str | None = None, - name: str | None = None, - display_name: str | None = None, - active: bool = True, - ) -> dict[str, Any]: - name = name or generate_random_str() - return { - "id": id_ or generate_random_str(), - "name": name, - "email": email or generate_random_email(name=name), - "displayName": display_name or name, - "avatarUrl": f"https://avatar.example.com/{generate_random_str()}.png", - "active": active, - } - - return user_dict_builder - - -@pytest.fixture -def build_team_dict() -> Callable: - def team_dict_builder( - id_: str | None = None, - key: str | None = None, - name: str | None = None, - description: str | None = None, - ) -> dict[str, Any]: - name = name or generate_random_str() - return { - "id": id_ or generate_random_str(), - "key": key or generate_random_str(3).upper(), - "name": name, - "description": description or f"Description for {name}", - "private": False, - "archivedAt": None, - "createdAt": "2023-01-01T00:00:00.000Z", - "updatedAt": "2023-01-01T00:00:00.000Z", - "icon": "🚀", - "color": "#FF6B6B", - "cyclesEnabled": True, - "issueEstimationType": "exponential", - "organization": {"id": generate_random_str(), "name": "Test Organization"}, - "members": {"nodes": []}, - } - - return team_dict_builder - - -@pytest.fixture -def build_issue_dict(build_user_dict: Callable, build_team_dict: Callable) -> Callable: - def issue_dict_builder( - id_: str | None = None, - identifier: str | None = None, - title: str | None = None, - description: str | None = None, - priority: int = 2, - priority_label: str = "Medium", - ) -> dict[str, Any]: - user = build_user_dict() - team = build_team_dict() - return { - "id": id_ or generate_random_str(), - "identifier": identifier or f"TEST-{generate_random_int(1, 9999)}", - "title": title or f"Test Issue {generate_random_str()}", - "description": description or f"Description for test issue {generate_random_str()}", - "priority": priority, - "priorityLabel": priority_label, - "estimate": None, - "sortOrder": 100.0, - "createdAt": "2023-01-01T00:00:00.000Z", - "updatedAt": "2023-01-01T00:00:00.000Z", - "completedAt": None, - "canceledAt": None, - "dueDate": None, - "url": f"https://linear.app/test/issue/{identifier or 'TEST-1'}", - "branchName": None, - "creator": user, - "assignee": user, - "state": { - "id": generate_random_str(), - "name": "Todo", - "type": "unstarted", - "color": "#e2e2e2", - "position": 1, - }, - "team": team, - "project": None, - "cycle": None, - "parent": None, - "labels": {"nodes": []}, - "children": {"nodes": []}, - "relations": {"nodes": []}, - } - - return issue_dict_builder - - -@pytest.fixture -def build_workflow_state_dict(build_team_dict: Callable) -> Callable: - def workflow_state_dict_builder( - id_: str | None = None, - name: str | None = None, - type_: str = "unstarted", - color: str = "#e2e2e2", - position: float = 1.0, - ) -> dict[str, Any]: - team = build_team_dict() - return { - "id": id_ or generate_random_str(), - "name": name or f"State {generate_random_str()}", - "description": f"Description for {name or 'test state'}", - "type": type_, - "color": color, - "position": position, - "team": team, - } - - return workflow_state_dict_builder - - -@pytest.fixture -def build_cycle_dict(build_team_dict: Callable) -> Callable: - def cycle_dict_builder( - id_: str | None = None, - number: int | None = None, - name: str | None = None, - description: str | None = None, - ) -> dict[str, Any]: - team = build_team_dict() - number = number or generate_random_int(1, 100) - return { - "id": id_ or generate_random_str(), - "number": number, - "name": name or f"Sprint {number}", - "description": description or f"Description for Sprint {number}", - "startsAt": "2023-01-01T00:00:00.000Z", - "endsAt": "2023-01-14T23:59:59.000Z", - "completedAt": None, - "autoArchivedAt": None, - "progress": 0.5, - "createdAt": "2023-01-01T00:00:00.000Z", - "updatedAt": "2023-01-01T00:00:00.000Z", - "team": team, - "issues": {"nodes": []}, - } - - return cycle_dict_builder - - -@pytest.fixture -def build_project_dict(build_user_dict: Callable) -> Callable: - def project_dict_builder( - id_: str | None = None, - name: str | None = None, - description: str | None = None, - state: str = "planned", - ) -> dict[str, Any]: - user = build_user_dict() - return { - "id": id_ or generate_random_str(), - "name": name or f"Project {generate_random_str()}", - "description": description or "Description for test project", - "state": state, - "progress": 0.3, - "startDate": "2023-01-01", - "targetDate": "2023-12-31", - "completedAt": None, - "canceledAt": None, - "autoArchivedAt": None, - "createdAt": "2023-01-01T00:00:00.000Z", - "updatedAt": "2023-01-01T00:00:00.000Z", - "icon": "📋", - "color": "#4F46E5", - "creator": user, - "lead": user, - "teams": {"nodes": []}, - "members": {"nodes": []}, - } - - return project_dict_builder - - -# GraphQL response builders -@pytest.fixture -def build_graphql_response() -> Callable[[dict], dict]: - def graphql_response_builder(data: dict, errors: list | None = None) -> dict: - response = {"data": data} - if errors: - response["errors"] = errors - return response - - return graphql_response_builder - - -@pytest.fixture -def build_paginated_response() -> Callable[[list, bool, str | None, str | None], dict]: - def paginated_response_builder( - nodes: list, - has_next_page: bool = False, - start_cursor: str | None = None, - end_cursor: str | None = None, - ) -> dict: - return { - "nodes": nodes, - "pageInfo": { - "hasNextPage": has_next_page, - "hasPreviousPage": False, - "startCursor": start_cursor, - "endCursor": end_cursor, - }, - } - - return paginated_response_builder diff --git a/toolkits/linear/evals/eval_issues.py b/toolkits/linear/evals/eval_issues.py deleted file mode 100644 index 2c61d05f..00000000 --- a/toolkits/linear/evals/eval_issues.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Evaluation suite for Linear get_issue tool. -""" - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_linear -from arcade_linear.tools.issues import get_issue - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - -# Tool catalog -catalog = ToolCatalog() -catalog.add_module(arcade_linear) - - -@tool_eval() -def get_issue_eval_suite() -> EvalSuite: - """Comprehensive evaluation suite for get_issue tool""" - suite = EvalSuite( - name="Get Issue Evaluation", - system_message=( - "You are an AI assistant with access to Linear tools. " - "Use them to help the user get detailed information about Linear issues." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get complete issue details", - user_message="Show me complete details for issue API-789", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue, - args={ - "issue_id": "API-789", - "include_comments": True, - "include_attachments": True, - "include_relations": True, - "include_children": True, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="issue_id", weight=0.6), - BinaryCritic(critic_field="include_comments", weight=0.1), - BinaryCritic(critic_field="include_attachments", weight=0.1), - BinaryCritic(critic_field="include_relations", weight=0.1), - BinaryCritic(critic_field="include_children", weight=0.1), - ], - ) - - suite.add_case( - name="Get issue dependencies", - user_message="Find all dependencies for issue PROJ-100", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue, - args={ - "issue_id": "PROJ-100", - "include_relations": True, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="issue_id", weight=0.7), - BinaryCritic(critic_field="include_relations", weight=0.3), - ], - ) - - suite.add_case( - name="Get issue with sub-issues and dependencies", - user_message="Get issue FE-123 with all related sub-issues and dependencies", - expected_tool_calls=[ - ExpectedToolCall( - func=get_issue, - args={ - "issue_id": "FE-123", - "include_relations": True, - "include_children": True, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="issue_id", weight=0.5), - BinaryCritic(critic_field="include_relations", weight=0.25), - BinaryCritic(critic_field="include_children", weight=0.25), - ], - ) - - return suite diff --git a/toolkits/linear/evals/eval_teams.py b/toolkits/linear/evals/eval_teams.py deleted file mode 100644 index 236a2dec..00000000 --- a/toolkits/linear/evals/eval_teams.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -Evaluation suite for Linear get_teams tool. -""" - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_linear -from arcade_linear.tools.teams import get_teams - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_linear) - - -@tool_eval() -def teams_eval_suite() -> EvalSuite: - """Comprehensive evaluation suite for get_teams tool""" - suite = EvalSuite( - name="Teams Management Evaluation", - system_message=( - "You are an AI assistant with access to Linear tools. " - "Use them to help the user manage Linear teams and organizational structure." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get all teams in workspace", - user_message="Show me all teams in our workspace", - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={}, - ), - ], - critics=[], # No specific args expected - ) - - suite.add_case( - name="Find recently created teams", - user_message="Which teams were created in the last month?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "created_after": "last month", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="created_after", weight=1.0), - ], - ) - - suite.add_case( - name="Find teams created this week", - user_message="Show me teams created this week", - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "created_after": "this week", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="created_after", weight=1.0), - ], - ) - - suite.add_case( - name="Find teams created in last 7 days", - user_message="Which teams were created in the last 7 days?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "created_after": "last 7 days", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="created_after", weight=1.0), - ], - ) - - suite.add_case( - name="Get active teams only", - user_message="Find teams that aren't archived", - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "include_archived": False, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="include_archived", weight=1.0), - ], - ) - - suite.add_case( - name="Search teams by name", - user_message='Find teams that have "Engineering" in their name', - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "team_name": "Engineering", - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="team_name", weight=1.0), - ], - ) - - suite.add_case( - name="Get specific team by name", - user_message="Show me the Frontend team details", - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "team_name": "Frontend", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="team_name", weight=1.0), - ], - ) - - suite.add_case( - name="Clarify ambiguous team request", - user_message="I need to see the Engineering team info", - additional_messages=[ - { - "role": "assistant", - "content": ( - "I found multiple teams with 'Engineering' in the name. " - "Could you be more specific about which Engineering team you're looking for?" - ), - }, - {"role": "user", "content": "I meant the Backend Engineering team specifically"}, - ], - expected_tool_calls=[ - ExpectedToolCall( - func=get_teams, - args={ - "team_name": "Backend Engineering", - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="team_name", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/linear/pyproject.toml b/toolkits/linear/pyproject.toml deleted file mode 100644 index 9066e2fe..00000000 --- a/toolkits/linear/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_linear" -version = "0.1.0" -description = "Arcade tools designed for LLMs to interact with Linear" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", - "dateparser>=1.1.8,<2.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", - "types-dateparser>=1.2.2.20250627", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_linear/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_linear",] diff --git a/toolkits/linear/tests/__init__.py b/toolkits/linear/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/linear/tests/test_client.py b/toolkits/linear/tests/test_client.py deleted file mode 100644 index 6ca23398..00000000 --- a/toolkits/linear/tests/test_client.py +++ /dev/null @@ -1,154 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_linear.client import LinearClient - - -class TestLinearClient: - """Tests for LinearClient""" - - @pytest.mark.asyncio - async def test_client_init(self): - """Test client initialization""" - test_token = "test_token" # noqa: S105 - client = LinearClient(test_token) - assert client.auth_token == test_token - assert client.api_url == "https://api.linear.app/graphql" - assert client.max_concurrent_requests == 3 - assert client.timeout_seconds == 30 - - def test_build_headers(self): - """Test header building""" - client = LinearClient("test_token") - headers = client._build_headers() - - assert headers["Authorization"] == "Bearer test_token" - assert headers["Content-Type"] == "application/json" - assert headers["Accept"] == "application/json" - - def test_build_error_message_graphql_single(self): - """Test error message building for single GraphQL error""" - client = LinearClient("test_token") - - # Mock response with single GraphQL error - response = AsyncMock() - response.status_code = 200 - response.json = lambda: { - "errors": [{"message": "Field not found", "extensions": {"code": "FIELD_ERROR"}}] - } - - user_msg, dev_msg = client._build_error_message(response) - - assert user_msg == "Field not found" - assert "Field not found" in dev_msg - assert "FIELD_ERROR" in dev_msg - - def test_build_error_message_http_error(self): - """Test error message building for HTTP errors""" - client = LinearClient("test_token") - - # Mock HTTP error response with valid JSON but no GraphQL errors - response = AsyncMock() - response.status_code = 401 - response.reason_phrase = "Unauthorized" - response.text = "Authentication required" - - # Return valid JSON that doesn't have "errors" field - response.json = lambda: {"message": "Authentication required"} - - user_msg, dev_msg = client._build_error_message(response) - - assert user_msg == "HTTP 401: Unauthorized" - assert "HTTP 401: Authentication required" in dev_msg - - @pytest.mark.asyncio - async def test_raise_for_status_success(self): - """Test _raise_for_status with successful response""" - client = LinearClient("test_token") - - # Mock successful response - response = AsyncMock() - response.status_code = 200 - response.json = lambda: {"data": {"viewer": {"id": "user_1"}}} - - # Should not raise exception - client._raise_for_status(response) - - @pytest.mark.asyncio - async def test_raise_for_status_graphql_error(self): - """Test _raise_for_status with GraphQL errors""" - client = LinearClient("test_token") - - # Mock response with GraphQL errors - response = AsyncMock() - response.status_code = 200 - response.json = lambda: {"errors": [{"message": "Invalid field"}]} - - with pytest.raises(ToolExecutionError) as exc_info: - client._raise_for_status(response) - - assert "Invalid field" in str(exc_info.value) - - @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_execute_query_success(self, mock_http_client): - """Test successful GraphQL query execution""" - client = LinearClient("test_token") - - # Mock HTTP client and response - mock_client_instance = AsyncMock() - mock_http_client.return_value.__aenter__.return_value = mock_client_instance - - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = lambda: {"data": {"viewer": {"id": "user_1"}}} - mock_client_instance.post.return_value = mock_response - - query = "query { viewer { id } }" - variables = {"test": "value"} - - result = await client.execute_query(query, variables) - - assert result["data"]["viewer"]["id"] == "user_1" - mock_client_instance.post.assert_called_once() - - @pytest.mark.asyncio - @patch("arcade_linear.client.LinearClient.execute_query") - async def test_get_teams(self, mock_execute_query): - """Test get_teams method""" - client = LinearClient("test_token") - - mock_execute_query.return_value = { - "data": { - "teams": { - "nodes": [{"id": "team_1", "name": "Frontend", "key": "FE"}], - "pageInfo": {"hasNextPage": False}, - } - } - } - - result = await client.get_teams(first=10, include_archived=True) - - assert len(result["nodes"]) == 1 - assert result["nodes"][0]["name"] == "Frontend" - assert result["pageInfo"]["hasNextPage"] is False - mock_execute_query.assert_called_once() - - @pytest.mark.asyncio - @patch("arcade_linear.client.LinearClient.execute_query") - async def test_get_issue_by_id(self, mock_execute_query): - """Test get_issue_by_id method""" - client = LinearClient("test_token") - - mock_execute_query.return_value = { - "data": {"issue": {"id": "issue_1", "identifier": "FE-123", "title": "Test issue"}} - } - - result = await client.get_issue_by_id("FE-123") - - assert result["id"] == "issue_1" - assert result["identifier"] == "FE-123" - assert result["title"] == "Test issue" - mock_execute_query.assert_called_once() diff --git a/toolkits/linear/tests/test_issues.py b/toolkits/linear/tests/test_issues.py deleted file mode 100644 index 1b13b92e..00000000 --- a/toolkits/linear/tests/test_issues.py +++ /dev/null @@ -1,94 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest - -from arcade_linear.tools.issues import get_issue - - -@pytest.fixture -def mock_context(): - """Mock context for testing""" - context = AsyncMock() - context.get_auth_token_or_empty.return_value = "test-token" - return context - - -class TestGetIssue: - """Tests for get_issue tool""" - - @pytest.mark.asyncio - @patch("arcade_linear.tools.issues.LinearClient") - async def test_get_issue_success(self, mock_client_class, mock_context): - """Test successful issue retrieval""" - # Setup mock - mock_client = AsyncMock() - mock_client_class.return_value = mock_client - mock_client.get_issue_by_id.return_value = { - "id": "issue_1", - "identifier": "FE-123", - "title": "Fix authentication bug", - "description": "Authentication not working", - "priority": 1, - "priorityLabel": "Urgent", - "createdAt": "2024-01-01T00:00:00Z", - "team": {"id": "team_1", "key": "FE", "name": "Frontend"}, - "assignee": {"id": "user_1", "name": "John Doe"}, - "state": {"id": "state_1", "name": "In Progress"}, - "labels": {"nodes": []}, - "attachments": {"nodes": []}, - "comments": {"nodes": []}, - "children": {"nodes": []}, - "relations": {"nodes": []}, - } - - # Call function - result = await get_issue(mock_context, "FE-123") - - # Assertions - assert "issue" in result - assert result["issue"]["identifier"] == "FE-123" - assert result["issue"]["title"] == "Fix authentication bug" - mock_client.get_issue_by_id.assert_called_once_with("FE-123") - - @pytest.mark.asyncio - @patch("arcade_linear.tools.issues.LinearClient") - async def test_get_issue_not_found(self, mock_client_class, mock_context): - """Test issue retrieval when issue not found""" - # Setup mock - mock_client = AsyncMock() - mock_client_class.return_value = mock_client - mock_client.get_issue_by_id.return_value = None - - # Call function - result = await get_issue(mock_context, "NON-EXISTENT") - - # Assertions - assert "error" in result - assert "Issue not found" in result["error"] - - @pytest.mark.asyncio - @patch("arcade_linear.tools.issues.LinearClient") - async def test_get_issue_selective_includes(self, mock_client_class, mock_context): - """Test issue retrieval with selective includes""" - # Setup mock - mock_client = AsyncMock() - mock_client_class.return_value = mock_client - mock_client.get_issue_by_id.return_value = { - "id": "issue_1", - "identifier": "FE-123", - "title": "Fix authentication bug", - "comments": {"nodes": [{"id": "comment_1"}]}, - "attachments": {"nodes": [{"id": "attachment_1"}]}, - } - - # Call function without comments and attachments - result = await get_issue( - mock_context, - "FE-123", - include_comments=False, - include_attachments=False, - ) - - # Assertions - assert "comments" not in result["issue"] - assert "attachments" not in result["issue"] diff --git a/toolkits/linear/tests/test_teams.py b/toolkits/linear/tests/test_teams.py deleted file mode 100644 index b48f2f98..00000000 --- a/toolkits/linear/tests/test_teams.py +++ /dev/null @@ -1,145 +0,0 @@ -import pytest -from arcade_tdk import ToolContext - -from arcade_linear.tools.teams import get_teams - - -@pytest.mark.asyncio -async def test_get_teams_success( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response, - build_team_dict, - build_graphql_response, - build_paginated_response, -): - """Test successful team retrieval""" - # Create sample teams - team1 = build_team_dict(name="Team Alpha", key="ALPHA") - team2 = build_team_dict(name="Team Beta", key="BETA") - - # Build paginated response - teams_response = build_paginated_response([team1, team2]) - - # Build full GraphQL response - graphql_data = build_graphql_response({"teams": teams_response}) - - # Mock the HTTP response - http_response = mock_httpx_response(200, graphql_data) - mock_httpx_client.post.return_value = http_response - - result = await get_teams(context=mock_context) - - assert result["total_count"] == 2 - assert len(result["teams"]) == 2 - assert result["teams"][0]["name"] == "Team Alpha" - assert result["teams"][0]["key"] == "ALPHA" - assert result["teams"][1]["name"] == "Team Beta" - assert result["teams"][1]["key"] == "BETA" - - # Verify the request was made correctly - mock_httpx_client.post.assert_called_once() - call_args = mock_httpx_client.post.call_args - - # Check URL - assert call_args[0][0] == "https://api.linear.app/graphql" - - # Check that query is in the request body - request_body = call_args[1]["json"] - assert "query" in request_body - assert "teams" in request_body["query"] - - -@pytest.mark.asyncio -async def test_get_teams_with_team_filter( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response, - build_team_dict, - build_graphql_response, - build_paginated_response, -): - """Test team retrieval with name filter""" - # Create sample team - team = build_team_dict(name="Engineering Team", key="ENG") - - # Build responses - teams_response = build_paginated_response([team]) - graphql_data = build_graphql_response({"teams": teams_response}) - http_response = mock_httpx_response(200, graphql_data) - mock_httpx_client.post.return_value = http_response - - result = await get_teams(context=mock_context, team_name="Engineering") - - assert result["total_count"] == 1 - assert len(result["teams"]) == 1 - assert result["teams"][0]["name"] == "Engineering Team" - - # Verify filter was applied in the request - call_args = mock_httpx_client.post.call_args - request_body = call_args[1]["json"] - assert "variables" in request_body - assert "filter" in request_body["variables"] - - -@pytest.mark.asyncio -async def test_get_teams_empty_result( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response, - build_graphql_response, - build_paginated_response, -): - """Test handling of empty teams list""" - # Build empty response - teams_response = build_paginated_response([]) - graphql_data = build_graphql_response({"teams": teams_response}) - http_response = mock_httpx_response(200, graphql_data) - mock_httpx_client.post.return_value = http_response - - result = await get_teams(context=mock_context) - - assert result["total_count"] == 0 - assert len(result["teams"]) == 0 - - -@pytest.mark.asyncio -async def test_get_teams_graphql_error( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response, -): - """Test handling of GraphQL errors""" - # Build error response - error_data = { - "data": None, - "errors": [ - {"message": "Authentication required", "extensions": {"code": "UNAUTHENTICATED"}} - ], - } - http_response = mock_httpx_response(200, error_data) - mock_httpx_client.post.return_value = http_response - - # The tool should raise an exception for GraphQL errors - with pytest.raises(Exception) as exc_info: - await get_teams(context=mock_context) - - assert "Authentication required" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_get_teams_http_error( - mock_context: ToolContext, - mock_httpx_client, - mock_httpx_response, -): - """Test handling of HTTP errors""" - # Mock HTTP 401 error - error_response = mock_httpx_response(401, {"error": "Unauthorized"}) - mock_httpx_client.post.return_value = error_response - - with pytest.raises(Exception) as exc_info: - await get_teams(context=mock_context) - - # Should contain HTTP status information - assert "401" in str(exc_info.value) or "Unauthorized" in str(exc_info.value) diff --git a/toolkits/linear/tests/test_utils.py b/toolkits/linear/tests/test_utils.py deleted file mode 100644 index d5ad7476..00000000 --- a/toolkits/linear/tests/test_utils.py +++ /dev/null @@ -1,236 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_linear.models import DateRange -from arcade_linear.utils import ( - add_pagination_info, - clean_issue_data, - clean_team_data, - parse_date_range, - parse_date_string, - remove_none_values, - validate_date_format, -) - - -class TestDateParsing: - """Tests for date parsing utilities""" - - def test_parse_date_string_valid_iso(self): - """Test date parsing with valid ISO strings""" - result = parse_date_string("2024-01-01") - assert result is not None - assert result.year == 2024 - assert result.month == 1 - assert result.day == 1 - assert result.tzinfo is not None - - def test_parse_date_string_with_time(self): - """Test date parsing with date and time""" - result = parse_date_string("2024-01-01T12:30:00Z") - assert result is not None - assert result.year == 2024 - assert result.hour == 12 - assert result.minute == 30 - - def test_parse_date_string_relative(self): - """Test date parsing with relative strings""" - result = parse_date_string("today") - assert result is not None - - result = parse_date_string("yesterday") - assert result is not None - - def test_parse_date_string_time_mappings(self): - """Test date parsing with DateRange enum expressions""" - result = parse_date_string("this week") - assert result is not None - - result = parse_date_string("last month") - assert result is not None - - result = parse_date_string("yesterday") - assert result is not None - - result = parse_date_string("last 7 days") - assert result is not None - - def test_parse_date_string_invalid(self): - """Test date parsing with invalid strings""" - result = parse_date_string("invalid-date") - assert result is None - - result = parse_date_string("not-a-date") - assert result is None - - def test_parse_date_string_empty(self): - """Test date parsing with empty string""" - result = parse_date_string("") - assert result is None - - result = parse_date_string(None) - assert result is None - - def test_validate_date_format_valid(self): - """Test date validation with valid format""" - # Should not raise exception - validate_date_format("test_field", "2024-01-01") - validate_date_format("test_field", "today") - validate_date_format("test_field", None) - validate_date_format("test_field", "") - - def test_validate_date_format_invalid(self): - """Test date validation with invalid format""" - with pytest.raises(ToolExecutionError) as exc_info: - validate_date_format("test_field", "invalid-date") - - assert "Invalid date format for test_field" in str(exc_info.value) - - -class TestDateRange: - """Tests for DateRange enum""" - - def test_date_range_from_string_valid(self): - """Test DateRange.from_string with valid strings""" - assert DateRange.from_string("today") == DateRange.TODAY - assert DateRange.from_string("yesterday") == DateRange.YESTERDAY - assert DateRange.from_string("last week") == DateRange.LAST_WEEK - assert DateRange.from_string("this month") == DateRange.THIS_MONTH - assert DateRange.from_string("last month") == DateRange.LAST_MONTH - assert DateRange.from_string("last 7 days") == DateRange.LAST_7_DAYS - assert DateRange.from_string("last 30 days") == DateRange.LAST_30_DAYS - - def test_date_range_from_string_case_insensitive(self): - """Test DateRange.from_string is case insensitive""" - assert DateRange.from_string("TODAY") == DateRange.TODAY - assert DateRange.from_string("Last Week") == DateRange.LAST_WEEK - assert DateRange.from_string("THIS MONTH") == DateRange.THIS_MONTH - - def test_date_range_from_string_invalid(self): - """Test DateRange.from_string with invalid strings""" - assert DateRange.from_string("invalid") is None - assert DateRange.from_string("next week") is None - assert DateRange.from_string("") is None - - def test_date_range_to_datetime_range(self): - """Test DateRange.to_datetime_range returns proper datetime objects""" - today_range = DateRange.TODAY.to_datetime_range() - assert len(today_range) == 2 - start, end = today_range - assert start.tzinfo is not None - assert end.tzinfo is not None - assert start <= end - - def test_date_range_to_start_datetime(self): - """Test DateRange.to_start_datetime returns proper datetime""" - start = DateRange.LAST_WEEK.to_start_datetime() - assert start.tzinfo is not None - - def test_date_range_to_end_datetime(self): - """Test DateRange.to_end_datetime returns proper datetime""" - end = DateRange.LAST_MONTH.to_end_datetime() - assert end.tzinfo is not None - - -class TestParseDateRange: - """Tests for parse_date_range function""" - - def test_parse_date_range_valid(self): - """Test parse_date_range with valid DateRange strings""" - result = parse_date_range("today") - assert result is not None - assert len(result) == 2 - start, end = result - assert start.tzinfo is not None - assert end.tzinfo is not None - - def test_parse_date_range_invalid(self): - """Test parse_date_range with invalid strings""" - assert parse_date_range("invalid") is None - assert parse_date_range("") is None - assert parse_date_range("2024-01-01") is None # ISO date, not range - - def test_parse_date_range_relative_dates(self): - """Test parse_date_range with various relative date strings""" - ranges = ["yesterday", "last week", "this month", "last month", "last 7 days"] - for range_str in ranges: - result = parse_date_range(range_str) - assert result is not None, f"Failed for {range_str}" - start, end = result - assert start <= end, f"Invalid range for {range_str}" - - -class TestDataCleaning: - """Tests for data cleaning functions""" - - def test_remove_none_values(self): - """Test removing None values from dictionary""" - data = {"a": 1, "b": None, "c": "test", "d": None} - result = remove_none_values(data) - assert result == {"a": 1, "c": "test"} - - def test_clean_team_data(self): - """Test team data cleaning""" - team_data = { - "id": "team_1", - "key": "FE", - "name": "Frontend", - "description": "Frontend team", - "private": False, - "archivedAt": None, - "createdAt": "2024-01-01T00:00:00Z", - "members": {"nodes": [{"id": "user_1", "name": "John Doe"}]}, - } - - result = clean_team_data(team_data) - - assert result["id"] == "team_1" - assert result["key"] == "FE" - assert result["name"] == "Frontend" - assert len(result["members"]) == 1 - assert result["members"][0]["name"] == "John Doe" - - def test_clean_issue_data(self): - """Test issue data cleaning""" - issue_data = { - "id": "issue_1", - "identifier": "FE-123", - "title": "Test issue", - "description": "Issue description", - "priority": 2, - "priorityLabel": "High", - "createdAt": "2024-01-01T00:00:00Z", - "assignee": {"id": "user_1", "name": "John Doe"}, - "state": {"id": "state_1", "name": "In Progress"}, - "team": {"id": "team_1", "name": "Frontend"}, - "labels": {"nodes": [{"id": "label_1", "name": "bug"}]}, - "children": {"nodes": []}, - } - - result = clean_issue_data(issue_data) - - assert result["id"] == "issue_1" - assert result["identifier"] == "FE-123" - assert result["title"] == "Test issue" - assert result["assignee"]["name"] == "John Doe" - assert result["state"]["name"] == "In Progress" - assert result["team"]["name"] == "Frontend" - assert len(result["labels"]) == 1 - assert result["labels"][0]["name"] == "bug" - - def test_add_pagination_info(self): - """Test adding pagination information""" - response = {"data": "test"} - page_info = { - "hasNextPage": True, - "hasPreviousPage": False, - "startCursor": "start123", - "endCursor": "end456", - } - - result = add_pagination_info(response, page_info) - - assert result["pagination"]["has_next_page"] is True - assert result["pagination"]["has_previous_page"] is False - assert result["pagination"]["start_cursor"] == "start123" - assert result["pagination"]["end_cursor"] == "end456" diff --git a/toolkits/linkedin/.pre-commit-config.yaml b/toolkits/linkedin/.pre-commit-config.yaml deleted file mode 100644 index bc01e243..00000000 --- a/toolkits/linkedin/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/linkedin/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/linkedin/.ruff.toml b/toolkits/linkedin/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/linkedin/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/linkedin/LICENSE b/toolkits/linkedin/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/linkedin/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/linkedin/Makefile b/toolkits/linkedin/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/linkedin/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/linkedin/arcade_linkedin/__init__.py b/toolkits/linkedin/arcade_linkedin/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/linkedin/arcade_linkedin/tools/__init__.py b/toolkits/linkedin/arcade_linkedin/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/linkedin/arcade_linkedin/tools/constants.py b/toolkits/linkedin/arcade_linkedin/tools/constants.py deleted file mode 100644 index 187eeaed..00000000 --- a/toolkits/linkedin/arcade_linkedin/tools/constants.py +++ /dev/null @@ -1 +0,0 @@ -LINKEDIN_BASE_URL = "https://api.linkedin.com/v2" diff --git a/toolkits/linkedin/arcade_linkedin/tools/share.py b/toolkits/linkedin/arcade_linkedin/tools/share.py deleted file mode 100644 index 16549e0a..00000000 --- a/toolkits/linkedin/arcade_linkedin/tools/share.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import LinkedIn -from arcade_tdk.errors import ToolExecutionError - -from arcade_linkedin.tools.utils import _handle_linkedin_api_error, _send_linkedin_request - - -@tool( - requires_auth=LinkedIn( - scopes=["w_member_social"], - ) -) -async def create_text_post( - context: ToolContext, - text: Annotated[str, "The text content of the post"], -) -> Annotated[str, "URL of the shared post"]: - """Share a new text post to LinkedIn.""" - endpoint = "/ugcPosts" - - # The LinkedIn user ID is required to create a post, even though we're using - # the user's access token. - # Arcade Engine gets the current user's info from LinkedIn and automatically - # populates context.authorization.user_info. - # LinkedIn calls the user ID "sub" in their user_info data payload. See: - # https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details - user_id = context.authorization.user_info.get("sub") if context.authorization else None - - if not user_id: - raise ToolExecutionError( - "User ID not found.", - developer_message="User ID not found in `context.authorization.user_info.sub`", - ) - - author_id = f"urn:li:person:{user_id}" - payload = { - "author": author_id, - "lifecycleState": "PUBLISHED", - "specificContent": { - "com.linkedin.ugc.ShareContent": { - "shareCommentary": {"text": text}, - "shareMediaCategory": "NONE", - } - }, - "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}, - } - - response = await _send_linkedin_request(context, "POST", endpoint, json_data=payload) - - if response.status_code >= 200 and response.status_code < 300: - share_id = response.json().get("id") - return f"https://www.linkedin.com/feed/update/{share_id}/" - - _handle_linkedin_api_error(response) - - return "" diff --git a/toolkits/linkedin/arcade_linkedin/tools/utils.py b/toolkits/linkedin/arcade_linkedin/tools/utils.py deleted file mode 100644 index 782b8409..00000000 --- a/toolkits/linkedin/arcade_linkedin/tools/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -import httpx -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError - -from arcade_linkedin.tools.constants import LINKEDIN_BASE_URL - - -async def _send_linkedin_request( - context: ToolContext, - method: str, - endpoint: str, - params: dict | None = None, - json_data: dict | None = None, -) -> httpx.Response: - """ - Send an asynchronous request to the LinkedIn API. - - Args: - context: The tool context containing the authorization token. - method: The HTTP method (GET, POST, PUT, DELETE, etc.). - endpoint: The API endpoint path (e.g., "/ugcPosts"). - params: Query parameters to include in the request. - json_data: JSON data to include in the request body. - - Returns: - The response object from the API request. - - Raises: - ToolExecutionError: If the request fails for any reason. - """ - url = f"{LINKEDIN_BASE_URL}{endpoint}" - token = ( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - headers = {"Authorization": f"Bearer {token}"} - - async with httpx.AsyncClient() as client: - try: - response = await client.request( - method, url, headers=headers, params=params, json=json_data - ) - response.raise_for_status() - except httpx.RequestError as e: - raise ToolExecutionError(f"Failed to send request to LinkedIn API: {e}") - - return response - - -def _handle_linkedin_api_error(response: httpx.Response) -> None: - """ - Handle errors from the LinkedIn API by mapping common status codes to ToolExecutionErrors. - - Args: - response: The response object from the API request. - - Raises: - ToolExecutionError: If the response contains an error status code. - """ - status_code_map = { - 401: ToolExecutionError("Unauthorized: Invalid or expired token"), - 403: ToolExecutionError("Forbidden: User does not have Spotify Premium"), - 429: ToolExecutionError("Too Many Requests: Rate limit exceeded"), - } - - if response.status_code in status_code_map: - raise status_code_map[response.status_code] - elif response.status_code >= 400: - raise ToolExecutionError(f"Error: {response.status_code} - {response.text}") diff --git a/toolkits/linkedin/conftest.py b/toolkits/linkedin/conftest.py deleted file mode 100644 index 7cba84e5..00000000 --- a/toolkits/linkedin/conftest.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def tool_context(): - """Fixture for the ToolContext with mock authorization.""" - return ToolContext( - authorization=ToolAuthorizationContext(token="test_token", user_info={"sub": "test_user"}), # noqa: S106 - user_id="test_user", - ) - - -@pytest.fixture -def mock_httpx_client(mocker): - """Fixture to mock the httpx.AsyncClient.""" - # Mock the AsyncClient context manager - mock_client = mocker.patch("httpx.AsyncClient", autospec=True) - async_mock_client = mock_client.return_value.__aenter__.return_value - return async_mock_client diff --git a/toolkits/linkedin/evals/eval_linkedin.py b/toolkits/linkedin/evals/eval_linkedin.py deleted file mode 100644 index 5a4c005f..00000000 --- a/toolkits/linkedin/evals/eval_linkedin.py +++ /dev/null @@ -1,48 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_linkedin -from arcade_linkedin.tools.share import create_text_post - -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_linkedin) - - -@tool_eval() -def linkedin_eval_suite(): - suite = EvalSuite( - name="LinkedIn Tools Evaluation", - system_message="You are an AI assistant with access to LinkedIn tools. Use them to help the user with their tasks.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Run code", - user_message="post this transcription to linkedin. there may be some things that you need to clean up since it was spoken.: 'It is with great pleasure that I announce that I am now a member of the LinkedIn community! I'd like to thank the LinkedIn team for their support and encouragement in my journey to success. hash tag Y2K'", - expected_tool_calls=[ - ExpectedToolCall( - func=create_text_post, - args={ - "text": "It is with great pleasure that I announce that I am now a member of the LinkedIn community! I'd like to thank the LinkedIn team for their support and encouragement in my journey to success. #Y2K", - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="text", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/linkedin/pyproject.toml b/toolkits/linkedin/pyproject.toml deleted file mode 100644 index 0d6b9e54..00000000 --- a/toolkits/linkedin/pyproject.toml +++ /dev/null @@ -1,57 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_linkedin" -version = "0.1.13" -description = "Arcade.dev LLM tools for LinkedIn" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_linkedin/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_linkedin",] diff --git a/toolkits/linkedin/tests/__init__.py b/toolkits/linkedin/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/linkedin/tests/test_share.py b/toolkits/linkedin/tests/test_share.py deleted file mode 100644 index 9aa39882..00000000 --- a/toolkits/linkedin/tests/test_share.py +++ /dev/null @@ -1,35 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_linkedin.tools.share import create_text_post - - -@pytest.mark.asyncio -async def test_create_text_post_success(tool_context, mock_httpx_client): - """Test successful creation of a LinkedIn text post.""" - # Mock response for a successful post creation - mock_response = MagicMock() - mock_response.status_code = 201 - mock_response.json.return_value = {"id": "1234567890"} - # Ensure the mock is awaited properly - mock_httpx_client.request = AsyncMock(return_value=mock_response) - - post_text = "Hello, LinkedIn!" - result = await create_text_post(tool_context, post_text) - - expected_url = "https://www.linkedin.com/feed/update/1234567890/" - assert result == expected_url - mock_httpx_client.request.assert_called_once() - - -@pytest.mark.asyncio -async def test_create_text_post_no_user_id(tool_context): - """Test error when user ID is not found in the context.""" - # Simulate missing user ID in the context - tool_context.authorization.user_info = {} - - post_text = "Hello, LinkedIn!" - with pytest.raises(ToolExecutionError, match="User ID not found"): - await create_text_post(tool_context, post_text) diff --git a/toolkits/math/.pre-commit-config.yaml b/toolkits/math/.pre-commit-config.yaml deleted file mode 100644 index 3e1fd287..00000000 --- a/toolkits/math/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/math/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/math/.ruff.toml b/toolkits/math/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/math/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/math/LICENSE b/toolkits/math/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/math/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/math/Makefile b/toolkits/math/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/math/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/math/arcade_math/__init__.py b/toolkits/math/arcade_math/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/math/arcade_math/tools/__init__.py b/toolkits/math/arcade_math/tools/__init__.py deleted file mode 100644 index 4b3d12f8..00000000 --- a/toolkits/math/arcade_math/tools/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -from arcade_math.tools.arithmetic import ( - add, - divide, - mod, - multiply, - subtract, - sum_list, - sum_range, -) -from arcade_math.tools.exponents import ( - log, - power, -) -from arcade_math.tools.miscellaneous import ( - abs_val, - factorial, - sqrt, -) -from arcade_math.tools.random import ( - generate_random_float, - generate_random_int, -) -from arcade_math.tools.rational import ( - gcd, - lcm, -) -from arcade_math.tools.rounding import ( - ceil, - floor, - round_num, -) -from arcade_math.tools.statistics import ( - avg, - median, -) -from arcade_math.tools.trigonometry import ( - deg_to_rad, - rad_to_deg, -) - -__all__ = [ - "add", - "subtract", - "multiply", - "divide", - "sum_list", - "sum_range", - "mod", - "log", - "power", - "abs_val", - "factorial", - "sqrt", - "generate_random_float", - "generate_random_int", - "gcd", - "lcm", - "ceil", - "floor", - "round_num", - "avg", - "median", - "deg_to_rad", - "rad_to_deg", -] diff --git a/toolkits/math/arcade_math/tools/arithmetic.py b/toolkits/math/arcade_math/tools/arithmetic.py deleted file mode 100644 index 251c09ff..00000000 --- a/toolkits/math/arcade_math/tools/arithmetic.py +++ /dev/null @@ -1,97 +0,0 @@ -import decimal -from decimal import Decimal -from typing import Annotated - -from arcade_tdk import tool - -decimal.getcontext().prec = 100 - - -@tool -def add( - a: Annotated[str, "The first number as a string"], - b: Annotated[str, "The second number as a string"], -) -> Annotated[str, "The sum of the two numbers as a string"]: - """ - Add two numbers together - """ - # Use Decimal for arbitrary precision - a_decimal = Decimal(a) - b_decimal = Decimal(b) - return str(a_decimal + b_decimal) - - -@tool -def subtract( - a: Annotated[str, "The first number as a string"], - b: Annotated[str, "The second number as a string"], -) -> Annotated[str, "The difference of the two numbers as a string"]: - """ - Subtract two numbers - """ - # Use Decimal for arbitrary precision - a_decimal = Decimal(a) - b_decimal = Decimal(b) - return str(a_decimal - b_decimal) - - -@tool -def multiply( - a: Annotated[str, "The first number as a string"], - b: Annotated[str, "The second number as a string"], -) -> Annotated[str, "The product of the two numbers as a string"]: - """ - Multiply two numbers together - """ - # Use Decimal for arbitrary precision - a_decimal = Decimal(a) - b_decimal = Decimal(b) - return str(a_decimal * b_decimal) - - -@tool -def divide( - a: Annotated[str, "The first number as a string"], - b: Annotated[str, "The second number as a string"], -) -> Annotated[str, "The quotient of the two numbers as a string"]: - """ - Divide two numbers - """ - # Use Decimal for arbitrary precision - a_decimal = Decimal(a) - b_decimal = Decimal(b) - return str(a_decimal / b_decimal) - - -@tool -def sum_list( - numbers: Annotated[list[str], "The list of numbers as strings"], -) -> Annotated[str, "The sum of the numbers in the list as a string"]: - """ - Sum all numbers in a list - """ - # Use Decimal for arbitrary precision - return str(sum([Decimal(n) for n in numbers])) - - -@tool -def sum_range( - start: Annotated[str, "The start of the range to sum as a string"], - end: Annotated[str, "The end of the range to sum as a string"], -) -> Annotated[str, "The sum of the numbers in the list as a string"]: - """ - Sum all numbers from start through end - """ - return str(sum(list(range(int(start), int(end) + 1)))) - - -@tool -def mod( - a: Annotated[str, "The dividend as a string"], - b: Annotated[str, "The divisor as a string"], -) -> Annotated[str, "The remainder after dividing a by b as a string"]: - """ - Calculate the remainder (modulus) of one number divided by another - """ - # Use Decimal for arbitrary precision - return str(Decimal(a) % Decimal(b)) diff --git a/toolkits/math/arcade_math/tools/exponents.py b/toolkits/math/arcade_math/tools/exponents.py deleted file mode 100644 index 14fbd55e..00000000 --- a/toolkits/math/arcade_math/tools/exponents.py +++ /dev/null @@ -1,32 +0,0 @@ -import decimal -import math -from decimal import Decimal -from typing import Annotated - -from arcade_tdk import tool - -decimal.getcontext().prec = 100 - - -@tool -def log( - a: Annotated[str, "The number to take the logarithm of as a string"], - base: Annotated[str, "The logarithmic base as a string"], -) -> Annotated[str, "The logarithm of the number with the specified base as a string"]: - """ - Calculate the logarithm of a number with a given base - """ - # Use Decimal for arbitrary precision - return str(math.log(Decimal(a), Decimal(base))) - - -@tool -def power( - a: Annotated[str, "The base number as a string"], - b: Annotated[str, "The exponent as a string"], -) -> Annotated[str, "The result of raising a to the power of b as a string"]: - """ - Calculate one number raised to the power of another - """ - # Use Decimal for arbitrary precision - return str(Decimal(a) ** Decimal(b)) diff --git a/toolkits/math/arcade_math/tools/miscellaneous.py b/toolkits/math/arcade_math/tools/miscellaneous.py deleted file mode 100644 index db52e92c..00000000 --- a/toolkits/math/arcade_math/tools/miscellaneous.py +++ /dev/null @@ -1,42 +0,0 @@ -import decimal -import math -from decimal import Decimal -from typing import Annotated - -from arcade_tdk import tool - -decimal.getcontext().prec = 100 - - -@tool -def abs_val( - a: Annotated[str, "The number as a string"], -) -> Annotated[str, "The absolute value of the number as a string"]: - """ - Calculate the absolute value of a number - """ - # Use Decimal for arbitrary precision - return str(abs(Decimal(a))) - - -@tool -def factorial( - a: Annotated[str, "The non-negative integer to compute the factorial for as a string"], -) -> Annotated[str, "The factorial of the number as a string"]: - """ - Compute the factorial of a non-negative integer - Returns "1" for "0" - """ - return str(math.factorial(int(a))) - - -@tool -def sqrt( - a: Annotated[str, "The number to square root as a string"], -) -> Annotated[str, "The square root of the number as a string"]: - """ - Get the square root of a number - """ - # Use Decimal for arbitrary precision - a_decimal = Decimal(a) - return str(a_decimal.sqrt()) diff --git a/toolkits/math/arcade_math/tools/random.py b/toolkits/math/arcade_math/tools/random.py deleted file mode 100644 index 7b50d345..00000000 --- a/toolkits/math/arcade_math/tools/random.py +++ /dev/null @@ -1,38 +0,0 @@ -import random -from typing import Annotated - -from arcade_tdk import tool - - -@tool -def generate_random_int( - min_value: Annotated[str, "The minimum value of the random integer as a string"], - max_value: Annotated[str, "The maximum value of the random integer as a string"], - seed: Annotated[ - str | None, - "The seed for the random number generator as a string." - " If None, the current system time is used.", - ] = None, -) -> Annotated[str, "A random integer between min_value and max_value as a string"]: - """Generate a random integer between min_value and max_value (inclusive).""" - if seed is not None: - random.seed(int(seed)) - - return str(random.randint(int(min_value), int(max_value))) # noqa: S311 - - -@tool -def generate_random_float( - min_value: Annotated[str, "The minimum value of the random float as a string"], - max_value: Annotated[str, "The maximum value of the random float as a string"], - seed: Annotated[ - str | None, - "The seed for the random number generator as a string." - " If None, the current system time is used.", - ] = None, -) -> Annotated[str, "A random float between min_value and max_value as a string"]: - """Generate a random float between min_value and max_value.""" - if seed is not None: - random.seed(int(seed)) - - return str(random.uniform(float(min_value), float(max_value))) # noqa: S311 diff --git a/toolkits/math/arcade_math/tools/rational.py b/toolkits/math/arcade_math/tools/rational.py deleted file mode 100644 index fa314919..00000000 --- a/toolkits/math/arcade_math/tools/rational.py +++ /dev/null @@ -1,30 +0,0 @@ -import math -from typing import Annotated - -from arcade_tdk import tool - - -@tool -def gcd( - a: Annotated[str, "First integer as a string"], - b: Annotated[str, "Second integer as a string"], -) -> Annotated[str, "The greatest common divisor of a and b as a string"]: - """ - Calculate the greatest common divisor (GCD) of two integers. - """ - return str(math.gcd(int(a), int(b))) - - -@tool -def lcm( - a: Annotated[str, "First integer as a string"], - b: Annotated[str, "Second integer as a string"], -) -> Annotated[str, "The least common multiple of a and b as a string"]: - """ - Calculate the least common multiple (LCM) of two integers. - Returns "0" if either integer is 0. - """ - a_int, b_int = int(a), int(b) - if a_int == 0 or b_int == 0: - return "0" - return str(abs(a_int * b_int) // math.gcd(a_int, b_int)) diff --git a/toolkits/math/arcade_math/tools/rounding.py b/toolkits/math/arcade_math/tools/rounding.py deleted file mode 100644 index 9d9e7ea0..00000000 --- a/toolkits/math/arcade_math/tools/rounding.py +++ /dev/null @@ -1,47 +0,0 @@ -import decimal -import math -from decimal import Decimal -from typing import Annotated - -from arcade_tdk import tool - -decimal.getcontext().prec = 100 - - -@tool -def ceil( - a: Annotated[str, "The number to round up as a string"], -) -> Annotated[str, "The smallest integer greater than or equal to the number as a string"]: - """ - Return the ceiling of a number - """ - # Use Decimal for arbitrary precision - return str(math.ceil(Decimal(a))) - - -@tool -def floor( - a: Annotated[str, "The number to round down as a string"], -) -> Annotated[str, "The largest integer less than or equal to the number as a string"]: - """ - Return the floor of a number - """ - # Use Decimal for arbitrary precision - return str(math.floor(Decimal(a))) - - -@tool -def round_num( - value: Annotated[str, "The number to round as a string"], - ndigits: Annotated[str, "The number of digits after the decimal point as a string"], -) -> Annotated[str, "The number rounded to the specified number of digits as a string"]: - """ - Round a number to a specified number of positive digits - """ - ndigits_int = int(ndigits) - if ndigits_int >= 0: - # Use Decimal for arbitrary precision - return str(round(Decimal(value), int(ndigits_int))) - # cast value from str -> float -> int here because rounding with negative - # decimals is only useful for weird math - return str(round(int(float(value)), int(ndigits_int))) diff --git a/toolkits/math/arcade_math/tools/statistics.py b/toolkits/math/arcade_math/tools/statistics.py deleted file mode 100644 index 2001d6e0..00000000 --- a/toolkits/math/arcade_math/tools/statistics.py +++ /dev/null @@ -1,34 +0,0 @@ -import decimal -from decimal import Decimal -from statistics import median as stats_median -from typing import Annotated - -from arcade_tdk import tool - -decimal.getcontext().prec = 100 - - -@tool -def avg( - numbers: Annotated[list[str], "The list of numbers as strings"], -) -> Annotated[str, "The average (mean) of the numbers in the list as a string"]: - """ - Calculate the average (mean) of a list of numbers. - Returns "0.0" if the list is empty. - """ - # Use Decimal for arbitrary precision - d_numbers = [Decimal(n) for n in numbers] - return str(sum(d_numbers) / len(d_numbers)) if d_numbers else "0.0" - - -@tool -def median( - numbers: Annotated[list[str], "A list of numbers as strings"], -) -> Annotated[str, "The median value of the numbers in the list as a string"]: - """ - Calculate the median of a list of numbers. - Returns "0.0" if the list is empty. - """ - # Use Decimal for arbitrary precision - d_numbers = [Decimal(n) for n in numbers] - return str(stats_median(d_numbers)) if d_numbers else "0.0" diff --git a/toolkits/math/arcade_math/tools/trigonometry.py b/toolkits/math/arcade_math/tools/trigonometry.py deleted file mode 100644 index db982439..00000000 --- a/toolkits/math/arcade_math/tools/trigonometry.py +++ /dev/null @@ -1,30 +0,0 @@ -import decimal -import math -from decimal import Decimal -from typing import Annotated - -from arcade_tdk import tool - -decimal.getcontext().prec = 100 - - -@tool -def deg_to_rad( - degrees: Annotated[str, "Angle in degrees as a string"], -) -> Annotated[str, "Angle in radians as a string"]: - """ - Convert an angle from degrees to radians. - """ - # Use Decimal for arbitrary precision - return str(math.radians(Decimal(degrees))) - - -@tool -def rad_to_deg( - radians: Annotated[str, "Angle in radians as a string"], -) -> Annotated[str, "Angle in degrees as a string"]: - """ - Convert an angle from radians to degrees. - """ - # Use Decimal for arbitrary precision - return str(math.degrees(Decimal(radians))) diff --git a/toolkits/math/evals/eval_math_tools.py b/toolkits/math/evals/eval_math_tools.py deleted file mode 100644 index f1646b0a..00000000 --- a/toolkits/math/evals/eval_math_tools.py +++ /dev/null @@ -1,131 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_math -from arcade_math.tools.arithmetic import ( - add, - divide, - mod, - multiply, - subtract, - sum_list, - sum_range, -) -from arcade_math.tools.exponents import ( - log, - power, -) -from arcade_math.tools.miscellaneous import ( - abs_val, - factorial, - sqrt, -) -from arcade_math.tools.rational import ( - gcd, - lcm, -) -from arcade_math.tools.rounding import ( - ceil, - floor, - round_num, -) -from arcade_math.tools.statistics import ( - avg, - median, -) -from arcade_math.tools.trigonometry import ( - deg_to_rad, - rad_to_deg, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_math) - - -@tool_eval() -def math_eval_suite(): - suite = EvalSuite( - name="Math Tools Evaluation", - system_message="You're an AI assistant with access to math tools. Use them to help the user with their math-related tasks.", - catalog=catalog, - rubric=rubric, - ) - - list_param = ["1", "2", "3", "4", "5"] - funcs_to_expression_and_params = [ - # unary - (sqrt, "What's the square root of {a}?", {"a": "25"}), - (abs_val, "What's the absolute value of {a}?", {"a": "-10"}), - (factorial, "What's the factorial of {a}?", {"a": "5"}), - (deg_to_rad, "Convert {degrees} from degrees to radians", {"degrees": "180"}), - (rad_to_deg, "Convert {radians} from radias to degrees", {"radians": "3.14"}), - (ceil, "Compute the ceiling of {a}", {"a": "3.14"}), - (floor, "Compute the floor of {a}", {"a": "3.14"}), - # binary - (add, "Add {a} and {b}", {"a": "12345", "b": "987654321"}), - (subtract, "Subtract {b} from {a}", {"a": "987654321", "b": "12345"}), - (multiply, "Multiply {a} and {b}", {"a": "12345", "b": "567890"}), - (divide, "What is {a} divided by {b}?", {"a": "1234123479", "b": "123"}), - ( - sum_range, - "What's the sum of all numbers from {start} to {end}?", - {"start": "10", "end": "345"}, - ), - (mod, "What's the remainder of dividing {a} by {b}?", {"a": "234", "b": "17"}), - (power, "Raise {a} to the power of {b}", {"a": "2", "b": "8"}), - (log, "What's the logarithm of {a} with base {base}?", {"a": "8", "base": "2"}), - ( - round_num, - "Round {value} to {ndigits} decimal places", - {"value": "12.23746234", "ndigits": "3"}, - ), - (gcd, "Find the greatest common divisor of {a} and {b}", {"a": "50", "b": "10"}), - (lcm, "FInd the least common multiple of {a} and {b}", {"a": "7", "b": "13"}), - # n-nary - ( - sum_list, - f"Calculate the sum of these numbers: {' '.join(list_param)}", - {"numbers": list_param}, - ), - ( - avg, - f"Find the average of these numbers: {' '.join(list_param)}", - {"numbers": list_param}, - ), - ( - median, - f"Find the median of these numbers: {' '.join(list_param)}", - {"numbers": list_param}, - ), - ] - - for func, expression, params in funcs_to_expression_and_params: - parametrized_expression = expression.format(**params) - num_params = len(params) - suite.add_case( - name=parametrized_expression, - user_message=parametrized_expression, - expected_tool_calls=[ - ExpectedToolCall( - func=func, - args=params, - ) - ], - rubric=rubric, - critics=[BinaryCritic(critic_field=param, weight=1.0 / num_params) for param in params], - ) - - return suite diff --git a/toolkits/math/pyproject.toml b/toolkits/math/pyproject.toml deleted file mode 100644 index e12385b0..00000000 --- a/toolkits/math/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_math" -version = "1.0.4" -description = "Arcade.dev LLM tools for doing math" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_math/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_math",] diff --git a/toolkits/math/tests/__init__.py b/toolkits/math/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/math/tests/test_arithmetic.py b/toolkits/math/tests/test_arithmetic.py deleted file mode 100644 index 845110ea..00000000 --- a/toolkits/math/tests/test_arithmetic.py +++ /dev/null @@ -1,147 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_math.tools.arithmetic import ( - add, - divide, - mod, - multiply, - subtract, - sum_list, - sum_range, -) - - -@pytest.mark.parametrize( - "a, b, expected", - [ - ("1", "2", "3"), - ("-1", "1", "0"), - ("0.5", "10.9", "11.4"), - # Big ints - ("12345678901234567890", "9876543210987654321", "22222222112222222211"), - # Big floats - ( - "12345678901234567890.120", - "9876543210987654321.987", - "22222222112222222212.107", - ), - ], -) -def test_add(a, b, expected): - assert add(a, b) == expected - - -@pytest.mark.parametrize( - "a, b, expected", - [ - ("1", "2", "-1"), - ("-1", "1", "-2"), - ("0.5", "10.9", "-10.4"), - # Big ints - ("12345678901234567890", "12323456679012345668", "22222222222222222"), - # Big floats - ( - "12345678901234567890.120", - "12343557689113355768.9079", - "2121212121212121.2121", - ), - ], -) -def test_subtract(a, b, expected): - assert subtract(a, b) == expected - - -@pytest.mark.parametrize( - "a, b, expected", - [ - ("-1", "2", "-2"), - ("-10", "0", "-0"), - ("0.5", "10.9", "5.45"), - # Big ints - ( - "12345678901234567890", - "18000000162000001474380013420000", - "222222222222222222222222222261233060226101083800000", - ), - # Big floats - ( - "12345678901234567890.120", - "12345678901234567890.120", - "152415787532388367504868162811315348393.614400", - ), - ], -) -def test_multiply(a, b, expected): - assert multiply(a, b) == expected - - -@pytest.mark.parametrize( - "a, b, expected", - [ - ("-1", "2", "-0.5"), - ("-10", "1", "-10"), - ( - "0.5", - "10.9", - "0.0458715596330275229357798165137614678899082568807339" - "4495412844036697247706422018348623853211009174312", - ), - # Big ints - ("152407406035740740602050", "12345678901234567890", "12345"), - # Big floats - ( - "152407406035740740603531.400", - "12345678901234567890.120", - "12345", - ), - ], -) -def test_divide(a, b, expected): - assert divide(a, b) == expected - - -def text_zero_division(): - with pytest.raises(ToolExecutionError): - divide("1", "0") - with pytest.raises(ToolExecutionError): - divide("1", "0.0") - with pytest.raises(ToolExecutionError): - divide("1", "0.000000") - - -def test_sum_list(): - assert sum_list(["1", "2", "3", "4", "5", "6"]) == "21" - assert sum_list([]) == "0" - assert sum_list(["-1", "-2", "-3", "-4", "-5", "-6"]) == "-21" - assert sum_list(["0.1", "0.2", "0.3", "0.3", "0.5", "0.7"]) == "2.1" - - -def test_sum_range(): - assert sum_range("8", "2") == "0" - assert sum_range("-8", "2") == "-33" - assert sum_range("8", "-2") == "0" - assert sum_range("2", "3") == "5" - assert sum_range("0", "10") == "55" - with pytest.raises(ToolExecutionError): - sum_range("2", "0.5") - with pytest.raises(ToolExecutionError): - sum_range("-1", "0.5") - with pytest.raises(ToolExecutionError): - sum_range("2.", "0.5") - with pytest.raises(ToolExecutionError): - sum_range("-1", "0.5") - - -def test_mod(): - assert mod("-1", "0.5") == "-0.0" - assert mod("-8", "2") == "-0" - assert mod("0", "10") == "0" - assert mod("2", "0.5") == "0.0" - assert mod("2", "3") == "2" - assert mod("2.", "-0.5") == "0.0" - assert mod("2.1234", "0.6") == "0.3234" - assert mod("2.1234", "1") == "0.1234" - assert mod("2.1234", "3") == "2.1234" - assert mod("8", "-2") == "0" - assert mod("8", "2") == "0" diff --git a/toolkits/math/tests/test_exponents.py b/toolkits/math/tests/test_exponents.py deleted file mode 100644 index be2d6f9e..00000000 --- a/toolkits/math/tests/test_exponents.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_math.tools.exponents import ( - log, - power, -) - - -def test_log(): - assert log("8", "2") == "3.0" - assert log("2", "3") == "0.6309297535714574" - assert log("2", "0.5") == "-1.0" - with pytest.raises(ToolExecutionError): - log("-1", "0.5") - with pytest.raises(ToolExecutionError): - log("0", "10") - - -def test_power(): - assert power("-8", "2") == "64" - assert power("0", "10") == "0" - assert ( - power("2", "0.5") == "1.41421356237309504880168872420969807856" - "9671875376948073176679737990732478462107038850387534327641573" - ) - assert power("2", "3") == "8" - assert ( - power("2.", "-0.5") == "0.707106781186547524400844362104849039" - "2848359376884740365883398689953662392310535194251937671638207864" - ) - assert ( - power("2.1234", "0.6") == "1.571155202490495156807227174573016145" - "282682479346448636509576776014844055570115193494685328114403375" - ) - assert power("2.1234", "1") == "2.1234" - assert power("2.1234", "3") == "9.574044440904" - assert power("8", "-2") == "0.015625" - assert power("8", "2") == "64" - with pytest.raises(ToolExecutionError): - power("-1", "0.5") diff --git a/toolkits/math/tests/test_miscellaneous.py b/toolkits/math/tests/test_miscellaneous.py deleted file mode 100644 index 16d7877b..00000000 --- a/toolkits/math/tests/test_miscellaneous.py +++ /dev/null @@ -1,81 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_math.tools.miscellaneous import ( - abs_val, - factorial, - sqrt, -) - - -def test_abs_val(): - assert abs_val("2") == "2" - assert abs_val("-1") == "1" - assert abs_val("-1.12341234") == "1.12341234" - - -def test_factorial(): - assert factorial("1") == "1" - assert factorial("0") == "1" - assert factorial("-0") == "1" - assert factorial("23") == "25852016738884976640000" - assert factorial("24") == "620448401733239439360000" - assert factorial("10") == "3628800" - with pytest.raises(ToolExecutionError): - factorial("-1") - with pytest.raises(ToolExecutionError): - factorial("-10") - with pytest.raises(ToolExecutionError): - factorial("0.0000") - with pytest.raises(ToolExecutionError): - factorial("-0.0") - with pytest.raises(ToolExecutionError): - factorial("1.0") - with pytest.raises(ToolExecutionError): - factorial("-1.0") - with pytest.raises(ToolExecutionError): - factorial("23.0") - - -def test_sqrt(): - assert sqrt("1") == "1" - assert sqrt("0") == "0" - assert sqrt("-0") == "-0" - assert ( - sqrt("23") == "4.79583152331271954159743806416269391999670704190" - "4129346485309114448257235907464082492191446436918861" - ) - assert ( - sqrt("24") == "4.89897948556635619639456814941178278393189496131" - "3340256865385134501920754914630053079718866209280470" - ) - assert ( - sqrt("10") == "3.16227766016837933199889354443271853371955513932" - "5216826857504852792594438639238221344248108379300295" - ) - assert sqrt("0.0") == "0.0" - assert sqrt("0.0000") == "0.00" - assert sqrt("-0.0") == "-0.0" - assert sqrt("1.0") == "1.0" - assert ( - sqrt("3.14") == "1.772004514666935040199112509753631525073608516" - "162942966817771970290992972348902551472561151153909188" - ) - assert ( - sqrt("0.4") == "0.6324555320336758663997787088865437067439110278" - "650433653715009705585188877278476442688496216758600590" - ) - assert ( - sqrt("10.0") == "3.162277660168379331998893544432718533719555139" - "325216826857504852792594438639238221344248108379300295" - ) - with pytest.raises(ToolExecutionError): - sqrt("-1") - with pytest.raises(ToolExecutionError): - sqrt("-10") - with pytest.raises(ToolExecutionError): - sqrt("-1.0") - with pytest.raises(ToolExecutionError): - sqrt("-1.3") - with pytest.raises(ToolExecutionError): - sqrt("-10.0") diff --git a/toolkits/math/tests/test_rational.py b/toolkits/math/tests/test_rational.py deleted file mode 100644 index c35bef5c..00000000 --- a/toolkits/math/tests/test_rational.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_math.tools.rational import ( - gcd, - lcm, -) - - -def test_gcd(): - assert gcd("-15", "-5") == "5" - assert gcd("15", "0") == "15" - assert gcd("15", "-2") == "1" - assert gcd("15", "-0") == "15" - assert gcd("15", "5") == "5" - assert gcd("7", "13") == "1" - assert gcd("-13", "13") == "13" - with pytest.raises(ToolExecutionError): - gcd("15.0", "5.0") - - -def test_lcm(): - assert lcm("-15", "-5") == "15" - assert lcm("15", "0") == "0" - assert lcm("15", "-2") == "30" - assert lcm("15", "-0") == "0" - assert lcm("15", "5") == "15" - assert lcm("7", "13") == "91" - assert lcm("-13", "13") == "13" - with pytest.raises(ToolExecutionError): - lcm("15.0", "5.0") diff --git a/toolkits/math/tests/test_rounding.py b/toolkits/math/tests/test_rounding.py deleted file mode 100644 index 22e252a5..00000000 --- a/toolkits/math/tests/test_rounding.py +++ /dev/null @@ -1,54 +0,0 @@ -from arcade_math.tools.rounding import ( - ceil, - floor, - round_num, -) - - -def test_ceil(): - assert ceil("1") == "1" - assert ceil("-1") == "-1" - assert ceil("0") == "0" - assert ceil("-0") == "0" - assert ceil("0.0") == "0" - assert ceil("0.0000") == "0" - assert ceil("-0.0") == "0" - assert ceil("1.0") == "1" - assert ceil("-1.0") == "-1" - assert ceil("3.14") == "4" - assert ceil("0.4") == "1" - assert ceil("-1.3") == "-1" - - -def test_floor(): - assert floor("1") == "1" - assert floor("-1") == "-1" - assert floor("0") == "0" - assert floor("-0") == "0" - assert floor("10") == "10" - assert floor("0.0") == "0" - assert floor("0.0000") == "0" - assert floor("-0.0") == "0" - assert floor("1.0") == "1" - assert floor("-1.0") == "-1" - assert floor("3.14") == "3" - assert floor("0.4") == "0" - assert floor("-1.3") == "-2" - - -def test_round_num(): - # TODO(mateo): ok with scientific notatin? ok with negative round digits? - assert round_num("1.2345", "-2") == "0" - assert round_num("1.2345", "-1") == "0" - assert round_num("1.2345", "0") == "1" - assert round_num("1.2345", "1") == "1.2" - assert round_num("1.2345", "2") == "1.23" - assert round_num("1.2345", "3") == "1.234" - assert round_num("1.2345", "8") == "1.23450000" - assert round_num("1.654321", "-2") == "0" - assert round_num("1.654321", "-1") == "0" - assert round_num("1.654321", "0") == "2" - assert round_num("1.654321", "1") == "1.7" - assert round_num("1.654321", "2") == "1.65" - assert round_num("1.654321", "3") == "1.654" - assert round_num("1.654321", "8") == "1.65432100" diff --git a/toolkits/math/tests/test_statistics.py b/toolkits/math/tests/test_statistics.py deleted file mode 100644 index 90a542af..00000000 --- a/toolkits/math/tests/test_statistics.py +++ /dev/null @@ -1,18 +0,0 @@ -from arcade_math.tools.statistics import ( - avg, - median, -) - - -def test_avg(): - assert avg(["1", "2", "3", "4", "5", "6"]) == "3.5" - assert avg([]) == "0.0" - assert avg(["-1", "-2", "-3", "-4", "-5", "-6"]) == "-3.5" - assert avg(["0.1", "0.2", "0.3", "0.3", "0.5", "0.7"]) == "0.35" - - -def test_median(): - assert median(["1", "2", "3", "4", "5", "6"]) == "3.5" - assert median([]) == "0.0" - assert median(["-1", "-2", "-3", "-4", "-5", "-6"]) == "-3.5" - assert median(["0.1", "0.2", "0.3", "0.3", "0.5", "0.7"]) == "0.3" diff --git a/toolkits/math/tests/test_trigonometry.py b/toolkits/math/tests/test_trigonometry.py deleted file mode 100644 index 98e1f8fe..00000000 --- a/toolkits/math/tests/test_trigonometry.py +++ /dev/null @@ -1,45 +0,0 @@ -from arcade_math.tools.trigonometry import ( - deg_to_rad, - rad_to_deg, -) - - -def test_deg_to_rad(): - assert deg_to_rad("1") == "0.017453292519943295" - assert deg_to_rad("-1") == "-0.017453292519943295" - assert deg_to_rad("0") == "0.0" - assert deg_to_rad("-0") == "-0.0" - assert deg_to_rad("23") == "0.4014257279586958" - assert deg_to_rad("24") == "0.4188790204786391" - assert deg_to_rad("-10") == "-0.17453292519943295" - assert deg_to_rad("10") == "0.17453292519943295" - assert deg_to_rad("180") == "3.141592653589793" - assert deg_to_rad("0.0") == "0.0" - assert deg_to_rad("0.0000") == "0.0" - assert deg_to_rad("-0.0") == "-0.0" - assert deg_to_rad("1.0") == "0.017453292519943295" - assert deg_to_rad("-1.0") == "-0.017453292519943295" - assert deg_to_rad("23.0") == "0.4014257279586958" - assert deg_to_rad("0.4") == "0.006981317007977318" - assert deg_to_rad("-10.0") == "-0.17453292519943295" - assert deg_to_rad("10.0") == "0.17453292519943295" - - -def test_rad_to_deg(): - assert rad_to_deg("1") == "57.29577951308232" - assert rad_to_deg("-1") == "-57.29577951308232" - assert rad_to_deg("0") == "0.0" - assert rad_to_deg("-0") == "-0.0" - assert rad_to_deg("23") == "1317.8029288008934" - assert rad_to_deg("24") == "1375.0987083139757" - assert rad_to_deg("-10") == "-572.9577951308232" - assert rad_to_deg("10") == "572.9577951308232" - assert rad_to_deg("0.0") == "0.0" - assert rad_to_deg("0.0000") == "0.0" - assert rad_to_deg("-0.0") == "-0.0" - assert rad_to_deg("1.0") == "57.29577951308232" - assert rad_to_deg("-1.0") == "-57.29577951308232" - assert rad_to_deg("3.14") == "179.9087476710785" - assert rad_to_deg("0.4") == "22.918311805232932" - assert rad_to_deg("-10.0") == "-572.9577951308232" - assert rad_to_deg("10.0") == "572.9577951308232" diff --git a/toolkits/microsoft/.pre-commit-config.yaml b/toolkits/microsoft/.pre-commit-config.yaml deleted file mode 100644 index a740213d..00000000 --- a/toolkits/microsoft/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/microsoft/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/microsoft/.ruff.toml b/toolkits/microsoft/.ruff.toml deleted file mode 100644 index 9519fe6c..00000000 --- a/toolkits/microsoft/.ruff.toml +++ /dev/null @@ -1,44 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/microsoft/LICENSE b/toolkits/microsoft/LICENSE deleted file mode 100644 index 8c2d4f37..00000000 --- a/toolkits/microsoft/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/microsoft/Makefile b/toolkits/microsoft/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/microsoft/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/microsoft/arcade_microsoft/__init__.py b/toolkits/microsoft/arcade_microsoft/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/microsoft/arcade_microsoft/client.py b/toolkits/microsoft/arcade_microsoft/client.py deleted file mode 100644 index e11d257a..00000000 --- a/toolkits/microsoft/arcade_microsoft/client.py +++ /dev/null @@ -1,26 +0,0 @@ -import datetime -from typing import Any - -from azure.core.credentials import AccessToken, TokenCredential -from msgraph import GraphServiceClient - -DEFAULT_SCOPE = "https://graph.microsoft.com/.default" - - -class StaticTokenCredential(TokenCredential): - """Implementation of TokenCredential protocol to be provided to the MSGraph SDK client""" - - def __init__(self, token: str): - self._token = token - - def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken: - # An expiration is required by MSGraph SDK. Set to 1 hour from now. - expires_on = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + 3600 - return AccessToken(self._token, expires_on) - - -def get_client(token: str) -> GraphServiceClient: - """Create and return a MSGraph SDK client, given the provided token.""" - token_credential = StaticTokenCredential(token) - - return GraphServiceClient(token_credential, scopes=[DEFAULT_SCOPE]) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/__init__.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/__init__.py deleted file mode 100644 index 4baac7ce..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_microsoft.outlook_calendar.tools import ( - create_event, - get_event, - list_events_in_time_range, -) - -__all__ = ["create_event", "get_event", "list_events_in_time_range"] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py deleted file mode 100644 index 5be36170..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py +++ /dev/null @@ -1,225 +0,0 @@ -import re -from datetime import datetime -from typing import Any - -import pytz -from arcade_tdk.errors import ToolExecutionError -from kiota_abstractions.base_request_configuration import RequestConfiguration -from kiota_abstractions.headers_collection import HeadersCollection -from msgraph import GraphServiceClient -from msgraph.generated.users.item.mailbox_settings.mailbox_settings_request_builder import ( - MailboxSettingsRequestBuilder, -) - -from arcade_microsoft.outlook_calendar.constants import WINDOWS_TO_IANA - - -def validate_date_times(start_date_time: str, end_date_time: str) -> None: - """ - Validate date times are in ISO 8601 format and - that end time is after start time (ignoring timezone offsets). - - Args: - start_date_time: The start date time string to validate. - end_date_time: The end date time string to validate. - - Raises: - ValueError: If the date times are not in ISO 8601 format - ToolExecutionError: If end time is not after start time. - - Note: - This function ignores timezone offsets. - """ - # parse into offset-aware datetimes - start_aware = datetime.fromisoformat(start_date_time) - end_aware = datetime.fromisoformat(end_date_time) - - # drop tzinfo to treat both as naïve local times - start_naive = start_aware.replace(tzinfo=None) - end_naive = end_aware.replace(tzinfo=None) - - if start_naive >= end_naive: - raise ToolExecutionError( - message="Start time must be before end time", - developer_message=( - f"The start time '{start_naive}' is not before the end time '{end_naive}'" - ), - ) - - -def prepare_meeting_body( - body: str, custom_meeting_url: str | None, is_online_meeting: bool -) -> tuple[str, bool]: - """Prepare meeting body and determine final online meeting status. - - Args: - body: The original meeting body text - custom_meeting_url: Custom URL for the meeting, if one exists - is_online_meeting: Whether this should be an online meeting - - Returns: - tuple: (Updated meeting body, final online meeting status) - - Note: - If a custom meeting URL is provided, is_online_meeting will be set to False - to prevent Microsoft from generating its own meeting URL. The custom meeting - URL will then be added to the body of the meeting. - """ - is_online_meeting = not custom_meeting_url and is_online_meeting - - if custom_meeting_url: - body = f"""{body}\n -......................................................................... -Join online meeting -{custom_meeting_url}""" - - return body, is_online_meeting - - -def validate_emails(emails: list[str]) -> None: - """Validate a list of email addresses. - - Args: - emails: The list of email addresses to validate. - - Raises: - ToolExecutionError: If any email address is invalid. - """ - invalid_emails = [] - for email in emails: - if not is_valid_email(email): - invalid_emails.append(email) - if invalid_emails: - raise ToolExecutionError(message=f"Invalid email address(es): {', '.join(invalid_emails)}") - - -def is_valid_email(email: str) -> bool: - """Simple check to see if an email address is valid. - - Args: - email: The email address to check. - - Returns: - True if the email address is valid, False otherwise. - """ - pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" - return re.match(pattern, email) is not None - - -def remove_timezone_offset(date_time: str) -> str: - """Remove the timezone offset from the date_time string.""" - return re.sub(r"[+-][0-9]{2}:[0-9]{2}$|Z$", "", date_time) - - -def replace_timezone_offset(date_time: str, time_zone_offset: str) -> str: - """Replace the timezone offset in the date_time string with the time_zone_offset. - - If the date_time str already contains a timezone offset, it will be replaced. - If the date_time str does not contain a timezone offset, the time_zone_offset will be appended - - Args: - date_time: The date_time string to replace the timezone offset in. - time_zone_offset: The timezone offset to replace the existing timezone offset with. - - Returns: - The date_time string with the timezone offset replaced or appended. - """ - date_time = remove_timezone_offset(date_time) - return f"{date_time}{time_zone_offset}" - - -def convert_timezone_to_offset(time_zone: str) -> str: - """ - Convert a timezone (Windows or IANA) to ISO 8601 offset. - First tries Windows timezone format, then IANA, then falls back to UTC if both fail. - - Args: - time_zone: The timezone (Windows or IANA) to convert to ISO 8601 offset. - - Returns: - The timezone offset in ISO 8601 format (e.g. '+08:00', '-07:00', or 'Z' for UTC) - """ - # Try Windows timezone format - iana_timezone = WINDOWS_TO_IANA.get(time_zone) - if iana_timezone: - try: - tz = pytz.timezone(iana_timezone) - now = datetime.now(tz) - tz_offset = now.strftime("%z") - - if len(tz_offset) == 5: # +HHMM format - tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format - return tz_offset # noqa: TRY300 - except (pytz.exceptions.UnknownTimeZoneError, ValueError): - pass - - # Try IANA timezone format - try: - tz = pytz.timezone(time_zone) - now = datetime.now(tz) - tz_offset = now.strftime("%z") - - if len(tz_offset) == 5: # +HHMM format - tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format - return tz_offset # noqa: TRY300 - except (pytz.exceptions.UnknownTimeZoneError, ValueError): - # Fallback to UTC - return "Z" - - -async def get_default_calendar_timezone(client: GraphServiceClient) -> str: - """Get the authenticated user's default calendar's timezone. - - Args: - client: The GraphServiceClient to use to get - the authenticated user's default calendar's timezone. - - Returns: - The timezone in "Windows timezone format" or "IANA timezone format". - """ - query_params = MailboxSettingsRequestBuilder.MailboxSettingsRequestBuilderGetQueryParameters( - select=["timeZone"] - ) - request_config = RequestConfiguration( - query_parameters=query_params, - ) - response = await client.me.mailbox_settings.get(request_config) - - if response and response.time_zone: - return response.time_zone - return "UTC" - - -def create_timezone_headers(time_zone: str) -> HeadersCollection: - """ - Create headers with timezone preference. - - Args: - time_zone: The timezone to set in the headers. - - Returns: - Headers collection with timezone preference set. - """ - headers = HeadersCollection() - headers.try_add("Prefer", f'outlook.timezone="{time_zone}"') - return headers - - -def create_timezone_request_config( - time_zone: str, query_parameters: Any | None = None -) -> RequestConfiguration: - """ - Create a request configuration with timezone headers and optional query parameters. - - Args: - time_zone: The timezone to set in the headers. - query_parameters: Optional query parameters to include in the configuration. - - Returns: - Request configuration with timezone headers and optional query parameters. - """ - headers = create_timezone_headers(time_zone) - return RequestConfiguration( - headers=headers, - query_parameters=query_parameters, - ) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/constants.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/constants.py deleted file mode 100644 index 678e3771..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/constants.py +++ /dev/null @@ -1,138 +0,0 @@ -# Maps "Windows timezone format" to "IANA timezone format" -# Does not include all Windows timezones. -WINDOWS_TO_IANA = { - "Dateline Standard Time": "Etc/GMT+12", - "UTC-11": "Etc/GMT+11", - "Aleutian Standard Time": "America/Adak", - "Hawaiian Standard Time": "Pacific/Honolulu", - "Marquesas Standard Time": "Pacific/Marquesas", - "Alaskan Standard Time": "America/Anchorage", - "UTC-09": "Etc/GMT+9", - "Pacific Standard Time (Mexico)": "America/Tijuana", - "UTC-08": "Etc/GMT+8", - "Pacific Standard Time": "America/Los_Angeles", - "US Mountain Standard Time": "America/Phoenix", - "Mountain Standard Time (Mexico)": "America/Chihuahua", - "Mountain Standard Time": "America/Denver", - "Central America Standard Time": "America/Guatemala", - "Central Standard Time": "America/Chicago", - "Easter Island Standard Time": "Pacific/Easter", - "Central Standard Time (Mexico)": "America/Mexico_City", - "Canada Central Standard Time": "America/Regina", - "SA Pacific Standard Time": "America/Bogota", - "Eastern Standard Time (Mexico)": "America/Cancun", - "Eastern Standard Time": "America/New_York", - "Haiti Standard Time": "America/Port-au-Prince", - "Cuba Standard Time": "America/Havana", - "US Eastern Standard Time": "America/Indianapolis", - "Turks And Caicos Standard Time": "America/Grand_Turk", - "Paraguay Standard Time": "America/Asuncion", - "Atlantic Standard Time": "America/Halifax", - "Venezuela Standard Time": "America/Caracas", - "Central Brazilian Standard Time": "America/Cuiaba", - "SA Western Standard Time": "America/La_Paz", - "Pacific SA Standard Time": "America/Santiago", - "Newfoundland Standard Time": "America/St_Johns", - "Tocantins Standard Time": "America/Araguaina", - "E. South America Standard Time": "America/Sao_Paulo", - "SA Eastern Standard Time": "America/Cayenne", - "Argentina Standard Time": "America/Buenos_Aires", - "Greenland Standard Time": "America/Godthab", - "Montevideo Standard Time": "America/Montevideo", - "Magallanes Standard Time": "America/Punta_Arenas", - "Saint Pierre Standard Time": "America/Miquelon", - "Bahia Standard Time": "America/Bahia", - "UTC-02": "Etc/GMT+2", - "Azores Standard Time": "Atlantic/Azores", - "Cape Verde Standard Time": "Atlantic/Cape_Verde", - "UTC": "Etc/UTC", - "GMT Standard Time": "Europe/London", - "Greenwich Standard Time": "Atlantic/Reykjavik", - "W. Europe Standard Time": "Europe/Berlin", - "Central Europe Standard Time": "Europe/Budapest", - "Romance Standard Time": "Europe/Paris", - "Central European Standard Time": "Europe/Warsaw", - "W. Central Africa Standard Time": "Africa/Lagos", - "Jordan Standard Time": "Asia/Amman", - "GTB Standard Time": "Europe/Bucharest", - "Middle East Standard Time": "Asia/Beirut", - "Egypt Standard Time": "Africa/Cairo", - "E. Europe Standard Time": "Europe/Chisinau", - "Syria Standard Time": "Asia/Damascus", - "West Bank Standard Time": "Asia/Hebron", - "South Africa Standard Time": "Africa/Johannesburg", - "FLE Standard Time": "Europe/Kiev", - "Israel Standard Time": "Asia/Jerusalem", - "Kaliningrad Standard Time": "Europe/Kaliningrad", - "Sudan Standard Time": "Africa/Khartoum", - "Libya Standard Time": "Africa/Tripoli", - "Namibia Standard Time": "Africa/Windhoek", - "Arabic Standard Time": "Asia/Baghdad", - "Turkey Standard Time": "Europe/Istanbul", - "Arab Standard Time": "Asia/Riyadh", - "Belarus Standard Time": "Europe/Minsk", - "Russian Standard Time": "Europe/Moscow", - "E. Africa Standard Time": "Africa/Nairobi", - "Iran Standard Time": "Asia/Tehran", - "Arabian Standard Time": "Asia/Dubai", - "Astrakhan Standard Time": "Europe/Astrakhan", - "Azerbaijan Standard Time": "Asia/Baku", - "Russia Time Zone 3": "Europe/Samara", - "Mauritius Standard Time": "Indian/Mauritius", - "Saratov Standard Time": "Europe/Saratov", - "Georgian Standard Time": "Asia/Tbilisi", - "Volgograd Standard Time": "Europe/Volgograd", - "Caucasus Standard Time": "Asia/Yerevan", - "Afghanistan Standard Time": "Asia/Kabul", - "West Asia Standard Time": "Asia/Tashkent", - "Ekaterinburg Standard Time": "Asia/Yekaterinburg", - "Pakistan Standard Time": "Asia/Karachi", - "India Standard Time": "Asia/Calcutta", - "Sri Lanka Standard Time": "Asia/Colombo", - "Nepal Standard Time": "Asia/Kathmandu", - "Central Asia Standard Time": "Asia/Almaty", - "Bangladesh Standard Time": "Asia/Dhaka", - "Omsk Standard Time": "Asia/Omsk", - "Myanmar Standard Time": "Asia/Rangoon", - "SE Asia Standard Time": "Asia/Bangkok", - "Altai Standard Time": "Asia/Barnaul", - "W. Mongolia Standard Time": "Asia/Hovd", - "North Asia Standard Time": "Asia/Krasnoyarsk", - "N. Central Asia Standard Time": "Asia/Novosibirsk", - "Tomsk Standard Time": "Asia/Tomsk", - "China Standard Time": "Asia/Shanghai", - "North Asia East Standard Time": "Asia/Irkutsk", - "Singapore Standard Time": "Asia/Singapore", - "W. Australia Standard Time": "Australia/Perth", - "Taipei Standard Time": "Asia/Taipei", - "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", - "North Korea Standard Time": "Asia/Pyongyang", - "Aus Central W. Standard Time": "Australia/Eucla", - "Transbaikal Standard Time": "Asia/Chita", - "Tokyo Standard Time": "Asia/Tokyo", - "Korea Standard Time": "Asia/Seoul", - "Yakutsk Standard Time": "Asia/Yakutsk", - "Cen. Australia Standard Time": "Australia/Adelaide", - "AUS Central Standard Time": "Australia/Darwin", - "E. Australia Standard Time": "Australia/Brisbane", - "AUS Eastern Standard Time": "Australia/Sydney", - "West Pacific Standard Time": "Pacific/Port_Moresby", - "Tasmania Standard Time": "Australia/Hobart", - "Vladivostok Standard Time": "Asia/Vladivostok", - "Lord Howe Standard Time": "Australia/Lord_Howe", - "Bougainville Standard Time": "Pacific/Bougainville", - "Russia Time Zone 10": "Asia/Srednekolymsk", - "Magadan Standard Time": "Asia/Magadan", - "Norfolk Standard Time": "Pacific/Norfolk", - "Sakhalin Standard Time": "Asia/Sakhalin", - "Central Pacific Standard Time": "Pacific/Guadalcanal", - "Russia Time Zone 11": "Asia/Kamchatka", - "New Zealand Standard Time": "Pacific/Auckland", - "UTC+12": "Etc/GMT-12", - "Fiji Standard Time": "Pacific/Fiji", - "Chatham Islands Standard Time": "Pacific/Chatham", - "UTC+13": "Etc/GMT-13", - "Tonga Standard Time": "Pacific/Tongatapu", - "Samoa Standard Time": "Pacific/Apia", - "Line Islands Standard Time": "Pacific/Kiritimati", -} diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py deleted file mode 100644 index b65201b9..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py +++ /dev/null @@ -1,288 +0,0 @@ -import re -from dataclasses import dataclass, field -from typing import Any - -from bs4 import BeautifulSoup -from msgraph.generated.models.attendee import Attendee as GraphAttendee -from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.event import Event as GraphEvent -from msgraph.generated.models.event_type import EventType as GraphEventType -from msgraph.generated.models.free_busy_status import FreeBusyStatus as GraphFreeBusyStatus -from msgraph.generated.models.importance import Importance as GraphImportance -from msgraph.generated.models.item_body import ItemBody as GraphItemBody -from msgraph.generated.models.location import Location as GraphLocation -from msgraph.generated.models.recipient import Recipient as GraphRecipient -from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus -from msgraph.generated.models.response_type import ResponseType as GraphResponseType - - -@dataclass -class Attendee: - """An attendee of a calendar event.""" - - name: str = "" - address: str = "" - response: str = "" - - @classmethod - def from_sdk(cls, attendee: GraphAttendee) -> "Attendee": - """Convert a Microsoft Graph SDK Attendee object to an Attendee dataclass.""" - return cls( - name=attendee.email_address.name - if attendee.email_address and attendee.email_address.name - else "", - address=attendee.email_address.address - if attendee.email_address and attendee.email_address.address - else "", - response=attendee.status.response - if attendee.status and attendee.status.response - else "", - ) - - def to_dict(self) -> dict[str, str]: - return { - "name": self.name, - "address": self.address, - "response": self.response, - } - - def to_sdk(self) -> GraphAttendee: - """Convert an Attendee dataclass to a Microsoft Graph SDK Attendee object.""" - return GraphAttendee( - email_address=GraphEmailAddress(name=self.name, address=self.address), - status=GraphResponseStatus( - response=GraphResponseType(self.response) - if self.response - else GraphResponseType.None_ - ), - ) - - -@dataclass -class Organizer: - """The organizer of an event.""" - - name: str = "" - address: str = "" - - @classmethod - def from_sdk(cls, organizer: GraphRecipient) -> "Organizer": - """Convert a Microsoft Graph SDK Organizer object to an Organizer dataclass.""" - return cls( - name=organizer.email_address.name - if organizer.email_address and organizer.email_address.name - else "", - address=organizer.email_address.address - if organizer.email_address and organizer.email_address.address - else "", - ) - - def to_dict(self) -> dict[str, str]: - return { - "name": self.name, - "address": self.address, - } - - def to_sdk(self) -> GraphRecipient: - """Convert an Organizer dataclass to a Microsoft Graph SDK Organizer object.""" - recipient = GraphRecipient( - email_address=GraphEmailAddress(name=self.name, address=self.address) - ) - return recipient - - -@dataclass -class DateTimeTimeZone: - """Time information for an event.""" - - date_time: str = "" - time_zone: str = "" - - @classmethod - def from_sdk(cls, date_time_time_zone: GraphDateTimeTimeZone) -> "DateTimeTimeZone": - """Convert a Microsoft Graph SDK DateTimeTimeZone object to a TimeInfo dataclass.""" - return cls( - date_time=date_time_time_zone.date_time or "", - time_zone=date_time_time_zone.time_zone or "", - ) - - def to_dict(self) -> dict[str, str]: - return { - "dateTime": self.date_time, - "timeZone": self.time_zone, - } - - def to_sdk(self) -> GraphDateTimeTimeZone: - """Convert a TimeInfo dataclass to a Microsoft Graph SDK DateTimeTimeZone object.""" - return GraphDateTimeTimeZone(date_time=self.date_time, time_zone=self.time_zone) - - -@dataclass -class ResponseStatus: - """The response status for an event.""" - - response: str = "" - - @classmethod - def from_sdk(cls, response_status: GraphResponseStatus) -> "ResponseStatus": - """Convert a Microsoft Graph SDK ResponseStatus object to a ResponseStatus dataclass.""" - response_value = ( - str(response_status.response.value) - if response_status.response and hasattr(response_status.response, "value") - else "" - ) - return cls(response=response_value) - - def to_dict(self) -> dict[str, str]: - return { - "response": self.response, - } - - def to_sdk(self) -> GraphResponseStatus: - """Convert a ResponseStatus dataclass to a Microsoft Graph SDK ResponseStatus object.""" - return GraphResponseStatus(response=GraphResponseType(self.response)) - - -@dataclass -class Event: - """A calendar event in Outlook.""" - - attendees: list[Attendee] = field(default_factory=list) - body: str = "" - end: DateTimeTimeZone | None = None - has_attachments: bool = False - importance: str = "" - is_all_day: bool = False - is_cancelled: bool = False - is_draft: bool = False - is_online_meeting: bool = False - is_organizer: bool = False - location: str = "" - online_meeting_url: str = "" - organizer: Organizer | None = None - id: str = "" - response_status: ResponseStatus | None = None - show_as: str = "" - start: DateTimeTimeZone | None = None - subject: str = "" - type: str = "" - web_link: str = "" - event_id: str = "" # The unique identifier of the event. Read-only. - - @staticmethod - def _safe_str(value: Any) -> str: - if not value: - return "" - if isinstance(value, bytes | bytearray): - return value.decode("utf-8", errors="ignore") - return str(value) - - @staticmethod - def _safe_bool(value: Any) -> bool: - return bool(value) - - @staticmethod - def _parse_body(mime: str) -> str: - if not mime: - return "" - soup = BeautifulSoup(mime, "html.parser") - text = soup.get_text(separator=" ") - # Replace multiple newlines with a single newline - text = re.sub(r"\n+", "\n", text) - # Replace multiple spaces with a single space - text = re.sub(r"\s+", " ", text) - # Replace sequences of dots (likely from horizontal lines) with a single newline - text = re.sub(r"\.{3,}", "\n---\n", text) - # Remove leading/trailing whitespace from each line - text = "\n".join(line.strip() for line in text.split("\n")) - return text - - @classmethod - def from_sdk(cls, event: GraphEvent) -> "Event": - """Convert a Microsoft Graph SDK Event object to an Event dataclass.""" - body_mime = event.body.content if event.body and event.body.content else "" - body = cls._parse_body(body_mime) - - attendees = [Attendee.from_sdk(a) for a in event.attendees if a] if event.attendees else [] - start = DateTimeTimeZone.from_sdk(event.start) if event.start else None - end = DateTimeTimeZone.from_sdk(event.end) if event.end else None - organizer = Organizer.from_sdk(event.organizer) if event.organizer else None - response_status = ( - ResponseStatus.from_sdk(event.response_status) if event.response_status else None - ) - - return cls( - attendees=attendees, - body=body, - end=end, - has_attachments=cls._safe_bool(event.has_attachments), - importance=cls._safe_str(str(event.importance.value)) if event.importance else "", - is_all_day=cls._safe_bool(event.is_all_day), - is_cancelled=cls._safe_bool(event.is_cancelled), - is_draft=cls._safe_bool(event.is_draft), - is_online_meeting=cls._safe_bool(event.is_online_meeting), - is_organizer=cls._safe_bool(event.is_organizer), - location=cls._safe_str(event.location.display_name if event.location else ""), - online_meeting_url=cls._safe_str(event.online_meeting_url), - organizer=organizer, - id=cls._safe_str(event.id), - response_status=response_status, - show_as=cls._safe_str(str(event.show_as.value)) if event.show_as else "", - start=start, - subject=cls._safe_str(event.subject), - type=cls._safe_str(str(event.type.value)) if event.type else "", - web_link=cls._safe_str(event.web_link), - event_id=cls._safe_str(event.id), - ) - - def to_dict(self) -> dict[str, Any]: - """Converts the Event dataclass to a dictionary.""" - return { - "attendees": [attendee.to_dict() for attendee in self.attendees], - "body": self.body, - "end": self.end.to_dict() if self.end else None, - "has_attachments": self.has_attachments, - "importance": self.importance, - "is_all_day": self.is_all_day, - "is_cancelled": self.is_cancelled, - "is_draft": self.is_draft, - "is_online_meeting": self.is_online_meeting, - "is_organizer": self.is_organizer, - "location": self.location, - "online_meeting_url": self.online_meeting_url, - "organizer": self.organizer.to_dict() if self.organizer else None, - "id": self.id, - "response_status": self.response_status.to_dict() if self.response_status else None, - "show_as": self.show_as, - "start": self.start.to_dict() if self.start else None, - "subject": self.subject, - "type": self.type, - "web_link": self.web_link, - "event_id": self.event_id, - } - - def to_sdk(self) -> GraphEvent: - """Convert an Event dataclass to a Microsoft Graph SDK Event object.""" - return GraphEvent( - attendees=[attendee.to_sdk() for attendee in self.attendees], - body=GraphItemBody(content=self.body), - end=self.end.to_sdk() if self.end else None, - has_attachments=self.has_attachments, - importance=GraphImportance(self.importance) if self.importance else None, - is_all_day=self.is_all_day, - is_cancelled=self.is_cancelled, - is_draft=self.is_draft, - is_online_meeting=self.is_online_meeting, - is_organizer=self.is_organizer, - location=GraphLocation(display_name=self.location), - online_meeting_url=self.online_meeting_url, - organizer=self.organizer.to_sdk() if self.organizer else None, - id=self.id, - response_status=self.response_status.to_sdk() if self.response_status else None, - show_as=GraphFreeBusyStatus(self.show_as) if self.show_as else None, - start=self.start.to_sdk() if self.start else None, - subject=self.subject, - type=GraphEventType(self.type) if self.type else None, - web_link=self.web_link, - ) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/__init__.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/__init__.py deleted file mode 100644 index 4fcdfabb..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_microsoft.outlook_calendar.tools.create_event import create_event -from arcade_microsoft.outlook_calendar.tools.get_event import get_event -from arcade_microsoft.outlook_calendar.tools.list_events_in_time_range import ( - list_events_in_time_range, -) - -__all__ = ["create_event", "get_event", "list_events_in_time_range"] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/create_event.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/create_event.py deleted file mode 100644 index 021adfdb..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/create_event.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_calendar._utils import ( - create_timezone_request_config, - get_default_calendar_timezone, - prepare_meeting_body, - remove_timezone_offset, - validate_date_times, - validate_emails, -) -from arcade_microsoft.outlook_calendar.models import ( - Attendee, - DateTimeTimeZone, - Event, -) - - -@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadWrite"])) -async def create_event( - context: ToolContext, - subject: Annotated[str, "The text of the event's subject (title) line."], - body: Annotated[str, "The body of the event"], - start_date_time: Annotated[ - str, - "The datetime of the event's start, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:00:00", - ], - end_date_time: Annotated[ - str, - "The datetime of the event's end, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:30:00", - ], - location: Annotated[str | None, "The location of the event"] = None, - attendee_emails: Annotated[ - list[str] | None, - "The email addresses of the attendees of the event. " - "Must be valid email addresses e.g., username@domain.com.", - ] = None, - is_online_meeting: Annotated[ - bool, "Whether the event is an online meeting. Defaults to False" - ] = False, - custom_meeting_url: Annotated[ - str | None, - "The URL of the online meeting. If not provided and is_online_meeting is True, " - "then a url will be generated for you", - ] = None, -) -> Annotated[dict, "A dictionary containing the created event details"]: - """Create an event in the authenticated user's default calendar. - - Ignores timezone offsets provided in the start_date_time and end_date_time parameters. - Instead, uses the user's default calendar timezone to filter events. - If the user has not set a timezone for their calendar, then the timezone will be UTC. - """ - # Validate & cleanse inputs - validate_emails(attendee_emails or []) - validate_date_times(start_date_time, end_date_time) - body, is_online_meeting = prepare_meeting_body(body, custom_meeting_url, is_online_meeting) - - client = get_client(context.get_auth_token_or_empty()) - - time_zone = await get_default_calendar_timezone(client) - start_date_time = remove_timezone_offset(start_date_time) - end_date_time = remove_timezone_offset(end_date_time) - event = Event( - subject=subject, - body=body, - start=DateTimeTimeZone(date_time=start_date_time, time_zone=time_zone), - end=DateTimeTimeZone(date_time=end_date_time, time_zone=time_zone), - location=location or "", - attendees=[Attendee(address=attendee) for attendee in attendee_emails or []], - is_online_meeting=is_online_meeting, - ).to_sdk() - request_config = create_timezone_request_config(time_zone) - - response = await client.me.events.post(body=event, request_configuration=request_config) - - return Event.from_sdk(response).to_dict() # type: ignore[arg-type] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/get_event.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/get_event.py deleted file mode 100644 index 3c083c67..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/get_event.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_calendar._utils import ( - create_timezone_request_config, - get_default_calendar_timezone, -) -from arcade_microsoft.outlook_calendar.models import Event - - -@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"])) -async def get_event( - context: ToolContext, - event_id: Annotated[str, "The ID of the event to get"], -) -> Annotated[dict, "A dictionary containing the event details"]: - """Get an event by its ID from the user's calendar.""" - client = get_client(context.get_auth_token_or_empty()) - - time_zone = await get_default_calendar_timezone(client) - request_config = create_timezone_request_config(time_zone) - - response = await client.me.events.by_event_id(event_id).get( - request_configuration=request_config - ) - - return Event.from_sdk(response).to_dict() # type: ignore[arg-type] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/list_events_in_time_range.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/list_events_in_time_range.py deleted file mode 100644 index 3eeef1cc..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/list_events_in_time_range.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from msgraph.generated.users.item.calendar.calendar_view.calendar_view_request_builder import ( - CalendarViewRequestBuilder, -) - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_calendar._utils import ( - convert_timezone_to_offset, - create_timezone_request_config, - get_default_calendar_timezone, - replace_timezone_offset, - validate_date_times, -) -from arcade_microsoft.outlook_calendar.models import Event - - -@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"])) -async def list_events_in_time_range( - context: ToolContext, - start_date_time: Annotated[ - str, - "The start date and time of the time range, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:00:00", - ], - end_date_time: Annotated[ - str, - "The end date and time of the time range, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:30:00", - ], - limit: Annotated[int, "The maximum number of events to return. Max 1000. Defaults to 10"] = 10, -) -> Annotated[dict, "A dictionary containing a list of events"]: - """List events in the user's calendar in a specific time range. - - Ignores timezone offsets provided in the start_date_time and end_date_time parameters. - Instead, uses the user's default calendar timezone to filter events. - If the user has not set a timezone for their calendar, then the timezone will be UTC. - """ - # Validate inputs - validate_date_times(start_date_time, end_date_time) - - client = get_client(context.get_auth_token_or_empty()) - time_zone = await get_default_calendar_timezone(client) - time_zone_offset = convert_timezone_to_offset(time_zone) - start_date_time = replace_timezone_offset(start_date_time, time_zone_offset) - end_date_time = replace_timezone_offset(end_date_time, time_zone_offset) - query_params = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters( - start_date_time=start_date_time, - end_date_time=end_date_time, - top=max(1, min(limit, 1000)), - ) - request_config = create_timezone_request_config(time_zone, query_params) - - response = await client.me.calendar.calendar_view.get(request_config) - events = [Event.from_sdk(event).to_dict() for event in response.value] # type: ignore[union-attr] - - return {"events": events, "num_events": len(events)} diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/__init__.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/__init__.py deleted file mode 100644 index f0d7ab26..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -from arcade_microsoft.outlook_mail.tools import ( - create_and_send_email, - create_draft_email, - list_emails, - list_emails_by_property, - list_emails_in_folder, - reply_to_email, - send_draft_email, - update_draft_email, -) - -__all__ = [ - # Read - "list_emails", - "list_emails_by_property", - "list_emails_in_folder", - # Send - "create_and_send_email", - "send_draft_email", - "reply_to_email", - # Write - "create_draft_email", - "update_draft_email", -] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/_utils.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/_utils.py deleted file mode 100644 index 8677adc4..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/_utils.py +++ /dev/null @@ -1,116 +0,0 @@ -from arcade_tdk import ToolContext -from msgraph.generated.models.message_collection_response import MessageCollectionResponse -from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import ( - MessagesRequestBuilder as MailFolderMessagesRequestBuilder, -) -from msgraph.generated.users.item.messages.item.reply.reply_post_request_body import ( - ReplyPostRequestBody, -) -from msgraph.generated.users.item.messages.item.reply_all.reply_all_post_request_body import ( - ReplyAllPostRequestBody, -) -from msgraph.generated.users.item.messages.messages_request_builder import ( - MessagesRequestBuilder as UserMessagesRequestBuilder, -) - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_mail.constants import DEFAULT_MESSAGE_FIELDS -from arcade_microsoft.outlook_mail.enums import EmailFilterProperty, FilterOperator, ReplyType - - -def remove_none_values(data: dict) -> dict: - """Remove all keys with None values from the dictionary.""" - return {k: v for k, v in data.items() if v is not None} - - -def _create_filter_expression( - property_: EmailFilterProperty | None = None, - operator: FilterOperator | None = None, - value: str | None = None, -) -> str | None: - if property_ and operator and value: - property_value = property_.value - operator_value = operator.value - - # Never use quotes around 'value' for booleans and numerics - value_quote = "'" - if value.lower() in ["true", "false"] or value.isdigit(): - value_quote = "" - - # Handle function operators (e.g., contains, startsWith, endsWith) - if operator.is_function(): - filter_expr = f"{operator_value}({property_value}, {value_quote}{value}{value_quote})" - else: - # Handle comparison operators (e.g., eq, ne, gt, ge, lt, le) - filter_expr = f"{property_value} {operator_value} {value_quote}{value}{value_quote}" - - if property_value == EmailFilterProperty.RECEIVED_DATE_TIME: - filter_expr = filter_expr - else: # Since receivedDateTime is in orderby, it must be in filter: https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0&tabs=http#optional-query-parameters - filter_expr = f"receivedDateTime ge 1900-01-01T00:00:00Z and {filter_expr}" - - return filter_expr - - return None - - -def prepare_list_emails_request_config( - limit: int, - property_: EmailFilterProperty | None = None, - operator: FilterOperator | None = None, - value: str | None = None, -) -> MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration: - """Prepare a request configuration for listing emails.""" - limit = max(1, min(limit, 100)) # limit must be between 1 and 100 - - orderby = ["receivedDateTime DESC"] - filter_expr = _create_filter_expression(property_, operator, value) - - query_params = MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters( - count=True, - select=DEFAULT_MESSAGE_FIELDS, - orderby=orderby, - filter=filter_expr, - top=limit, - ) - return MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration( - query_parameters=query_params, - ) - - -async def fetch_emails( - message_builder: MailFolderMessagesRequestBuilder | UserMessagesRequestBuilder, - pagination_token: str | None = None, - request_config: MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration - | None = None, -) -> MessageCollectionResponse: - """Fetch emails from the user's mailbox. - - Microsoft Graph Python SDK does not support pagination (as of 2025-04-17), - so we use raw URL for pagination if a pagination token is provided. - """ - if pagination_token: - return await message_builder.with_url(pagination_token).get() # type: ignore[return-value] - return await message_builder.get(request_configuration=request_config) # type: ignore[return-value, arg-type] - - -async def send_reply_email( - context: ToolContext, - message_id: str, - body: str, - reply_type: ReplyType, -) -> dict: - """Send a reply email to the sender or all recipients of an existing email.""" - client = get_client(context.get_auth_token_or_empty()) - - if reply_type == ReplyType.REPLY: - reply_request_body = ReplyPostRequestBody(comment=body) - await client.me.messages.by_message_id(message_id).reply.post(reply_request_body) - elif reply_type == ReplyType.REPLY_ALL: - reply_all_request_body = ReplyAllPostRequestBody(comment=body) - await client.me.messages.by_message_id(message_id).reply_all.post(reply_all_request_body) - - return { - "success": True, - "message": "Email sent successfully", - } diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py deleted file mode 100644 index 6daef4d6..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -DEFAULT_MESSAGE_FIELDS = [ - "bccRecipients", - "body", - "ccRecipients", - "conversationId", - "conversationIndex", - "flag", - "from", - "hasAttachments", - "importance", - "isDraft", - "isRead", - "receivedDateTime", - "replyTo", - "subject", - "toRecipients", - "webLink", -] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/enums.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/enums.py deleted file mode 100644 index ce68162f..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/enums.py +++ /dev/null @@ -1,65 +0,0 @@ -from enum import Enum - - -class WellKnownFolderNames(str, Enum): - """Well-known folder names that are created for users by default. - Instead of using the ID of these folders, you can use the well-known folder names. - For a list of all well-known folder names, see: https://learn.microsoft.com/en-us/graph/api/resources/mailfolder?view=graph-rest-1.0 - """ - - DELETED_ITEMS = "deleteditems" - DRAFTS = "drafts" - INBOX = "inbox" - JUNK_EMAIL = "junkemail" - SENT_ITEMS = "sentitems" - STARRED = "starred" - TODO = "tasks" - - -class ReplyType(str, Enum): - """The type of reply to send to an email.""" - - REPLY = "reply" - REPLY_ALL = "reply_all" - - -class EmailFilterProperty(str, Enum): - """The property to filter the emails by.""" - - # Basic properties - SUBJECT = "subject" - CONVERSATION_ID = "conversationId" - RECEIVED_DATE_TIME = "receivedDateTime" - SENDER = "sender/emailAddress/address" - - -class FilterOperator(str, Enum): - """The operator to use for the filter. - - For a full list of possible operators, see: https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions - """ - - # Equality operators - EQUAL = "eq" # example: $filter=conversationId eq 'hello' - NOT_EQUAL = "ne" # example: $filter=subject ne 'hello' - - # Relational operators - GREATER_THAN = "gt" # example: $filter=receivedDateTime gt 2024-01-01 - GREATER_THAN_OR_EQUAL_TO = "ge" # example: $filter=receivedDateTime ge 2024-01-01 - LESS_THAN = "lt" # example: $filter=receivedDateTime lt 2024-01-01 - LESS_THAN_OR_EQUAL_TO = "le" # example: $filter=receivedDateTime le 2024-01-01 - - # Functions - STARTS_WITH = "startsWith" # example: $filter=startsWith(subject, 'hello') - ENDS_WITH = "endsWith" # example: $filter=endsWith(subject, 'hello') - CONTAINS = "contains" # example: $filter=contains(subject, 'hello') - - def is_operator(self) -> bool: - """Check if the operator is a comparison operator.""" - operators = [self.EQUAL, self.NOT_EQUAL] - return self in operators - - def is_function(self) -> bool: - """Check if the operator is a function.""" - functions = [self.STARTS_WITH, self.ENDS_WITH, self.CONTAINS] - return self in functions diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/message.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/message.py deleted file mode 100644 index 67f90e9a..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/message.py +++ /dev/null @@ -1,218 +0,0 @@ -import re -from dataclasses import dataclass, field -from typing import Any, cast - -from bs4 import BeautifulSoup -from msgraph.generated.models.body_type import BodyType -from msgraph.generated.models.email_address import EmailAddress -from msgraph.generated.models.item_body import ItemBody -from msgraph.generated.models.message import Message as GraphMessage -from msgraph.generated.models.recipient import Recipient as GraphRecipient - - -@dataclass -class Recipient: - """A recipient of an email message.""" - - email_address: str = "" - name: str = "" - - @classmethod - def from_sdk(cls, recipient: GraphRecipient) -> "Recipient": - """Convert a Microsoft Graph SDK Recipient object to a Recipient dataclass.""" - address = ( - recipient.email_address.address - if recipient and recipient.email_address and recipient.email_address.address - else "" - ) - name = ( - recipient.email_address.name - if recipient and recipient.email_address and recipient.email_address.name - else "" - ) - return cls(email_address=address, name=name) - - def to_dict(self) -> dict[str, str]: - return {"email_address": self.email_address, "name": self.name} - - def to_sdk(self) -> GraphRecipient: - """Converts the Recipient dataclass to a Microsoft Graph SDK Recipient object.""" - recipient = GraphRecipient() - email_address = EmailAddress() - email_address.address = self.email_address - email_address.name = self.name - recipient.email_address = email_address - return recipient - - -@dataclass -class Message: - """An email message in Outlook.""" - - bcc_recipients: list[Recipient] = field(default_factory=list) - cc_recipients: list[Recipient] = field(default_factory=list) - reply_to: list[Recipient] = field(default_factory=list) - to_recipients: list[Recipient] = field(default_factory=list) - from_: Recipient = field(default_factory=Recipient) - subject: str = "" - body: str = "" - conversation_id: str = "" - conversation_index: str = "" - flag: dict[str, str] = field(default_factory=dict) - has_attachments: bool = False - importance: str = "" - is_read: bool = False - received_date_time: str = "" - web_link: str = "" - is_draft: bool = True - message_id: str = "" # The unique identifier of the email message. Read-only. - - @staticmethod - def _safe_str(value: Any) -> str: - if not value: - return "" - if isinstance(value, bytes | bytearray): - return value.decode("utf-8", errors="ignore") - return str(value) - - @staticmethod - def _safe_bool(value: Any) -> bool: - return bool(value) - - @staticmethod - def _parse_body(mime: str) -> str: - if not mime: - return "" - soup = BeautifulSoup(mime, "html.parser") - text = soup.get_text(separator=" ") - # Replace multiple newlines with a single newline - text = re.sub(r"\n+", "\n", text) - # Replace multiple spaces with a single space - text = re.sub(r"\s+", " ", text) - # Remove leading/trailing whitespace from each line - text = "\n".join(line.strip() for line in text.split("\n")) - - return text - - @staticmethod - def _parse_importance(value: Any) -> str: - return cast(str, value.value) if getattr(value, "value", None) else "" - - @staticmethod - def _parse_flag(flag: Any) -> dict[str, str]: - if not flag: - return {"flag_status": "", "due_date_time": ""} - status = flag.flag_status.value if getattr(flag, "flag_status", None) else "" - due = "" - if getattr(flag, "due_date_time", None) and getattr(flag.due_date_time, "date_time", None): - due = Message._safe_str(flag.due_date_time.date_time) - return {"flag_status": status, "due_date_time": due} - - @classmethod - def from_sdk(cls, msg: GraphMessage) -> "Message": - """Convert a Microsoft Graph SDK Message object to a Message dataclass.""" - text = cls._parse_body(msg.body.content if msg.body and msg.body.content else "") - return cls( - bcc_recipients=[ - Recipient.from_sdk(recipient) for recipient in msg.bcc_recipients or [] - ], - cc_recipients=[Recipient.from_sdk(recipient) for recipient in msg.cc_recipients or []], - reply_to=[Recipient.from_sdk(recipient) for recipient in msg.reply_to or []], - to_recipients=[Recipient.from_sdk(recipient) for recipient in msg.to_recipients or []], - from_=Recipient.from_sdk(msg.from_) if msg.from_ else Recipient(), - subject=cls._safe_str(msg.subject), - body=text, - conversation_id=cls._safe_str(msg.conversation_id), - conversation_index=( - msg.conversation_index.decode("utf-8", errors="ignore") - if isinstance(msg.conversation_index, bytes | bytearray) - else cls._safe_str(msg.conversation_index) - ), - flag=cls._parse_flag(msg.flag), - has_attachments=cls._safe_bool(msg.has_attachments), - importance=cls._parse_importance(msg.importance), - is_read=cls._safe_bool(msg.is_read), - received_date_time=( - msg.received_date_time.isoformat() if msg.received_date_time else "" - ), - web_link=cls._safe_str(msg.web_link), - is_draft=cls._safe_bool(msg.is_draft), - message_id=cls._safe_str(msg.id), - ) - - def to_sdk(self) -> GraphMessage: - """Converts the Message dataclass to a Microsoft Graph SDK Message object.""" - sdk_msg = GraphMessage() - sdk_msg.subject = self.subject - body_obj = ItemBody() - body_obj.content = self.body - body_obj.content_type = BodyType.Text - sdk_msg.body = body_obj - sdk_msg.is_draft = self.is_draft - sdk_msg.to_recipients = [r.to_sdk() for r in self.to_recipients] - sdk_msg.cc_recipients = [r.to_sdk() for r in self.cc_recipients] - sdk_msg.bcc_recipients = [r.to_sdk() for r in self.bcc_recipients] - sdk_msg.reply_to = [r.to_sdk() for r in self.reply_to] - - return sdk_msg - - def to_dict(self) -> dict[str, Any]: - """Converts the Message dataclass to a dictionary.""" - return { - "bcc_recipients": [recipient.to_dict() for recipient in self.bcc_recipients], - "cc_recipients": [recipient.to_dict() for recipient in self.cc_recipients], - "reply_to": [recipient.to_dict() for recipient in self.reply_to], - "to_recipients": [recipient.to_dict() for recipient in self.to_recipients], - "from": self.from_.to_dict(), - "subject": self.subject, - "body": self.body, - "conversation_id": self.conversation_id, - "conversation_index": self.conversation_index, - "flag": self.flag, - "has_attachments": self.has_attachments, - "importance": self.importance, - "is_read": self.is_read, - "received_date_time": self.received_date_time, - "web_link": self.web_link, - "is_draft": self.is_draft, - "message_id": self.message_id, - } - - def update_recipient_lists( - self, - to_add: list[str] | None = None, - to_remove: list[str] | None = None, - cc_add: list[str] | None = None, - cc_remove: list[str] | None = None, - bcc_add: list[str] | None = None, - bcc_remove: list[str] | None = None, - ) -> None: - """Update each recipient list of the message. - - This function updates the recipient lists of the message by first adding new recipients - and then removing existing recipients. Therefore, if an email address is both - added and removed, then it will not be included in the returned list. - """ - for attr, add_emails_input, remove_emails_input in ( - ("to_recipients", to_add, to_remove), - ("cc_recipients", cc_add, cc_remove), - ("bcc_recipients", bcc_add, bcc_remove), - ): - current_recipients = getattr(self, attr) or [] - # Add recipients - existing_emails = {r.email_address.lower() for r in current_recipients} - new_additions = [ - Recipient(email_address=email) - for email in (add_emails_input or []) - if email.lower() not in existing_emails - ] - # Remove recipients - updated_list = current_recipients + new_additions - remove_emails = {email.lower() for email in (remove_emails_input or [])} - updated_list = [ - recipient - for recipient in updated_list - if recipient.email_address.lower() not in remove_emails - ] - # Update the message's attribute with the new list - setattr(self, attr, updated_list) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/__init__.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/__init__.py deleted file mode 100644 index 5e4278a1..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -from arcade_microsoft.outlook_mail.tools.read import ( - list_emails, - list_emails_by_property, - list_emails_in_folder, -) -from arcade_microsoft.outlook_mail.tools.send import ( - create_and_send_email, - reply_to_email, - send_draft_email, -) -from arcade_microsoft.outlook_mail.tools.write import ( - create_draft_email, - update_draft_email, -) - -__all__ = [ - # Read - "list_emails", - "list_emails_by_property", - "list_emails_in_folder", - # Send - "create_and_send_email", - "reply_to_email", - "send_draft_email", - # Write - "create_draft_email", - "update_draft_email", -] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/read.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/read.py deleted file mode 100644 index 58df53cb..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/read.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from arcade_tdk.errors import ToolExecutionError - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_mail._utils import ( - fetch_emails, - prepare_list_emails_request_config, - remove_none_values, -) -from arcade_microsoft.outlook_mail.enums import ( - EmailFilterProperty, - FilterOperator, - WellKnownFolderNames, -) -from arcade_microsoft.outlook_mail.message import Message - - -@tool(requires_auth=Microsoft(scopes=["Mail.Read"])) -async def list_emails( - context: ToolContext, - limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[dict, "A dictionary containing a list of emails"]: - """List emails in the user's mailbox across all folders. - - Since this tool lists email across all folders, it may return sent items, drafts, - and other items that are not in the inbox. - """ - client = get_client(context.get_auth_token_or_empty()) - request_config = prepare_list_emails_request_config(limit) - message_builder = client.me.messages - - response = await fetch_emails(message_builder, pagination_token, request_config) - messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []] - pagination_token = response.odata_next_link - - result = { - "messages": messages, - "num_messages": len(messages), - "pagination_token": pagination_token, - } - result = remove_none_values(result) - return result - - -@tool(requires_auth=Microsoft(scopes=["Mail.Read"])) -async def list_emails_in_folder( - context: ToolContext, - well_known_folder_name: Annotated[ - WellKnownFolderNames | None, - "The name of the folder to list emails from. Defaults to None.", - ] = None, - folder_id: Annotated[ - str | None, - "The ID of the folder to list emails from if the folder is not a well-known folder. " - "Defaults to None.", - ] = None, - limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[ - dict, "A dictionary containing a list of emails and a pagination token, if applicable" -]: - """List the user's emails in the specified folder. - - Exactly one of `well_known_folder_name` or `folder_id` MUST be provided. - """ - if not (bool(well_known_folder_name) ^ bool(folder_id)): - raise ToolExecutionError( - message="Exactly one of `well_known_folder_name` or `folder_id` must be provided." - ) - folder_name = well_known_folder_name.value if well_known_folder_name else folder_id - client = get_client(context.get_auth_token_or_empty()) - request_config = prepare_list_emails_request_config(limit) - message_builder = client.me.mail_folders.by_mail_folder_id(folder_name).messages # type: ignore[arg-type] - - response = await fetch_emails(message_builder, pagination_token, request_config) - messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []] - pagination_token = response.odata_next_link - - result = { - "messages": messages, - "num_messages": len(messages), - "pagination_token": pagination_token, - } - result = remove_none_values(result) - return result - - -@tool(requires_auth=Microsoft(scopes=["Mail.Read"])) -async def list_emails_by_property( - context: ToolContext, - property: Annotated[EmailFilterProperty, "The property to filter the emails by."], # noqa: A002 - operator: Annotated[FilterOperator, "The operator to use for the filter."], - value: Annotated[str, "The value to filter the emails by"], - limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[dict, "A dictionary containing a list of emails"]: - """List emails in the user's mailbox across all folders filtering by a property.""" - client = get_client(context.get_auth_token_or_empty()) - request_config = prepare_list_emails_request_config(limit, property, operator, value) - message_builder = client.me.messages - - response = await fetch_emails(message_builder, pagination_token, request_config) - messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []] - pagination_token = response.odata_next_link - - result = { - "messages": messages, - "num_messages": len(messages), - "pagination_token": pagination_token, - } - result = remove_none_values(result) - return result diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/send.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/send.py deleted file mode 100644 index d40c280f..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/send.py +++ /dev/null @@ -1,94 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from msgraph.generated.users.item.send_mail.send_mail_post_request_body import ( - SendMailPostRequestBody, -) - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_mail._utils import send_reply_email -from arcade_microsoft.outlook_mail.enums import ReplyType -from arcade_microsoft.outlook_mail.message import Message, Recipient - - -@tool(requires_auth=Microsoft(scopes=["Mail.Send"])) -async def create_and_send_email( - context: ToolContext, - subject: Annotated[str, "The subject of the email to create"], - body: Annotated[str, "The body of the email to create"], - to_recipients: Annotated[ - list[str], "The email addresses that will be the recipients of the email" - ], - cc_recipients: Annotated[ - list[str] | None, "The email addresses that will be the CC recipients of the email." - ] = None, - bcc_recipients: Annotated[ - list[str] | None, - "The email addresses that will be the BCC recipients of the email.", - ] = None, -) -> Annotated[dict, "A dictionary containing the created email details"]: - """Create and immediately send a new email in Outlook to the specified recipients""" - client = get_client(context.get_auth_token_or_empty()) - message = Message( - subject=subject, - body=body, - to_recipients=[Recipient(email_address=email) for email in to_recipients], - cc_recipients=[Recipient(email_address=email) for email in cc_recipients or []], - bcc_recipients=[Recipient(email_address=email) for email in bcc_recipients or []], - ).to_sdk() - - send_mail_request_body = SendMailPostRequestBody( - message=message, - save_to_sent_items=True, - ) - - await client.me.send_mail.post(send_mail_request_body) - - return { - "success": True, - "message": "Email sent successfully", - } - - -@tool(requires_auth=Microsoft(scopes=["Mail.Send"])) -async def send_draft_email( - context: ToolContext, - message_id: Annotated[str, "The ID of the draft email to send"], -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """Send an existing draft email in Outlook - - This tool can send any un-sent email: - - draft - - reply-draft - - reply-all draft - - forward draft - """ - client = get_client(context.get_auth_token_or_empty()) - - await client.me.messages.by_message_id(message_id).send.post() - - return { - "success": True, - "message": "Email sent successfully", - } - - -@tool(requires_auth=Microsoft(scopes=["Mail.Send"])) -async def reply_to_email( - context: ToolContext, - message_id: Annotated[str, "The ID of the email to reply to"], - body: Annotated[str, "The body of the reply to the email"], - reply_type: Annotated[ - ReplyType, - f"Specify {ReplyType.REPLY} to reply only to the sender or " - f"{ReplyType.REPLY_ALL} to reply to all recipients. " - f"Defaults to {ReplyType.REPLY}.", - ] = ReplyType.REPLY, -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """Reply to an existing email in Outlook. - - Use this tool to reply to the sender or all recipients of the email. - Specify the reply_type to determine the scope of the reply. - """ - return await send_reply_email(context, message_id, body, reply_type) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/write.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/write.py deleted file mode 100644 index 4af31d38..00000000 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/tools/write.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from arcade_tdk.errors import ToolExecutionError - -from arcade_microsoft.client import get_client -from arcade_microsoft.outlook_mail.message import Message, Recipient - - -@tool(requires_auth=Microsoft(scopes=["Mail.ReadWrite"])) -async def create_draft_email( - context: ToolContext, - subject: Annotated[str, "The subject of the draft email to create"], - body: Annotated[str, "The body of the draft email to create"], - to_recipients: Annotated[ - list[str], "The email addresses that will be the recipients of the draft email" - ], - cc_recipients: Annotated[ - list[str] | None, - "The email addresses that will be the CC recipients of the draft email.", - ] = None, - bcc_recipients: Annotated[ - list[str] | None, - "The email addresses that will be the BCC recipients of the draft email.", - ] = None, -) -> Annotated[dict, "A dictionary containing the created email details"]: - """Compose a new draft email in Outlook""" - client = get_client(context.get_auth_token_or_empty()) - - message = Message( - subject=subject, - body=body, - to_recipients=[Recipient(email_address=email) for email in to_recipients], - cc_recipients=[Recipient(email_address=email) for email in cc_recipients or []], - bcc_recipients=[Recipient(email_address=email) for email in bcc_recipients or []], - is_draft=True, - ).to_sdk() - - response = await client.me.messages.post(message) - draft_message = Message.from_sdk(response).to_dict() # type: ignore[arg-type] - - return draft_message - - -@tool(requires_auth=Microsoft(scopes=["Mail.ReadWrite"])) -async def update_draft_email( - context: ToolContext, - message_id: Annotated[str, "The ID of the draft email to update"], - subject: Annotated[ - str | None, - "The new subject of the draft email. If provided, the existing subject will be overwritten", - ] = None, - body: Annotated[ - str | None, - "The new body of the draft email. If provided, the existing body will be overwritten", - ] = None, - to_add: Annotated[list[str] | None, "Email addresses to add as 'To' recipients."] = None, - to_remove: Annotated[ - list[str] | None, - "Email addresses to remove from the current 'To' recipients.", - ] = None, - cc_add: Annotated[ - list[str] | None, - "Email addresses to add as 'CC' recipients.", - ] = None, - cc_remove: Annotated[ - list[str] | None, - "Email addresses to remove from the current 'CC' recipients.", - ] = None, - bcc_add: Annotated[ - list[str] | None, - "Email addresses to add as 'BCC' recipients.", - ] = None, - bcc_remove: Annotated[ - list[str] | None, - "Email addresses to remove from the current 'BCC' recipients.", - ] = None, -) -> Annotated[dict, "A dictionary containing the updated email details"]: - """Update an existing draft email in Outlook. - - This tool overwrites the subject and body of a draft email (if provided), - and modifies its recipient lists by selectively adding or removing email addresses. - - This tool can update any un-sent email: - - draft - - reply-draft - - reply-all draft - - forward draft - """ - client = get_client(context.get_auth_token_or_empty()) - - # Get the draft email - draft_email_sdk = await client.me.messages.by_message_id(message_id).get() - - if draft_email_sdk is None: - raise ToolExecutionError(message=f"The draft email with ID {message_id} was not found.") - - # Update the draft email - draft_email = Message.from_sdk(draft_email_sdk) - draft_email.subject = subject if subject else draft_email.subject - draft_email.body = body if body else draft_email.body or "" - draft_email.update_recipient_lists( - to_add=to_add, - to_remove=to_remove, - cc_add=cc_add, - cc_remove=cc_remove, - bcc_add=bcc_add, - bcc_remove=bcc_remove, - ) - updated_draft_email = await client.me.messages.by_message_id(message_id).patch( - draft_email.to_sdk() - ) - - return Message.from_sdk(updated_draft_email).to_dict() # type: ignore[arg-type] diff --git a/toolkits/microsoft/evals/outlook_calendar/additional_messages.py b/toolkits/microsoft/evals/outlook_calendar/additional_messages.py deleted file mode 100644 index 9ea6c9a5..00000000 --- a/toolkits/microsoft/evals/outlook_calendar/additional_messages.py +++ /dev/null @@ -1,28 +0,0 @@ -get_event_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-22, Tuesday."}, - {"role": "user", "content": "show me my meetings for today"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_def456", - "type": "function", - "function": { - "name": "Microsoft_ListEventsInTimeRange", - "arguments": '{"start_date_time":"2025-04-22T00:00:00","end_date_time":"2025-04-22T23:59:59"}', # noqa: E501 - }, - } - ], - }, - { - "role": "tool", - "content": '{"events":[{"attendees":[{"email":"john@example.com","name":"John Smith"},{"email":"alice@example.com","name":"Alice Johnson"}],"body":"Quarterly review meeting","end":{"date_time":"2025-04-22T15:00:00.0000000","time_zone":"Pacific Standard Time"},"has_attachments":true,"id":"AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4","importance":"high","is_all_day":false,"is_cancelled":false,"is_online_meeting":true,"is_organizer":true,"location":"Online","online_meeting_url":"https://teams.microsoft.com/l/meetup-join/meeting_id","organizer":{"email":"user@example.com","name":"User Name"},"start":{"date_time":"2025-04-22T14:00:00.0000000","time_zone":"Pacific Standard Time"},"subject":"Q1 Review","web_link":"https://outlook.office365.com/owa/?itemid=AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4&exvsurl=1&path=/calendar/item"}],"num_events":1}', # noqa: E501 - "tool_call_id": "call_def456", - "name": "Microsoft_ListEventsInTimeRange", - }, - { - "role": "assistant", - "content": "You have 1 meeting scheduled for today:\n\n1. **Q1 Review** - Today, 2:00 PM - 3:00 PM\n Location: Online\n Attendees: John Smith, Alice Johnson\n This is a high importance meeting with attachments.", # noqa: E501 - }, -] diff --git a/toolkits/microsoft/evals/outlook_calendar/eval_create_event.py b/toolkits/microsoft/evals/outlook_calendar/eval_create_event.py deleted file mode 100644 index 91006617..00000000 --- a/toolkits/microsoft/evals/outlook_calendar/eval_create_event.py +++ /dev/null @@ -1,94 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_microsoft.outlook_calendar import create_event - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(create_event, "Microsoft") - - -@tool_eval() -def outlook_calendar_create_event_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Calendar create event tool.""" - suite = EvalSuite( - name="Outlook Calendar Create Event Evaluation", - system_message=( - "You are an AI that has access to tools to view and manage calendar events. " - "The current time date and time is April 25, 2025, 5:18 PM PST." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create virtual event", - user_message=( - "schedule a virtual team meeting 'Standup' tomorrow at 3pm for 1 hour. " - "john@example.com and sarah@example.com need to be there" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_event, - args={ - "subject": "Standup", - "start_date_time": "2025-04-26T15:00:00", - "end_date_time": "2025-04-26T16:00:00", - "attendee_emails": ["john@example.com", "sarah@example.com"], - "is_online_meeting": True, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 5), - BinaryCritic(critic_field="start_date_time", weight=1 / 5), - BinaryCritic(critic_field="end_date_time", weight=1 / 5), - BinaryCritic(critic_field="attendee_emails", weight=1 / 5), - BinaryCritic(critic_field="is_online_meeting", weight=1 / 5), - ], - ) - - suite.add_case( - name="Create event with physical location and virtual link", - user_message=( - "schedule a team meeting 'All hands' tomorrow at 3pm for 1 hour. " - "john@example.com and sarah@example.com need to be there. " - "The meeting will be in Conference Room A, but there will be a virtual link " - "for those who cannot attend in person." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_event, - args={ - "subject": "All hands", - "start_date_time": "2025-04-26T15:00:00", - "end_date_time": "2025-04-26T16:00:00", - "location": "Conference Room A", - "attendee_emails": ["john@example.com", "sarah@example.com"], - "is_online_meeting": True, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 6), - BinaryCritic(critic_field="start_date_time", weight=1 / 6), - BinaryCritic(critic_field="end_date_time", weight=1 / 6), - SimilarityCritic(critic_field="location", weight=1 / 6), - BinaryCritic(critic_field="attendee_emails", weight=1 / 6), - BinaryCritic(critic_field="is_online_meeting", weight=1 / 6), - ], - ) - - return suite diff --git a/toolkits/microsoft/evals/outlook_calendar/eval_get_event.py b/toolkits/microsoft/evals/outlook_calendar/eval_get_event.py deleted file mode 100644 index 1b657bb1..00000000 --- a/toolkits/microsoft/evals/outlook_calendar/eval_get_event.py +++ /dev/null @@ -1,53 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_microsoft.outlook_calendar import get_event -from evals.outlook_calendar.additional_messages import get_event_additional_messages - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(get_event, "Microsoft") - - -@tool_eval() -def outlook_calendar_get_event_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Calendar get event tool.""" - suite = EvalSuite( - name="Outlook Calendar Get Event Evaluation", - system_message=( - "You are an AI that has access to tools to view and manage calendar events. " - "The current time date and time is April 25, 2025, 5:18 PM PST." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get event by id after listing events", - user_message="tell me more about the first event", - expected_tool_calls=[ - ExpectedToolCall( - func=get_event, - args={ - "event_id": "AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", # noqa: E501 - }, - ) - ], - critics=[ - BinaryCritic(critic_field="event_id", weight=1.0), - ], - additional_messages=get_event_additional_messages, - ) - - return suite diff --git a/toolkits/microsoft/evals/outlook_calendar/eval_list_events_in_time_range.py b/toolkits/microsoft/evals/outlook_calendar/eval_list_events_in_time_range.py deleted file mode 100644 index b6b9ba43..00000000 --- a/toolkits/microsoft/evals/outlook_calendar/eval_list_events_in_time_range.py +++ /dev/null @@ -1,77 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_microsoft.outlook_calendar import list_events_in_time_range - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(list_events_in_time_range, "Microsoft") - - -@tool_eval() -def outlook_calendar_list_events_in_time_range_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Calendar list events tool.""" - suite = EvalSuite( - name="Outlook Calendar List Events Evaluation", - system_message=( - "You are an AI that has access to tools to view and manage calendar events. " - "The current time date and time is Friday, April 25, 2025, 5:18 PM PST." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List events in time range", - user_message="what are my meetings on monday", - expected_tool_calls=[ - ExpectedToolCall( - func=list_events_in_time_range, - args={ - "start_date_time": "2025-04-28T00:00:00", - "end_date_time": "2025-04-28T23:59:59", - }, - ) - ], - critics=[ - DatetimeCritic(critic_field="start_date_time", weight=0.5), - DatetimeCritic(critic_field="end_date_time", weight=0.5), - ], - ) - - suite.add_case( - name="List events in time range with limit", - user_message=( - "get my first 10 meetings for the next work-week through thursday, " - "starting tuesday (mon is holiday)" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=list_events_in_time_range, - args={ - "start_date_time": "2025-04-29T00:00:00", - "end_date_time": "2025-05-01T23:59:59", - "limit": 10, - }, - ) - ], - critics=[ - DatetimeCritic(critic_field="start_date_time", weight=0.3), - DatetimeCritic(critic_field="end_date_time", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.4), - ], - ) - - return suite diff --git a/toolkits/microsoft/evals/outlook_mail/additional_messages.py b/toolkits/microsoft/evals/outlook_mail/additional_messages.py deleted file mode 100644 index 547b3109..00000000 --- a/toolkits/microsoft/evals/outlook_mail/additional_messages.py +++ /dev/null @@ -1,83 +0,0 @@ -update_draft_email_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-22, Tuesday."}, - { - "role": "user", - "content": '"create a new draft email with subject \'Hello friends\' and body "\n"\'I\'ve gathered you all here to celebrate the launch of the new Arcade platform."\n "address it to e@arcade.dev and z@arcade.dev. also carbon copy to j@arcade.dev, "\n"f@arcade.dev, k@arcade.dev and finally to m@arcade.dev. also bcc to r@arcade.dev"', # noqa: E501 - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_lKw4S01FGe03oZeuW25roepy", - "type": "function", - "function": { - "name": "Microsoft_CreateDraftEmail", - "arguments": '{"subject":"Hello friends","body":"I\'ve gathered you all here to celebrate the launch of the new Arcade platform.","to_recipients":["e@arcade.dev","z@arcade.dev"],"cc_recipients":["j@arcade.dev","f@arcade.dev","k@arcade.dev","m@arcade.dev"],"bcc_recipients":["r@arcade.dev"]}', # noqa: E501 - }, - } - ], - }, - { - "role": "tool", - "content": '{"bcc_recipients":[{"email_address":"r@arcade.dev","name":"r@arcade.dev"}],"body":"I\'ve gathered you all here to celebrate the launch of the new Arcade platform.","cc_recipients":[{"email_address":"j@arcade.dev","name":"j@arcade.dev"},{"email_address":"f@arcade.dev","name":"f@arcade.dev"},{"email_address":"k@arcade.dev","name":"k@arcade.dev"},{"email_address":"m@arcade.dev","name":"m@arcade.dev"}],"conversation_id":"AQQkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoAEAAskq2oM-moTbt3gDT_yK0e","conversation_index":"AQHbs6c0LJKtqDP5qE27d4A0/sitHg==","flag":{"due_date_time":"","flag_status":"notFlagged"},"from":{"email_address":"","name":""},"has_attachments":false,"importance":"normal","is_draft":true,"is_read":true,"message_id":"AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA","received_date_time":"2025-04-22T16:54:25+00:00","reply_to":[],"subject":"Hello friends","to_recipients":[{"email_address":"e@arcade.dev","name":"e@arcade.dev"},{"email_address":"z@arcade.dev","name":"z@arcade.dev"}],"web_link":"https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDwAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAAC%2FdpvAAAA\\u0026exvsurl=1\\u0026viewmodel=ReadMessageItem"}', # noqa: E501 - "tool_call_id": "call_lKw4S01FGe03oZeuW25roepy", - "name": "Microsoft_CreateDraftEmail", - }, - { - "role": "assistant", - "content": 'I have created a draft email with the subject "Hello friends" addressed to the specified recipients. You can view and edit the draft through [this link](https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDwAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAAC%2FdpvAAAA&exvsurl=1&viewmodel=ReadMessageItem).', # noqa: E501 - }, -] - -list_emails_with_pagination_token_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-21, Monday."}, - {"role": "user", "content": "get one email"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "type": "function", - "function": {"name": "Microsoft_ListEmails", "arguments": '{"limit":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"messages":[{"bcc_recipients":[],"body":"Microsoft account New app(s) have access to your data Arcade","cc_recipients":["e@arcade.dev],"conversation_id":"AQQkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoAEABOD15A17tWSaVHkmjhko1R","conversation_index":"AQHbsuDd Tg9eQNe7VkmlR5Jo4ZKNUQ==","flag":{"due_date_time":"","flag_status":"notFlagged"},"from":{"email_address":"account-security@accountprotect ion.microsoft.com","name":"Microsoft account team"},"has_attachments":false,"importance":"normal","is_draft":false,"is_read":true,"message_id":"AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA","received_date_time":"2025-04-21T17:14:39+00:00", "reply_to":[],"subject":"New app(s) connected to your Microsoft account","to_recipients":[{"email_address":"ericarcade@outlook.com","name":"ericarcade@outlook.com"}],"web_link":"https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAABc%2BezAAAA\\u0026exvsurl=1\\u0026viewmodel=ReadMessageItem"}],"num_messages":1,"pagination_token":"https://graph.microsoft.com/v1.0/me/messages?%24count=true&%24orderby=receivedDateTime+DESC&%24select=bccRecipients%2cbody%2cccRecipients%2cconversationId%2cconversationIndex%2cflag%2cfrom%2chasAttachments%2cimportance%2cisDraft%2cisRead%2creceivedDateTime%2creplyTo%2csubject%2ctoRecipients%2cwebLink&%24top=1&%24skip=1"}', # noqa: E501 - "tool_call_id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "name": "Microsoft_ListEmails", - }, - { - "role": "assistant", - "content": "Here is the most recent email you received:\n\n- **From:** Microsoft account team (account-security@accountprotection.microsoft.com)\n- **To:** e@outlook.com\n- **Subject:** New app(s) connected to your Microsoft account\n- **Received Date:** April 21, 2025\n- **Body:**\n ```\n Microsoft account\n\n New app(s) have access to your data Arcade connected to the Microsoft account *@outlook.com.```\n- **Link to email:** [Read in Outlook](https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAABc%2BezAAAA&exvsurl=1&viewmodel=ReadMessageItem)", # noqa: E501 - }, -] - -list_emails_with_pagination_token_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-21, Monday."}, - {"role": "user", "content": "get one email"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "type": "function", - "function": {"name": "Microsoft_ListEmails", "arguments": '{"limit":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"messages":[{"bcc_recipients":[],"body":"Microsoft account New app(s) have access to your data Arcade","cc_recipients":[],"conversation_id":"AQQkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoAEABOD15A17tWSaVHkmjhko1R","conversation_index":"AQHbsuDdTg9eQNe7VkmlR5Jo4ZKNUQ==","flag":{"due_date_time":"","flag_status":"notFlagged"},"from":{"email_address":"account-security-noreply@accountprotection.microsoft.com","name":"Microsoft account team"},"has_attachments":false,"importance":"normal","is_draft":false,"is_read":true,"message_id":"AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA","received_date_time":"2025-04-21T17:14:39+00:00", "reply_to":[],"subject":"New app(s) connected to your Microsoft account","to_recipients":[{"email_address":"ericarcade@outlook.com","name":"ericarcade@outlook.com"}],"web_link":"https://outlook.live.com/owa/?I temID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAA Bc%2BezAAAA\\u0026exvsurl=1\\u0026viewmodel=ReadMessageItem"}],"num_messages":1,"pagination_token":"https://graph.microsoft.com/v1.0/me/messages?%24count=true&%24orderby=receivedDateTime+DESC&%24select=bccRecipients%2cbody%2cccRecipients%2cconversationId%2cconversationIndex%2cflag%2cfrom%2chasAttachments%2cimportance%2cisDraft%2cisRead%2creceivedDateTime%2creplyTo%2csubject%2ctoRecipients%2cwebLink&%24top=1&%24skip=1"}', # noqa: E501 - "tool_call_id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "name": "Microsoft_ListEmails", - }, - { - "role": "assistant", - "content": "Here is the most recent email you received:\n\n- **From:** Microsoft account team (account-security-noreply@accountprotection.microsoft.com)\n- **To:** e@outlook.com\n- **Subject:** New app(s) connected to your Microsoft account\n- **Received Date:** April 21, 2025\n- **Body:**\n ```\n Microsoft account\n\n New app(s) have access to your data Arcade connected to the Microsoft account *@outlook.com.```\n- **Link to email:** [Read in Outlook](https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAABc%2BezAAAA&exvsurl=1&viewmodel=ReadMessageItem)", # noqa: E501 - }, -] diff --git a/toolkits/microsoft/evals/outlook_mail/eval_read.py b/toolkits/microsoft/evals/outlook_mail/eval_read.py deleted file mode 100644 index e8de7cfc..00000000 --- a/toolkits/microsoft/evals/outlook_mail/eval_read.py +++ /dev/null @@ -1,210 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_microsoft.outlook_mail import ( - list_emails, - list_emails_by_property, - list_emails_in_folder, -) -from arcade_microsoft.outlook_mail.enums import WellKnownFolderNames -from evals.outlook_mail.additional_messages import ( - list_emails_with_pagination_token_additional_messages, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_tool(list_emails, "Microsoft") -catalog.add_tool(list_emails_in_folder, "Microsoft") -catalog.add_tool(list_emails_by_property, "Microsoft") - - -@tool_eval() -def outlook_mail_read_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Tools Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List emails in mailbox", - user_message="get my five most recent emails", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails, - args={"limit": 5}, - ) - ], - critics=[ - BinaryCritic(critic_field="limit", weight=1.0), - ], - ) - - suite.add_case( - name="List emails in mailbox with pagination token", - user_message="get the next 3", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails, - args={ - "limit": 3, - "pagination_token": "https://graph.microsoft.com/v1.0/me/messages?%24count=true&%24orderby=receivedDateTime+DESC&%24select=bccRecipients%2cbody%2cccRecipients%2cconversationId%2cconversationIndex%2cflag%2cfrom%2chasAttachments%2cimportance%2cisDraft%2cisRead%2creceivedDateTime%2creplyTo%2csubject%2ctoRecipients%2cwebLink&%24top=1&%24skip=1", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="pagination_token", weight=0.8), - ], - additional_messages=list_emails_with_pagination_token_additional_messages, - ) - - suite.add_case( - name="List emails in well-known folder", - user_message="summarize my inbox", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_in_folder, - args={ - "well_known_folder_name": WellKnownFolderNames.INBOX, - "folder_id": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="well_known_folder_name", weight=0.5), - BinaryCritic(critic_field="folder_id", weight=0.5), - ], - ) - - suite.add_case( - name="List emails in folder by id", - user_message="get 5 from folder AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoALgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", # noqa: E501 - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_in_folder, - args={ - "well_known_folder_name": None, - "folder_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoALgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", # noqa: E501 - "limit": 5, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="well_known_folder_name", weight=0.4), - BinaryCritic(critic_field="folder_id", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.2), - ], - ) - - return suite - - -@tool_eval() -def outlook_mail_list_emails_by_property_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Tools Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List emails by subject", - user_message="get all emails that talk about The Green Bottle", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "subject", - "operator": "contains", - "value": "The Green Bottle", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - BinaryCritic(critic_field="value", weight=1 / 3), - ], - ) - - suite.extend_case( - name="List emails by thread", - user_message="get all emails in my thread 1k2jh324h92f24krjb34mtb43kj4bk3tmn34b3k4nnm3tb34mntb34mntb3m4bt3mn4bt3mn4btmnb34tmnb3t4mnb==34tkjh", # noqa: E501 - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "conversationId", - "operator": "eq", - "value": "1k2jh324h92f24krjb34mtb43kj4bk3tmn34b3k4nnm3tb34mntb34mntb3m4bt3mn4bt3mn4btmnb34tmnb3t4mnb==34tkjh", # noqa: E501 - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - BinaryCritic(critic_field="value", weight=1 / 3), - ], - ) - - suite.extend_case( - name="List emails by date", - user_message="Today is May 1st 2025. Get all emails that are a year old or older", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "receivedDateTime", - "operator": "le", - "value": "2024-05-01T00:00:00Z", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - DatetimeCritic(critic_field="value", weight=1 / 3, tolerance=timedelta(days=1)), - ], - ) - - suite.extend_case( - name="List emails by sender", - user_message="get all of my correspondence with the folks over at arcade.dev", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "sender/emailAddress/address", - "operator": "contains", - "value": "arcade.dev", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - BinaryCritic(critic_field="value", weight=1 / 3), - ], - ) - - return suite diff --git a/toolkits/microsoft/evals/outlook_mail/eval_send.py b/toolkits/microsoft/evals/outlook_mail/eval_send.py deleted file mode 100644 index 4f136f7a..00000000 --- a/toolkits/microsoft/evals/outlook_mail/eval_send.py +++ /dev/null @@ -1,127 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_microsoft.outlook_mail import ( - create_and_send_email, - reply_to_email, - send_draft_email, -) -from arcade_microsoft.outlook_mail.enums import ReplyType -from evals.outlook_mail.additional_messages import ( - list_emails_with_pagination_token_additional_messages, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_tool(create_and_send_email, "Microsoft") -catalog.add_tool(send_draft_email, "Microsoft") -catalog.add_tool(reply_to_email, "Microsoft") - - -@tool_eval() -def outlook_mail_send_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Send Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create draft email", - user_message=( - "send an email to j@arcade.dev and e@arcade.dev. Title it 'Hello friends' and have it " - "say 'I've gathered you all here to celebrate the launch of the new Arcade platform.'" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_and_send_email, - args={ - "subject": "Hello friends", - "body": "I've gathered you all here to celebrate the launch of the new Arcade platform.", # noqa: E501 - "to_recipients": ["j@arcade.dev", "e@arcade.dev"], - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=0.3), - SimilarityCritic(critic_field="body", weight=0.3), - BinaryCritic(critic_field="to_recipients", weight=0.4), - ], - ) - - suite.add_case( - name="Update draft email", - user_message=( - "forward the draft AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA " # noqa: E501 - ), - expected_tool_calls=[ - ExpectedToolCall( - func=send_draft_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA", # noqa: E501 - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1), - ], - ) - - suite.add_case( - name="Reply all to email", - user_message=("Reply to everyone - 'sounds good to me'"), - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA", # noqa: E501 - "body": "sounds good to me", - "reply_type": ReplyType.REPLY_ALL, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1 / 3), - SimilarityCritic(critic_field="body", weight=1 / 3), - BinaryCritic(critic_field="reply_type", weight=1 / 3), - ], - additional_messages=list_emails_with_pagination_token_additional_messages, - ) - - suite.add_case( - name="Reply to email", - user_message=("Reply to the account security team - 'sounds good to me'"), - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA", # noqa: E501 - "body": "sounds good to me", - "reply_type": ReplyType.REPLY, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1 / 3), - SimilarityCritic(critic_field="body", weight=1 / 3), - BinaryCritic(critic_field="reply_type", weight=1 / 3), - ], - additional_messages=list_emails_with_pagination_token_additional_messages, - ) - - return suite diff --git a/toolkits/microsoft/evals/outlook_mail/eval_write.py b/toolkits/microsoft/evals/outlook_mail/eval_write.py deleted file mode 100644 index b7cc7607..00000000 --- a/toolkits/microsoft/evals/outlook_mail/eval_write.py +++ /dev/null @@ -1,104 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_microsoft.outlook_mail import create_draft_email, update_draft_email -from evals.outlook_mail.additional_messages import ( - update_draft_email_additional_messages, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_tool(create_draft_email, "Microsoft") -catalog.add_tool(update_draft_email, "Microsoft") - - -@tool_eval() -def outlook_mail_write_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Write Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create draft email", - user_message=( - "create a new draft email with subject 'Hello friends' and body " - "'I've gathered you all here to celebrate the launch of the new Arcade platform." - "address it to e@arcade.dev and z@arcade.dev. also carbon copy to j@arcade.dev, " - "f@arcade.dev, k@arcade.dev and finally to m@arcade.dev. also bcc to r@arcade.dev" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_draft_email, - args={ - "subject": "Hello friends", - "body": "I've gathered you all here to celebrate the launch of the new Arcade platform.", # noqa: E501 - "to_recipients": ["e@arcade.dev", "z@arcade.dev"], - "cc_recipients": [ - "j@arcade.dev", - "f@arcade.dev", - "k@arcade.dev", - "m@arcade.dev", - ], - "bcc_recipients": ["r@arcade.dev"], - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=0.2), - SimilarityCritic(critic_field="body", weight=0.2), - BinaryCritic(critic_field="to_recipients", weight=0.2), - BinaryCritic(critic_field="cc_recipients", weight=0.2), - BinaryCritic(critic_field="bcc_recipients", weight=0.2), - ], - ) - - suite.add_case( - name="Update draft email", - user_message=( - "oh wait i think i messed up on some emails. I meant 'z', not 'e'. " - "Also, I forgot to bcc y@arcade.dev. Also, replace the period with an " - "exclamation point since I want to convey excitement. Oh I almost forgot, " - "Don't cc anyone." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=update_draft_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA", # noqa: E501 - "body": "I've gathered you all here to celebrate the launch of the new Arcade platform!", # noqa: E501 - "to_add": ["z@arcade.dev"], - "to_remove": ["e@arcade.dev"], - "cc_remove": ["j@arcade.dev", "f@arcade.dev", "k@arcade.dev", "m@arcade.dev"], - "bcc_add": ["y@arcade.dev"], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1 / 6), - BinaryCritic(critic_field="body", weight=1 / 6), - BinaryCritic(critic_field="to_add", weight=1 / 6), - BinaryCritic(critic_field="to_remove", weight=1 / 6), - BinaryCritic(critic_field="cc_remove", weight=1 / 6), - BinaryCritic(critic_field="bcc_add", weight=1 / 6), - ], - additional_messages=update_draft_email_additional_messages, - ) - - return suite diff --git a/toolkits/microsoft/pyproject.toml b/toolkits/microsoft/pyproject.toml deleted file mode 100644 index 6ee04f51..00000000 --- a/toolkits/microsoft/pyproject.toml +++ /dev/null @@ -1,58 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_microsoft" -version = "0.2.3" -description = "Arcade.dev LLM tools for Outlook Mail" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "msgraph-sdk>=1.28.0,<2.0.0", - "beautifulsoup4>=4.10.0,<5.0.0", - "pytz>=2024.2,<2025.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_microsoft/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_microsoft",] diff --git a/toolkits/microsoft/tests/__init__.py b/toolkits/microsoft/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/microsoft/tests/outlook_calendar/__init__.py b/toolkits/microsoft/tests/outlook_calendar/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/microsoft/tests/outlook_calendar/test_models.py b/toolkits/microsoft/tests/outlook_calendar/test_models.py deleted file mode 100644 index c21b7f05..00000000 --- a/toolkits/microsoft/tests/outlook_calendar/test_models.py +++ /dev/null @@ -1,385 +0,0 @@ -import pytest -from msgraph.generated.models.attendee import Attendee as GraphAttendee -from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.event import Event as GraphEvent -from msgraph.generated.models.location import Location as GraphLocation -from msgraph.generated.models.recipient import Recipient as GraphRecipient -from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus -from msgraph.generated.models.response_type import ResponseType as GraphResponseType - -from arcade_microsoft.outlook_calendar.models import ( - Attendee, - DateTimeTimeZone, - Event, - Organizer, - ResponseStatus, -) - - -class DummyBody: - def __init__(self, content): - self.content = content - - -class DummyEventType: - def __init__(self, value): - self.value = value - - -class DummyImportance: - def __init__(self, value): - self.value = value - - -class DummyFreeBusyStatus: - def __init__(self, value): - self.value = value - - -class DummyResponseType: - def __init__(self, value): - self.value = value - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - {"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"}, - {"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"}, - ), - ( - {"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"}, - {"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"}, - ), - ( - {"name": None, "address": None, "response": "none"}, - {"name": "", "address": "", "response": "none"}, - ), - ], -) -def test_attendee_conversion(input_data, expected): - sdk_attendee = GraphAttendee() - sdk_attendee.email_address = GraphEmailAddress() - sdk_attendee.email_address.name = input_data["name"] - sdk_attendee.email_address.address = input_data["address"] - sdk_attendee.status = GraphResponseStatus() - sdk_attendee.status.response = GraphResponseType(input_data["response"]) - - # Test from_sdk method - attendee = Attendee.from_sdk(sdk_attendee) - assert attendee.name == expected["name"] - assert attendee.address == expected["address"] - assert attendee.response == expected["response"] - - # Test to_dict method - dict_result = attendee.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = attendee.to_sdk() - assert sdk_result.email_address.name == expected["name"] - assert sdk_result.email_address.address == expected["address"] - assert sdk_result.status.response == GraphResponseType(expected["response"]) - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - {"name": "Jane Smith", "address": "jane.smith@example.com"}, - {"name": "Jane Smith", "address": "jane.smith@example.com"}, - ), - ( - {"name": "", "address": "unknown@example.com"}, - {"name": "", "address": "unknown@example.com"}, - ), - ({"name": None, "address": None}, {"name": "", "address": ""}), - ], -) -def test_organizer_conversion(input_data, expected): - sdk_organizer = GraphRecipient() - sdk_organizer.email_address = GraphEmailAddress() - sdk_organizer.email_address.name = input_data["name"] - sdk_organizer.email_address.address = input_data["address"] - - # Test from_sdk method - organizer = Organizer.from_sdk(sdk_organizer) - assert organizer.name == expected["name"] - assert organizer.address == expected["address"] - - # Test to_dict method - dict_result = organizer.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = organizer.to_sdk() - assert sdk_result.email_address.name == expected["name"] - assert sdk_result.email_address.address == expected["address"] - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - {"date_time": "2023-05-10T14:00:00", "time_zone": "Pacific Standard Time"}, - {"dateTime": "2023-05-10T14:00:00", "timeZone": "Pacific Standard Time"}, - ), - ({"date_time": "", "time_zone": "UTC"}, {"dateTime": "", "timeZone": "UTC"}), - ({"date_time": None, "time_zone": None}, {"dateTime": "", "timeZone": ""}), - ], -) -def test_date_time_time_zone_conversion(input_data, expected): - sdk_date_time = GraphDateTimeTimeZone() - sdk_date_time.date_time = input_data["date_time"] - sdk_date_time.time_zone = input_data["time_zone"] - - # Test from_sdk method - date_time_tz = DateTimeTimeZone.from_sdk(sdk_date_time) - assert date_time_tz.date_time == (input_data["date_time"] or "") - assert date_time_tz.time_zone == (input_data["time_zone"] or "") - - # Test to_dict method - dict_result = date_time_tz.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = date_time_tz.to_sdk() - assert sdk_result.date_time == date_time_tz.date_time - assert sdk_result.time_zone == date_time_tz.time_zone - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ({"response": "accepted"}, {"response": "accepted"}), - ({"response": "declined"}, {"response": "declined"}), - ({"response": "none"}, {"response": "none"}), - ], -) -def test_response_status_conversion(input_data, expected): - sdk_response_status = GraphResponseStatus() - sdk_response_status.response = GraphResponseType(input_data["response"]) - - # Test from_sdk method - response_status = ResponseStatus.from_sdk(sdk_response_status) - assert response_status.response == expected["response"] - - # Test to_dict method - dict_result = response_status.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = response_status.to_sdk() - assert sdk_result.response == GraphResponseType(expected["response"]) - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - { - "body_content": "

Team Meeting

", - "has_attachments": True, - "importance": "high", - "is_all_day": False, - "is_cancelled": False, - "is_draft": False, - "is_online_meeting": True, - "is_organizer": True, - "location": "Conference Room A", - "online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123", - "id": "event-123", - "show_as": "busy", - "subject": "Weekly Team Sync", - "type": "singleInstance", - "web_link": "https://outlook.office.com/calendar/item/123", - "attendees": [ - {"name": "Alice", "address": "alice@example.com", "response": "accepted"}, - { - "name": "Bob", - "address": "bob@example.com", - "response": "tentativelyAccepted", - }, - ], - "organizer": {"name": "Manager", "address": "manager@example.com"}, - "start": {"date_time": "2023-05-10T10:00:00", "time_zone": "Eastern Standard Time"}, - "end": {"date_time": "2023-05-10T11:00:00", "time_zone": "Eastern Standard Time"}, - "response_status": {"response": "accepted"}, - }, - { - "body": "Team Meeting", - "has_attachments": True, - "importance": "high", - "is_all_day": False, - "is_cancelled": False, - "is_draft": False, - "is_online_meeting": True, - "is_organizer": True, - "location": "Conference Room A", - "online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123", - "id": "event-123", - "show_as": "busy", - "subject": "Weekly Team Sync", - "type": "singleInstance", - "web_link": "https://outlook.office.com/calendar/item/123", - "event_id": "event-123", - "attendees": [ - {"name": "Alice", "address": "alice@example.com", "response": "accepted"}, - { - "name": "Bob", - "address": "bob@example.com", - "response": "tentativelyAccepted", - }, - ], - "organizer": {"name": "Manager", "address": "manager@example.com"}, - "start": {"dateTime": "2023-05-10T10:00:00", "timeZone": "Eastern Standard Time"}, - "end": {"dateTime": "2023-05-10T11:00:00", "timeZone": "Eastern Standard Time"}, - "response_status": {"response": "accepted"}, - }, - ), - ( - { - "body_content": "

All day event description

", - "has_attachments": False, - "importance": "normal", - "is_all_day": True, - "is_cancelled": True, - "is_draft": True, - "is_online_meeting": False, - "is_organizer": False, - "location": "", - "online_meeting_url": "", - "id": "event-456", - "show_as": "free", - "subject": "Company Holiday", - "type": "occurrence", - "web_link": "https://outlook.office.com/calendar/item/456", - "attendees": [], - "organizer": {"name": "HR Department", "address": "hr@example.com"}, - "start": {"date_time": "2023-07-04T00:00:00", "time_zone": "UTC"}, - "end": {"date_time": "2023-07-05T00:00:00", "time_zone": "UTC"}, - "response_status": {"response": "notResponded"}, - }, - { - "body": "All day event description", - "has_attachments": False, - "importance": "normal", - "is_all_day": True, - "is_cancelled": True, - "is_draft": True, - "is_online_meeting": False, - "is_organizer": False, - "location": "", - "online_meeting_url": "", - "id": "event-456", - "show_as": "free", - "subject": "Company Holiday", - "type": "occurrence", - "web_link": "https://outlook.office.com/calendar/item/456", - "event_id": "event-456", - "attendees": [], - "organizer": {"name": "HR Department", "address": "hr@example.com"}, - "start": {"dateTime": "2023-07-04T00:00:00", "timeZone": "UTC"}, - "end": {"dateTime": "2023-07-05T00:00:00", "timeZone": "UTC"}, - "response_status": {"response": "notResponded"}, - }, - ), - ], -) -def test_event_conversion(input_data, expected): - def make_graph_attendee(attendee_data): - attendee = GraphAttendee() - attendee.email_address = GraphEmailAddress() - attendee.email_address.name = attendee_data.get("name", "") - attendee.email_address.address = attendee_data.get("address", "") - attendee.status = GraphResponseStatus() - attendee.status.response = GraphResponseType(attendee_data.get("response", "")) - return attendee - - def make_graph_organizer(organizer_data): - organizer = GraphRecipient() - organizer.email_address = GraphEmailAddress() - organizer.email_address.name = organizer_data.get("name", "") - organizer.email_address.address = organizer_data.get("address", "") - return organizer - - def make_graph_date_time(date_time_data): - date_time = GraphDateTimeTimeZone() - date_time.date_time = date_time_data.get("date_time", "") - date_time.time_zone = date_time_data.get("time_zone", "") - return date_time - - sdk_event = GraphEvent() - sdk_event.body = DummyBody(input_data["body_content"]) - sdk_event.has_attachments = input_data["has_attachments"] - sdk_event.importance = DummyImportance(input_data["importance"]) - sdk_event.is_all_day = input_data["is_all_day"] - sdk_event.is_cancelled = input_data["is_cancelled"] - sdk_event.is_draft = input_data["is_draft"] - sdk_event.is_online_meeting = input_data["is_online_meeting"] - sdk_event.is_organizer = input_data["is_organizer"] - sdk_event.location = GraphLocation(display_name=input_data["location"]) - sdk_event.online_meeting_url = input_data["online_meeting_url"] - sdk_event.id = input_data["id"] - sdk_event.show_as = DummyFreeBusyStatus(input_data["show_as"]) - sdk_event.subject = input_data["subject"] - sdk_event.type = DummyEventType(input_data["type"]) - sdk_event.web_link = input_data["web_link"] - sdk_event.attendees = [make_graph_attendee(a) for a in input_data["attendees"]] - sdk_event.organizer = make_graph_organizer(input_data["organizer"]) - sdk_event.start = make_graph_date_time(input_data["start"]) - sdk_event.end = make_graph_date_time(input_data["end"]) - sdk_event.response_status = GraphResponseStatus() - sdk_event.response_status.response = GraphResponseType( - input_data["response_status"]["response"] - ) - - # Test from_sdk method - event = Event.from_sdk(sdk_event) - assert event.body == expected["body"] - assert event.has_attachments == expected["has_attachments"] - assert event.importance == expected["importance"] - assert event.is_all_day == expected["is_all_day"] - assert event.is_cancelled == expected["is_cancelled"] - assert event.is_draft == expected["is_draft"] - assert event.is_online_meeting == expected["is_online_meeting"] - assert event.is_organizer == expected["is_organizer"] - assert event.location == expected["location"] - assert event.online_meeting_url == expected["online_meeting_url"] - assert event.id == expected["id"] - assert event.show_as == expected["show_as"] - assert event.subject == expected["subject"] - assert event.type == expected["type"] - assert event.web_link == expected["web_link"] - assert event.event_id == expected["event_id"] - assert len(event.attendees) == len(expected["attendees"]) - for i, attendee in enumerate(event.attendees): - assert attendee.name == expected["attendees"][i]["name"] - assert attendee.address == expected["attendees"][i]["address"] - assert attendee.response == expected["attendees"][i]["response"] - if event.start: - assert event.start.date_time == expected["start"]["dateTime"] - assert event.start.time_zone == expected["start"]["timeZone"] - if event.end: - assert event.end.date_time == expected["end"]["dateTime"] - assert event.end.time_zone == expected["end"]["timeZone"] - if event.organizer: - assert event.organizer.name == expected["organizer"]["name"] - assert event.organizer.address == expected["organizer"]["address"] - if event.response_status: - assert event.response_status.response == expected["response_status"]["response"] - - # Test to_dict method - dict_result = event.to_dict() - assert dict_result["body"] == expected["body"] - assert dict_result["subject"] == expected["subject"] - assert dict_result["event_id"] == expected["event_id"] - - # Test to_sdk method - sdk_result = event.to_sdk() - assert sdk_result.subject == event.subject - assert sdk_result.is_all_day == event.is_all_day - assert sdk_result.location.display_name == event.location - assert len(sdk_result.attendees) == len(event.attendees) diff --git a/toolkits/microsoft/tests/outlook_calendar/test_utils.py b/toolkits/microsoft/tests/outlook_calendar/test_utils.py deleted file mode 100644 index 8f1af403..00000000 --- a/toolkits/microsoft/tests/outlook_calendar/test_utils.py +++ /dev/null @@ -1,118 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_microsoft.outlook_calendar._utils import ( - convert_timezone_to_offset, - is_valid_email, - remove_timezone_offset, - replace_timezone_offset, - validate_date_times, - validate_emails, -) - - -@pytest.mark.parametrize( - "start_date_time, end_date_time, error_type", - [ - ( - "2026-01-01T10:00:00", - "2026-01-01T17:00:00", - None, - ), - # end_date_time before start_date_time - ( - "2026-01-01T10:00:00", - "2026-01-01T10:00:00", - ToolExecutionError, - ), - # end_date_time before start_date_time because timezone offset is ignored - ( - "2026-01-01T10:00:00-07:00", - "2026-01-01T09:00:00-08:00", - ToolExecutionError, - ), - # not ISO 8601 format - ( - "20260101T10:00:00", - "2026-01-0109:00:00", - ValueError, - ), - ], -) -def test_validate_date_times(start_date_time, end_date_time, error_type): - if error_type: - with pytest.raises(error_type): - validate_date_times(start_date_time, end_date_time) - else: - validate_date_times(start_date_time, end_date_time) - - -@pytest.mark.parametrize( - "emails, expect_error", - [ - (["test@test.com"], False), - (["test@test.com", "test@test.com.au"], False), - (["test@test.com", "test@test.com.au."], True), - (["#$&*@test.com"], True), - ], -) -def test_validate_emails(emails, expect_error): - if expect_error: - with pytest.raises(ToolExecutionError): - validate_emails(emails) - else: - validate_emails(emails) - - -@pytest.mark.parametrize( - "email, is_valid", - [ - ("test@test.com", True), - ("test@test", False), - ("test@test.com.au", True), - ("test@test.com.au.", False), - ], -) -def test_is_valid_email(email, is_valid): - assert is_valid_email(email) == is_valid - - -@pytest.mark.parametrize( - "input_date_time, expected_date_time", - [ - ("2021-01-01T10:00:00+07:00", "2021-01-01T10:00:00"), - ("2021-01-01T10:00:00-07:00", "2021-01-01T10:00:00"), - ("2021-01-01T10:00:00Z", "2021-01-01T10:00:00"), - ], -) -def test_remove_timezone_offset(input_date_time, expected_date_time): - assert remove_timezone_offset(input_date_time) == expected_date_time - - -@pytest.mark.parametrize( - "input_date_time, time_zone_offset, expected_date_time", - [ - # without existing offset - ("2021-01-01T10:00:00", "+07:00", "2021-01-01T10:00:00+07:00"), - ("2021-01-01T10:00:00", "-07:00", "2021-01-01T10:00:00-07:00"), - ("2021-01-01T10:00:00", "Z", "2021-01-01T10:00:00Z"), - # with existing offset - ("2021-01-01T10:00:00+07:00", "+04:00", "2021-01-01T10:00:00+04:00"), - ("2021-01-01T10:00:00-07:00", "-09:00", "2021-01-01T10:00:00-09:00"), - ("2021-01-01T10:00:00-07:00", "Z", "2021-01-01T10:00:00Z"), - ], -) -def test_replace_timezone_offset(input_date_time, time_zone_offset, expected_date_time): - assert replace_timezone_offset(input_date_time, time_zone_offset) == expected_date_time - - -@pytest.mark.parametrize( - "time_zone, expected_offset", - [ - ("Central Asia Standard Time", "+05:00"), # Windows timezone format - ("America/New_York", "-04:00"), # IANA timezone format - ("Not a valid timezone", "Z"), # Fallback to UTC - ], -) -def test_convert_timezone_to_offset(time_zone, expected_offset): - assert convert_timezone_to_offset(time_zone) == expected_offset diff --git a/toolkits/microsoft/tests/outlook_mail/__init__.py b/toolkits/microsoft/tests/outlook_mail/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/microsoft/tests/outlook_mail/test_message.py b/toolkits/microsoft/tests/outlook_mail/test_message.py deleted file mode 100644 index c189c40d..00000000 --- a/toolkits/microsoft/tests/outlook_mail/test_message.py +++ /dev/null @@ -1,249 +0,0 @@ -import pytest -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.message import Message as GraphMessage -from msgraph.generated.models.recipient import Recipient as GraphRecipient - -from arcade_microsoft.outlook_mail.message import Message, Recipient - - -# Dummy classes to simulate SDK objects -class DummyBody: - def __init__(self, content): - self.content = content - - -class DummyFlagStatus: - def __init__(self, value): - self.value = value - - -class DummyImportance: - def __init__(self, value): - self.value = value - - -class DummyDueDateTime: - def __init__(self, date_time): - self.date_time = date_time - - -class DummyFlag: - def __init__(self, flag_status, due_date_time): - self.flag_status = DummyFlagStatus(flag_status) - self.due_date_time = DummyDueDateTime(due_date_time) - - -class DummyDateTime: - def __init__(self, date_str): - self.date_str = date_str - - def isoformat(self): - return self.date_str - - -def make_graph_recipient(rec_data): - recipient = GraphRecipient() - recipient.email_address = GraphEmailAddress() - recipient.email_address.address = rec_data["email_address"] - recipient.email_address.name = rec_data.get("name", "") - return recipient - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - { - "body_content": "

Hello world

", - "subject": "Test subject", - "conversation_id": "conv-1", - "conversation_index": "conv-index", - "flag_status": "flagged", - "due_date_time": "2021-01-01T10:00:00", - "has_attachments": False, - "importance": "high", - "is_read": True, - "received_date_time": "2021-01-02T00:00:00", - "web_link": "http://example.com", - "is_draft": False, - "message_id": "1234", - "to_recipients": [{"email_address": "to@example.com", "name": "ToName"}], - "cc_recipients": [{"email_address": "cc@example.com", "name": "CcName"}], - "bcc_recipients": [{"email_address": "bcc@example.com", "name": "BccName"}], - "reply_to": [{"email_address": "reply@example.com", "name": "ReplyName"}], - "from_": {"email_address": "from@example.com", "name": "FromName"}, - "conversation_index_bytes": False, - }, - { - "body": "Hello world", - "subject": "Test subject", - "conversation_id": "conv-1", - "conversation_index": "conv-index", - "flag": {"flag_status": "flagged", "due_date_time": "2021-01-01T10:00:00"}, - "has_attachments": False, - "importance": "high", - "is_read": True, - "received_date_time": "2021-01-02T00:00:00", - "web_link": "http://example.com", - "is_draft": False, - "message_id": "1234", - "to_recipients": [{"email_address": "to@example.com", "name": "ToName"}], - "cc_recipients": [{"email_address": "cc@example.com", "name": "CcName"}], - "bcc_recipients": [{"email_address": "bcc@example.com", "name": "BccName"}], - "reply_to": [{"email_address": "reply@example.com", "name": "ReplyName"}], - "from_": {"email_address": "from@example.com", "name": "FromName"}, - }, - ), - ( - { - "body_content": "

Sample email message

", - "subject": "Another subject", - "conversation_id": "conv-2", - "conversation_index": b"byte-index", - "flag_status": "notFlaged", - "due_date_time": "", - "has_attachments": False, - "importance": "low", - "is_read": False, - "received_date_time": "", - "web_link": "", - "is_draft": True, - "message_id": "5678", - "to_recipients": [{"email_address": "user1@example.com", "name": "User1"}], - "cc_recipients": [], - "bcc_recipients": [], - "reply_to": [], - "from_": {"email_address": "sender@example.com", "name": "Sender"}, - "conversation_index_bytes": True, - }, - { - "body": "Sample email message", - "subject": "Another subject", - "conversation_id": "conv-2", - "conversation_index": "byte-index", - "flag": {"flag_status": "notFlaged", "due_date_time": ""}, - "has_attachments": False, - "importance": "low", - "is_read": False, - "received_date_time": "", - "web_link": "", - "is_draft": True, - "message_id": "5678", - "to_recipients": [{"email_address": "user1@example.com", "name": "User1"}], - "cc_recipients": [], - "bcc_recipients": [], - "reply_to": [], - "from_": {"email_address": "sender@example.com", "name": "Sender"}, - }, - ), - ], -) -def test_message_conversion(input_data, expected): - # Set up sdk message - sdk_message = GraphMessage() - sdk_message.body = ( - DummyBody(input_data["body_content"]) if "body_content" in input_data else None - ) - sdk_message.subject = input_data["subject"] - sdk_message.conversation_id = input_data["conversation_id"] - sdk_message.conversation_index = input_data["conversation_index"] - sdk_message.flag = ( - DummyFlag(input_data["flag_status"], input_data["due_date_time"]) - if "flag_status" in input_data - else None - ) - sdk_message.has_attachments = input_data["has_attachments"] - sdk_message.importance = DummyImportance(input_data["importance"]) - sdk_message.is_read = input_data["is_read"] - sdk_message.received_date_time = ( - DummyDateTime(input_data["received_date_time"]) - if input_data["received_date_time"] - else None - ) - sdk_message.web_link = input_data["web_link"] - sdk_message.is_draft = input_data["is_draft"] - sdk_message.id = input_data["message_id"] - sdk_message.to_recipients = [make_graph_recipient(r) for r in input_data["to_recipients"]] - sdk_message.cc_recipients = [make_graph_recipient(r) for r in input_data["cc_recipients"]] - sdk_message.bcc_recipients = [make_graph_recipient(r) for r in input_data["bcc_recipients"]] - sdk_message.reply_to = [make_graph_recipient(r) for r in input_data["reply_to"]] - sdk_message.from_ = make_graph_recipient(input_data["from_"]) - - # Convert to Arcade Message type - message = Message.from_sdk(sdk_message) - - # Ensure conversion is correct - assert message.body == expected["body"], "Body conversion mismatch" - assert message.subject == expected["subject"] - assert message.conversation_id == expected["conversation_id"] - assert message.conversation_index == expected["conversation_index"] - assert message.flag == expected["flag"] - assert message.has_attachments == expected["has_attachments"] - assert message.importance == expected["importance"] - assert message.is_read == expected["is_read"] - assert message.received_date_time == expected["received_date_time"] - assert message.web_link == expected["web_link"] - assert message.is_draft == expected["is_draft"] - assert message.message_id == expected["message_id"] - assert message.from_.email_address == expected["from_"]["email_address"] - assert message.from_.name == expected["from_"]["name"] - - def check_recipient_list(actual, exp_list): - assert len(actual) == len(exp_list) - for rec, exp in zip(actual, exp_list, strict=False): - assert rec.email_address == exp["email_address"] - assert rec.name == exp["name"] - - check_recipient_list(message.to_recipients, expected["to_recipients"]) - check_recipient_list(message.cc_recipients, expected["cc_recipients"]) - check_recipient_list(message.bcc_recipients, expected["bcc_recipients"]) - check_recipient_list(message.reply_to, expected["reply_to"]) - - -@pytest.mark.parametrize( - "initial, add_params, expected_to_recipients", - [ - # Add a "To" recipient - ( - {"to_recipients": []}, - {"to_add": ["new@example.com"]}, - [{"email_address": "new@example.com", "name": ""}], - ), - # Add a "To" recipient that already exists - ( - {"to_recipients": [{"email_address": "dup@example.com", "name": ""}]}, - {"to_add": ["dup@example.com"]}, - [ - {"email_address": "dup@example.com", "name": ""}, - ], - ), - # Remove a "To" recipient - ( - { - "to_recipients": [ - {"email_address": "a@example.com", "name": "A"}, - {"email_address": "b@example.com", "name": "B"}, - ] - }, - {"to_remove": ["a@example.com"]}, - [{"email_address": "b@example.com", "name": "B"}], - ), - # Add and remove a "To" recipient - ( - {"to_recipients": [{"email_address": "c@example.com", "name": "C"}]}, - {"to_add": ["d@example.com", "c@example.com"], "to_remove": ["c@example.com"]}, - [{"email_address": "d@example.com", "name": ""}], - ), - ], -) -def test_update_recipient_lists(initial, add_params, expected_to_recipients): - msg = Message() - msg.to_recipients = [ - Recipient(email_address=r["email_address"], name=r.get("name", "")) - for r in initial.get("to_recipients", []) - ] - msg.update_recipient_lists( - to_add=add_params.get("to_add"), to_remove=add_params.get("to_remove") - ) - result = [r.to_dict() for r in msg.to_recipients] - assert result == expected_to_recipients, f"Expected {expected_to_recipients}, got {result}" diff --git a/toolkits/microsoft/tests/outlook_mail/test_recipient.py b/toolkits/microsoft/tests/outlook_mail/test_recipient.py deleted file mode 100644 index 8dcb021f..00000000 --- a/toolkits/microsoft/tests/outlook_mail/test_recipient.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.recipient import Recipient as GraphRecipient - -from arcade_microsoft.outlook_mail.message import Recipient - - -@pytest.mark.parametrize( - "input_sdk_recipient, expected_email, expected_name", - [ - ( - GraphRecipient(email_address=GraphEmailAddress(address="dev@arcade.dev", name="Dev")), - "dev@arcade.dev", - "Dev", - ), - ( - GraphRecipient(email_address=GraphEmailAddress(address="dev@arcade.dev")), - "dev@arcade.dev", - "", - ), - (GraphRecipient(email_address=GraphEmailAddress(name="Dev")), "", "Dev"), - (GraphRecipient(email_address=GraphEmailAddress()), "", ""), - (GraphRecipient(), "", ""), - ], -) -def test_recipient(input_sdk_recipient, expected_email, expected_name): - recipient = Recipient.from_sdk(input_sdk_recipient) - assert ( - recipient.email_address == expected_email - ), "SDK conversion didn't set email_address correctly" - assert recipient.name == expected_name, "SDK conversion didn't set name correctly" - - recipient_dict = recipient.to_dict() - expected_dict = {"email_address": expected_email, "name": expected_name} - assert recipient_dict == expected_dict, "to_dict conversion did not produce expected dictionary" - - actual_sdk_recipient = recipient.to_sdk() - assert ( - actual_sdk_recipient.email_address.address == expected_email - ), "to_sdk conversion produced wrong email address" - assert ( - actual_sdk_recipient.email_address.name == expected_name - ), "to_sdk conversion produced wrong name" diff --git a/toolkits/microsoft/tests/outlook_mail/test_utils.py b/toolkits/microsoft/tests/outlook_mail/test_utils.py deleted file mode 100644 index 1d5bb0b2..00000000 --- a/toolkits/microsoft/tests/outlook_mail/test_utils.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest - -from arcade_microsoft.outlook_mail._utils import _create_filter_expression -from arcade_microsoft.outlook_mail.enums import EmailFilterProperty, FilterOperator - - -@pytest.mark.parametrize( - "property_, operator, value, expected_filter_expr", - [ - ( - EmailFilterProperty.SUBJECT, - FilterOperator.EQUAL, - "Hello", - "receivedDateTime ge 1900-01-01T00:00:00Z and subject eq 'Hello'", - ), - ( - EmailFilterProperty.SUBJECT, - FilterOperator.STARTS_WITH, - "He", - "receivedDateTime ge 1900-01-01T00:00:00Z and startsWith(subject, 'He')", - ), - ( - EmailFilterProperty.CONVERSATION_ID, - FilterOperator.EQUAL, - "12345askdfjh=wef67890", - "receivedDateTime ge 1900-01-01T00:00:00Z and conversationId eq '12345askdfjh=wef67890'", # noqa: E501 - ), - ( - EmailFilterProperty.CONVERSATION_ID, - FilterOperator.NOT_EQUAL, - "67890", - "receivedDateTime ge 1900-01-01T00:00:00Z and conversationId ne 67890", - ), - ( - EmailFilterProperty.RECEIVED_DATE_TIME, - FilterOperator.GREATER_THAN, - "2024-01-01", - "receivedDateTime gt '2024-01-01'", - ), - ( - EmailFilterProperty.SENDER, - FilterOperator.EQUAL, - "a@ex.com", - "receivedDateTime ge 1900-01-01T00:00:00Z and sender/emailAddress/address eq 'a@ex.com'", # noqa: E501 - ), - ( - EmailFilterProperty.SENDER, - FilterOperator.CONTAINS, - "joe", - "receivedDateTime ge 1900-01-01T00:00:00Z and contains(sender/emailAddress/address, 'joe')", # noqa: E501 - ), - ], -) -def test_create_filter_expression(property_, operator, value, expected_filter_expr): - assert _create_filter_expression(property_, operator, value) == expected_filter_expr diff --git a/toolkits/notion/.pre-commit-config.yaml b/toolkits/notion/.pre-commit-config.yaml deleted file mode 100644 index e36ce46e..00000000 --- a/toolkits/notion/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/notion/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/notion/.ruff.toml b/toolkits/notion/.ruff.toml deleted file mode 100644 index 9519fe6c..00000000 --- a/toolkits/notion/.ruff.toml +++ /dev/null @@ -1,44 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/notion/LICENSE b/toolkits/notion/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/notion/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/notion/Makefile b/toolkits/notion/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/notion/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/notion/arcade_notion_toolkit/__init__.py b/toolkits/notion/arcade_notion_toolkit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/notion/arcade_notion_toolkit/block_to_markdown_converter.py b/toolkits/notion/arcade_notion_toolkit/block_to_markdown_converter.py deleted file mode 100644 index 51ec0731..00000000 --- a/toolkits/notion/arcade_notion_toolkit/block_to_markdown_converter.py +++ /dev/null @@ -1,201 +0,0 @@ -import asyncio -from typing import Any - -from arcade_tdk import ToolContext - -from arcade_notion_toolkit.enums import BlockType -from arcade_notion_toolkit.utils import get_page_url - - -class BlockToMarkdownConverter: - """ - A converter class that transforms Notion blocks into Markdown. - - The class registers conversion handlers for different Notion block types. - If a block type does not have a handler, then the block's plain text is returned. - """ - - def __init__(self, context: ToolContext): - self.context = context - # block types whose conversion logic has been implemented - # TODO: implement conversion logic for more block types - self.handlers = { - BlockType.BULLETED_LIST_ITEM.value: self._convert_bulleted_list_item, - BlockType.EQUATION.value: self._convert_equation, - BlockType.HEADING_1.value: self._convert_heading_1, - BlockType.HEADING_2.value: self._convert_heading_2, - BlockType.HEADING_3.value: self._convert_heading_3, - BlockType.LINK_PREVIEW.value: self._convert_link_preview, - BlockType.NUMBERED_LIST_ITEM.value: self._convert_numbered_list_item, - BlockType.PARAGRAPH.value: self._convert_paragraph, - } - - async def convert_block(self, block: dict[str, Any]) -> str: - """ - Convert a single Notion block to a Markdown string - - Args: - block (dict[str, Any]): A Notion block. - - Returns: - str: A Markdown string. - """ - block_type = block.get("type") - if block_type in self.handlers: - converter = self.handlers[block_type] - if asyncio.iscoroutinefunction(converter): - md: str = await converter(block) - return md - else: - return converter(block) - elif block_type == BlockType.CHILD_PAGE.value: - return await self._convert_child_page(block) - else: - return self._get_plaintext(block) - - @staticmethod - def rich_text_to_markdown(rich_text_items: list[dict[str, Any]]) -> str: - """ - Convert a list of rich text items (from a Notion block) into Markdown. - - Handles formatting such as bold, italic, strikethrough, underline (via HTML), - inline code, text coloring, hyperlinks, and equations. - """ - md = "" - for item in rich_text_items: - annotations = item.get("annotations", {}) - type_val = item.get("type", "text") - link = None - - # Special handling for inline equations. - if type_val == "equation": - expression = item.get("equation", {}).get("expression", "") - md += f"${expression}$" - continue - - if type_val == "text": - text_obj = item.get("text", {}) - text = text_obj.get("content", "") - link_obj = text_obj.get("link") - link = ( - link_obj.get("url") - if (link_obj and isinstance(link_obj, dict)) - else item.get("href") - ) - elif type_val == "mention": - text = item.get("plain_text", "") - link = item.get("href") - else: - text = item.get("plain_text", "") - link = item.get("href") - - if text.strip() == "": - continue - - # Apply annotation formatting. - text = BlockToMarkdownConverter.apply_formatting(text, annotations, link) - - md += text - - return md - - @staticmethod - def apply_formatting(text: str, annotations: dict[str, Any], link: str | None = None) -> str: - """Apply formatting to a text string based on the annotations. - Used when converting rich text to markdown - - Args: - text (str): The text to format. - annotations (dict[str, Any]): The annotations to apply to the text. - link (str | None): An optional link for a hyperlink. - - Returns: - str: The formatted text. - """ - # If code block, wrap in backticks and skip other formatting. - if annotations.get("code"): - return f"`{text}`" - - # Add underline - if annotations.get("underline"): - text = f"{text}" - - # Apply color - color = annotations.get("color", "default") - if color != "default": - text = f'{text}' - - # Add bold, italic, and strikethrough - markers = [ - marker - for key, marker in (("bold", "**"), ("italic", "*"), ("strikethrough", "~~")) - if annotations.get(key) - ] - if markers: - text = "".join(markers) + text + "".join(reversed(markers)) - - # Add hyperlink - if link: - text = f"[{text}]({link})" - - return text - - def _get_plaintext(self, block: dict[str, Any]) -> str: - """ - Extract and return the plain text from a Notion block. - This acts as a fallback for unsupported block types. - """ - block_type: str = block.get("type", "") - content = block.get(block_type, {}) - if isinstance(content, dict): - rich_text_items = content.get("rich_text", []) - return "".join(item.get("plain_text", "") for item in rich_text_items) - return "" - - def _convert_text_block(self, block: dict[str, Any], element_key: str, prefix: str = "") -> str: - """ - Helper method to convert a Notion block's rich_text element into a Markdown string. - Optionally, a prefix (like a markdown list marker or heading hashes) is added. - """ - element = block.get(element_key, {}) - rich_text_items = element.get("rich_text", []) - text = self.rich_text_to_markdown(rich_text_items) - return f"{prefix}{text} \n" - - async def _convert_child_page(self, block: dict[str, Any]) -> str: - """ - Asynchronously convert a child page block. This requires fetching the page's URL. - """ - page_url = await get_page_url(self.context, block.get("id", "")) - child_page = block.get("child_page", {}) - rich_text_items = child_page.get("rich_text", []) - if rich_text_items: - title = self.rich_text_to_markdown(rich_text_items) - else: - title = child_page.get("title", "") - return f"[{title}]({page_url}) \n" - - def _convert_bulleted_list_item(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "bulleted_list_item", "- ") - - def _convert_equation(self, block: dict[str, Any]) -> str: - expression = block.get("equation", {}).get("expression", "") - return f"$$ {expression} $$ \n" - - def _convert_heading_1(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "heading_1", "# ") - - def _convert_heading_2(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "heading_2", "## ") - - def _convert_heading_3(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "heading_3", "### ") - - def _convert_link_preview(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "link_preview") - - def _convert_numbered_list_item(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "numbered_list_item", "1. ") - - def _convert_paragraph(self, block: dict[str, Any]) -> str: - return self._convert_text_block(block, "paragraph") diff --git a/toolkits/notion/arcade_notion_toolkit/constants.py b/toolkits/notion/arcade_notion_toolkit/constants.py deleted file mode 100644 index 75c94f9b..00000000 --- a/toolkits/notion/arcade_notion_toolkit/constants.py +++ /dev/null @@ -1,19 +0,0 @@ -NOTION_API_URL = "https://api.notion.com/v1" - - -ENDPOINTS = { - "create_a_page": "/pages", - "retrieve_block_children": "/blocks/{block_id}/children", - "search_by_title": "/search", - "query_a_database": "/databases/{database_id}/query", - "update_page_properties": "/pages/{page_id}", - "append_block_children": "/blocks/{block_id}/children", - "retrieve_a_database": "/databases/{database_id}", - "create_comment": "/comments", - "retrieve_a_page": "/pages/{page_id}", - "retrieve_a_block": "/blocks/{block_id}", - "delete_a_block": "/blocks/{block_id}", - "update_a_block": "/blocks/{block_id}", -} - -UNTITLED_TITLE = "New Page" diff --git a/toolkits/notion/arcade_notion_toolkit/enums.py b/toolkits/notion/arcade_notion_toolkit/enums.py deleted file mode 100644 index e28427c0..00000000 --- a/toolkits/notion/arcade_notion_toolkit/enums.py +++ /dev/null @@ -1,45 +0,0 @@ -from enum import Enum - - -class SortDirection(str, Enum): - ASCENDING = "ascending" - DESCENDING = "descending" - - -class ObjectType(str, Enum): - PAGE = "page" - DATABASE = "database" - - -class BlockType(str, Enum): - BOOKMARK = "bookmark" - BREADCRUMB = "breadcrumb" - BULLETED_LIST_ITEM = "bulleted_list_item" - CALLOUT = "callout" - CHILD_DATABASE = "child_database" - CHILD_PAGE = "child_page" - COLUMN = "column" - COLUMN_LIST = "column_list" - DIVIDER = "divider" - EMBED = "embed" - EQUATION = "equation" - FILE = "file" - HEADING_1 = "heading_1" - HEADING_2 = "heading_2" - HEADING_3 = "heading_3" - IMAGE = "image" - LINK_PREVIEW = "link_preview" - LINK_TO_PAGE = "link_to_page" - NUMBERED_LIST_ITEM = "numbered_list_item" - PARAGRAPH = "paragraph" - PDF = "pdf" - QUOTE = "quote" - SYNCED_BLOCK = "synced_block" - TABLE = "table" - TABLE_OF_CONTENTS = "table_of_contents" - TABLE_ROW = "table_row" - TEMPLATE = "template" - TO_DO = "to_do" - TOGGLE = "toggle" - UNSUPPORTED = "unsupported" - VIDEO = "video" diff --git a/toolkits/notion/arcade_notion_toolkit/markdown_to_block_converter.py b/toolkits/notion/arcade_notion_toolkit/markdown_to_block_converter.py deleted file mode 100644 index ce4ddb31..00000000 --- a/toolkits/notion/arcade_notion_toolkit/markdown_to_block_converter.py +++ /dev/null @@ -1,158 +0,0 @@ -import re -from typing import Any - -# TODO: This is a partial implementation. -# TODO: Does not support children blocks. Instead, the markdown content is flattened. -# TODO: Does not support equation blocks. -# TODO: Does not support colored text styling. -# TODO: Does not support underline text styling. -# TODO: Does not support multiple text styles for the same block. - - -def convert_markdown_to_blocks(content: str) -> list[dict[str, Any]]: # noqa: C901 - """Convert markdown content to Notion blocks.""" - blocks: list[dict[str, Any]] = [] - code_block: list[str] = [] - in_code: bool = False - language: str = "plain text" - numbered_list_index: int = 0 - - for line in content.splitlines(): - line = line.strip() - - if line.startswith("```"): - if in_code: - blocks.append({ - "type": "code", - "code": { - "rich_text": [ - { - "type": "text", - "text": {"content": "\n".join(code_block)}, - } - ], - "language": language, - }, - }) - code_block = [] - in_code = False - else: - in_code = True - language = line[3:].strip() or "plain text" - continue - - if in_code: - code_block.append(line) - continue - - if not line: - numbered_list_index = 0 - continue - - if line.startswith("### "): - block_type, text = "heading_3", line[4:] - elif line.startswith("## "): - block_type, text = "heading_2", line[3:] - elif line.startswith("# "): - block_type, text = "heading_1", line[2:] - elif numbered_match := re.match(r"(\d+)\.\s+(.+)", line): - block_type, text = "numbered_list_item", numbered_match.group(2) - numbered_list_index += 1 - elif line.startswith("- "): - block_type, text = "bulleted_list_item", line[2:] - elif line.startswith("> "): - block_type, text = "quote", line[2:] - elif line == "---": - blocks.append({"type": "divider", "divider": {}}) - continue - else: - block_type, text = "paragraph", line - - blocks.append({"type": block_type, block_type: {"rich_text": format_text(text)}}) - - return blocks - - -def format_text(text: str) -> list[dict[str, Any]]: - """Convert text with markdown formatting to Notion rich text.""" - patterns = [ - (r"\[([^\]]+)\]\(([^\)]+)\)", "link"), # [text](url) - (r"\*\*(.*?)\*\*", "bold"), # **bold** - (r"__(.*?)__", "bold"), # __bold__ - (r"\*(.*?)\*", "italic"), # *italic* - (r"_(.*?)_", "italic"), # _italic_ - (r"~~(.*?)~~", "strikethrough"), # ~~strikethrough~~ - (r"`(.*?)`", "code"), # `code` - ] - - rich_text = [] - last_index = 0 - combined_pattern = "|".join(f"({pattern})" for pattern, _ in patterns) - - for match in re.finditer(combined_pattern, text): - start, end = match.span() - if start > last_index: - rich_text.append({ - "type": "text", - "text": {"content": text[last_index:start]}, - "annotations": { - "bold": False, - "italic": False, - "strikethrough": False, - "underline": False, - "code": False, - "color": "default", - }, - }) - - matched_text = match.group(0) - for pattern, format_type in patterns: - if m := re.match(pattern, matched_text): - if format_type == "link": - rich_text.append({ - "type": "text", - "text": { - "content": m.group(1), - "link": {"url": m.group(2)}, - }, - "annotations": { - "bold": False, - "italic": False, - "strikethrough": False, - "underline": False, - "code": False, - "color": "default", - }, - }) - else: - rich_text.append({ - "type": "text", - "text": {"content": m.group(1)}, - "annotations": { - "bold": format_type == "bold", - "italic": format_type == "italic", - "strikethrough": format_type == "strikethrough", - "underline": False, - "code": format_type == "code", - "color": "default", - }, - }) - break - - last_index = end - - if last_index < len(text): - rich_text.append({ - "type": "text", - "text": {"content": text[last_index:]}, - "annotations": { - "bold": False, - "italic": False, - "strikethrough": False, - "underline": False, - "code": False, - "color": "default", - }, - }) - - return rich_text diff --git a/toolkits/notion/arcade_notion_toolkit/tools/__init__.py b/toolkits/notion/arcade_notion_toolkit/tools/__init__.py deleted file mode 100644 index 90d6c40f..00000000 --- a/toolkits/notion/arcade_notion_toolkit/tools/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from arcade_notion_toolkit.tools.pages import ( - append_content_to_end_of_page, - create_page, - get_page_content_by_id, - get_page_content_by_title, -) -from arcade_notion_toolkit.tools.search import ( - get_object_metadata, - get_workspace_structure, - search_by_title, -) - -__all__ = [ - "append_content_to_end_of_page", - "create_page", - "get_object_metadata", - "get_page_content_by_id", - "get_page_content_by_title", - "get_workspace_structure", - "search_by_title", -] diff --git a/toolkits/notion/arcade_notion_toolkit/tools/pages.py b/toolkits/notion/arcade_notion_toolkit/tools/pages.py deleted file mode 100644 index 59f002ae..00000000 --- a/toolkits/notion/arcade_notion_toolkit/tools/pages.py +++ /dev/null @@ -1,212 +0,0 @@ -import asyncio -from typing import Annotated, Any - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Notion -from arcade_tdk.errors import ToolExecutionError - -from arcade_notion_toolkit.block_to_markdown_converter import BlockToMarkdownConverter -from arcade_notion_toolkit.enums import BlockType, ObjectType -from arcade_notion_toolkit.markdown_to_block_converter import convert_markdown_to_blocks -from arcade_notion_toolkit.tools.search import get_object_metadata -from arcade_notion_toolkit.types import DatabaseParent, PageWithPageParentProperties, create_parent -from arcade_notion_toolkit.utils import ( - extract_title, - get_headers, - get_next_page, - get_page_url, - get_url, - is_page_id, -) - - -@tool(requires_auth=Notion()) -async def get_page_content_by_id( - context: ToolContext, page_id: Annotated[str, "ID of the page to get content from"] -) -> Annotated[str, "The markdown content of the page"]: - """Get the content of a Notion page as markdown with the page's ID""" - headers = get_headers(context) - params = {"page_size": 100} - converter = BlockToMarkdownConverter(context) - - async with httpx.AsyncClient() as client: - - async def fetch_blocks(block_id: str) -> list: - """Fetch all immediate children blocks for a given block ID, handling pagination""" - all_blocks = [] - url = get_url("retrieve_block_children", block_id=block_id) - cursor = None - - while True: - data, has_more, cursor = await get_next_page(client, url, headers, params, cursor) - all_blocks.extend(data.get("results", [])) - if not has_more: - break - - return all_blocks - - async def process_blocks_to_markdown(blocks: list, indent: str = "") -> str: - """Process a list of blocks into markdown. - - If a block has children, we recurse into the children blocks. - """ - markdown_pieces = [] - - for block in blocks: - block_markdown = await converter.convert_block(block) - if block_markdown: - # Append each line with indent as a separate piece - for line in block_markdown.rstrip("\n").splitlines(): - markdown_pieces.append(indent + line + "\n") - - # If the block has children and is not a child page, recurse. - # We don't recurse into child page content, as this would result in fetching - # the children pages' content, which the Notion UI does not show. - if ( - block.get("has_children", False) - and block.get("type") != BlockType.CHILD_PAGE.value - ): - # Fetch all child blocks first - child_blocks = await fetch_blocks(block["id"]) - # Then process them all at once - child_markdown = await process_blocks_to_markdown(child_blocks, indent + " ") - markdown_pieces.append(child_markdown) - - return "".join(markdown_pieces) - - # Get the title - page_metadata = await get_object_metadata(context, object_id=page_id) - markdown_title = f"# {extract_title(page_metadata)}\n" - - # Get all top-level blocks - top_level_blocks = await fetch_blocks(page_id) - - chunk_size = max(1, len(top_level_blocks) // 5) - chunks = [ - top_level_blocks[i : i + chunk_size] - for i in range(0, len(top_level_blocks), chunk_size) - ] - - # Process all block content into markdown - results = await asyncio.gather(*[process_blocks_to_markdown(chunk, "") for chunk in chunks]) - markdown_content = "".join(results) - - return markdown_title + markdown_content - - -@tool(requires_auth=Notion()) -async def get_page_content_by_title( - context: ToolContext, title: Annotated[str, "Title of the page to get content from"] -) -> Annotated[str, "The markdown content of the page"]: - """Get the content of a Notion page as markdown with the page's title""" - page_metadata = await get_object_metadata( - context, object_title=title, object_type=ObjectType.PAGE - ) - - page_content: str = await get_page_content_by_id(context, page_metadata["id"]) - return page_content - - -@tool(requires_auth=Notion()) -async def create_page( - context: ToolContext, - parent_title: Annotated[ - str, - "Title of an existing page/database within which the new page will be created. ", - ], - title: Annotated[str, "Title of the new page"], - content: Annotated[str | None, "The content of the new page"] = None, -) -> Annotated[str, "The ID of the new page"]: - """Create a new Notion page by the title of the new page's parent.""" - # Notion API does not support creating a page at the root of the workspace... sigh - parent_metadata = await get_object_metadata( - context, - parent_title, - object_type=ObjectType.PAGE, - ) - parent_type = parent_metadata["object"] + "_id" - parent = create_parent({"type": parent_type, parent_type: parent_metadata["id"]}) - - properties: dict[str, Any] = {} - if isinstance(parent, DatabaseParent): - # TODO: Support creating a page within a database - raise ToolExecutionError( - message="Creating a page within a database is not supported.", - developer_message="Database is not supported as a parent of a new page at this time.", - ) - else: - properties = PageWithPageParentProperties(title=title).to_dict() - - children = convert_markdown_to_blocks(content) if content else [] - - # Split children into chunks of 100 due to Notion API limit - chunk_size = 100 - first_chunk = children[:chunk_size] if children else [] - remaining_chunks = [ - children[i : i + chunk_size] for i in range(chunk_size, len(children), chunk_size) - ] - - body = { - "parent": parent.to_dict(), - "properties": properties, - "children": first_chunk, - } - - url = get_url("create_a_page") - headers = get_headers(context) - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=body) - response.raise_for_status() - page_id = response.json()["id"] - - # Append remaining chunks if any - if remaining_chunks: - append_url = get_url("append_block_children", block_id=page_id) - for chunk in remaining_chunks: - chunk_body = {"children": chunk} - append_response = await client.patch(append_url, headers=headers, json=chunk_body) - append_response.raise_for_status() - - return f"Successfully created page with ID: {page_id}" - - -@tool(requires_auth=Notion()) -async def append_content_to_end_of_page( - context: ToolContext, - page_id_or_title: Annotated[str, "ID or title of the page to append content to"], - content: Annotated[str, "The markdown content to append to the end of the page"], -) -> Annotated[dict[str, str], "A dictionary containing a success message and the URL to the page"]: - """Append markdown content to the end of a Notion page by its ID or title""" - # Determine if the provided identifier is an ID or a title - page_id = page_id_or_title - if not is_page_id(page_id_or_title): - page_metadata = await get_object_metadata( - context, - object_title=page_id_or_title, - object_type=ObjectType.PAGE, - ) - page_id = page_metadata["id"] - - headers = get_headers(context) - # the Notion API endpoint conveniently also accepts page ID for the block_id path parameter - url = get_url("append_block_children", block_id=page_id) - - children = convert_markdown_to_blocks(content) - - # Split children into chunks of 100 due to Notion API limit - chunk_size = 100 - async with httpx.AsyncClient() as client: - for i in range(0, len(children), chunk_size): - chunk = children[i : i + chunk_size] - body = {"children": chunk} - - response = await client.patch(url, headers=headers, json=body) - response.raise_for_status() - - page_url = await get_page_url(context, page_id) - - return { - "message": f"Successfully appended content to page with ID: {page_id}", - "url": page_url, - } diff --git a/toolkits/notion/arcade_notion_toolkit/tools/search.py b/toolkits/notion/arcade_notion_toolkit/tools/search.py deleted file mode 100644 index dc50fb36..00000000 --- a/toolkits/notion/arcade_notion_toolkit/tools/search.py +++ /dev/null @@ -1,225 +0,0 @@ -from typing import Annotated, Any - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Notion -from arcade_tdk.errors import ToolExecutionError - -from arcade_notion_toolkit.enums import ObjectType, SortDirection -from arcade_notion_toolkit.utils import ( - build_workspace_structure, - get_headers, - get_url, - remove_none_values, - simplify_search_result, -) - - -@tool(requires_auth=Notion()) -async def search_by_title( - context: ToolContext, - query: Annotated[ - str | None, - "A substring to search for within page and database titles. " - "If not provided (default), all pages and/or databases are returned.", - ] = None, - select: Annotated[ - ObjectType | None, - "Limit the results to either only pages or only databases. Defaults to both.", - ] = None, - order_by: Annotated[ - SortDirection, - "The direction to sort search results by last edited time. Defaults to 'descending'.", - ] = SortDirection.DESCENDING, - limit: Annotated[ - int, - "The maximum number of results to return. Defaults to 100. Set to -1 for no limit.", - ] = 100, -) -> Annotated[ - dict, - "A dictionary containing minimal information about the pages and/or databases that have " - "titles that are the best match for the query. Does not include content or location.", -]: - """Search for similar titles of pages, databases, or both within the user's workspace. - Does not include content. - """ - results = [] - current_cursor = None - - url = get_url("search_by_title") - headers = get_headers(context) - payload = { - "query": query, - "page_size": 100 if limit == -1 else min(100, limit), - "filter": {"property": "object", "value": select.value} if select else None, - "sort": {"direction": order_by, "timestamp": "last_edited_time"}, - } - payload = remove_none_values(payload) - - async with httpx.AsyncClient() as client: - while True: - if current_cursor: - payload["start_cursor"] = current_cursor - elif "start_cursor" in payload: - del payload["start_cursor"] - - response = await client.post(url, headers=headers, json=payload) - response.raise_for_status() - data = response.json() - - page_results = [simplify_search_result(item) for item in data.get("results", [])] - results.extend(page_results) - - # If a limit is set and we've reached or exceeded it, truncate the results. - if limit is not None and len(results) >= limit: - results = results[:limit] - break - - if not data.get("has_more", False): - break - - current_cursor = data.get("next_cursor") - - return {"results": results} - - -@tool(requires_auth=Notion()) -async def get_object_metadata( - context: ToolContext, - object_title: Annotated[ - str | None, "Title of the page or database whose metadata to get" - ] = None, - object_id: Annotated[str | None, "ID of the page or database whose metadata to get"] = None, - object_type: Annotated[ - ObjectType | None, - "The type of object to match title to. Only used if `object_title` is provided. " - "Defaults to both", - ] = None, -) -> Annotated[dict[str, Any], "The metadata of the object"]: - """Get the metadata of a Notion object (page or database) from its title or ID. - - One of `object_title` or `object_id` MUST be provided, but both cannot be provided. - The title is case-insensitive and outer whitespace is ignored. - - An object's metadata includes it's id, various timestamps, properties, url, and more. - """ - - async def get_metadata_by_title(object_title: str) -> dict[str, Any]: - candidates_response = await search_by_title( - context, - object_title, - select=object_type, - order_by=SortDirection.DESCENDING, - limit=3, - ) - - if object_type: - candidates: list[dict[str, Any]] = [ - page - for page in candidates_response["results"] - if page["object"] == object_type.value - ] - else: - candidates = candidates_response["results"] - - normalized_title = object_title.lower().strip() - error_msg = ( - f"The {object_type.value if object_type else 'object'} with " - f"the title '{object_title}' could not be found. " - "Either it does not exist, or it has not been shared with the integration." - ) - - if not candidates: - raise ToolExecutionError(message=error_msg) - - for object_ in candidates: - if object_["title"].lower().strip() == normalized_title: - # object_ is either a page object: https://developers.notion.com/reference/page - # or a database object: https://developers.notion.com/reference/database - return object_ - - raise ToolExecutionError( - message=error_msg, - developer_message=f"The closest matches are: {candidates}", - ) - - async def get_metadata_by_id(object_id: str) -> dict[str, Any]: - url = get_url("retrieve_a_page", page_id=object_id) - headers = get_headers(context) - async with httpx.AsyncClient(timeout=10) as client: - response = await client.get(url, headers=headers) - - if response.status_code != 200: - raise ToolExecutionError( - message="The page or database could not be found.", - developer_message=f"The response was: {response.json()}", - ) - - return dict(response.json()) - - if object_id is not None and object_id != "": - return await get_metadata_by_id(object_id) - elif object_title is not None and object_title != "": - return await get_metadata_by_title(object_title) - else: - raise ToolExecutionError( - message="Either object_title or object_id must be provided.", - ) - - -@tool(requires_auth=Notion()) -async def get_workspace_structure( - context: ToolContext, -) -> Annotated[dict[str, Any], "The workspace structure"]: - """Get the workspace structure of the user's Notion workspace. - Ideal for finding where an object is located in the workspace. - """ - # Retrieve the complete flat list of all pages and databases. - results = await search_by_title(context, None, limit=-1) - - # Remove database rows from results - # They're returned from the search results because they're - # technically child pages of the database, but since they're not displayed in the UI's - # sidebar workspace structure, we do not include them in this tool's response. - results["results"] = [ - item - for item in results.get("results", []) - if not ( - item.get("object", "") == "page" - and item.get("parent", {}).get("type", "") == "database_id" - ) - ] - - async with httpx.AsyncClient() as client: - headers = get_headers(context) - orphaned_items = [] - for item in results.get("results", []): - # This condition will only be met for databases that are 'child_pages' of a page. - # Notion API wraps these databases in a block object, so we need to unwrap it to - # link the parent page to the database. Sometimes it takes multiple unwrappings - # to get to the parent page. - while ( - item.get("parent", {}).get("type", "") == "block_id" - and item.get("type", "database") == "database" - ): - parent = item.get("parent", {}) - block_id = parent["block_id"] - url = get_url("retrieve_a_block", block_id=block_id) - block_response = await client.get(url, headers=headers) - if block_response.status_code != 200: - # unable to attach the database to the parent page - orphaned_items.append(item["id"]) - break - block_data = block_response.json() - if "parent" in block_data: - item["parent"] = block_data["parent"] - - # Drop orphaned items from results since we were unable to attach them to a parent page. - results["results"] = [ - item for item in results.get("results", []) if item["id"] not in orphaned_items - ] - - items = results.get("results", []) - workspace_tree = build_workspace_structure(items) - - return workspace_tree diff --git a/toolkits/notion/arcade_notion_toolkit/types.py b/toolkits/notion/arcade_notion_toolkit/types.py deleted file mode 100644 index 2dbab3a5..00000000 --- a/toolkits/notion/arcade_notion_toolkit/types.py +++ /dev/null @@ -1,105 +0,0 @@ -from dataclasses import asdict, dataclass, field - - -# ------------------------------------------------------ -# Parent types. -# See Notion API docs for more information: -# https://developers.notion.com/reference/parent-object -# ------------------------------------------------------ -@dataclass -class Parent: - type: str - - def to_dict(self) -> dict: - return asdict(self) - - -@dataclass -class DatabaseParent(Parent): - database_id: str - type: str = field(init=False, default="database_id") - - -@dataclass -class PageParent(Parent): - page_id: str - type: str = field(init=False, default="page_id") - - -@dataclass -class WorkspaceParent(Parent): - workspace: bool = True - type: str = field(init=False, default="workspace") - - -@dataclass -class BlockParent(Parent): - block_id: str - type: str = field(init=False, default="block_id") - - -def create_parent(parent_data: dict) -> Parent: - """ - Create a parent object from a dictionary. - - See https://developers.notion.com/reference/parent-object for more information - about the parent object. - - Args: - parent_data (dict): The dictionary containing the parent data. - - Returns: - Parent: The parent object. - """ - parent_type = parent_data.get("type") - if parent_type == "database_id": - return DatabaseParent(database_id=parent_data.get("database_id", "")) - elif parent_type == "page_id": - return PageParent(page_id=parent_data.get("page_id", "")) - elif parent_type == "workspace": - return WorkspaceParent() - elif parent_type == "block_id": - return BlockParent(block_id=parent_data.get("block_id", "")) - else: - raise ValueError(f"Unknown parent type: {parent_type}") # noqa: TRY003 - - -# ------------------------------------------------------ -# Property types. -# See Notion API docs for more information: -# https://developers.notion.com/reference/property-object -# and https://developers.notion.com/reference/page-property-values -# ------------------------------------------------------ - - -@dataclass -class PageWithPageParentProperties: - """Properties for a page that has a parent that is also a page""" - - title: str - - def to_dict(self) -> dict: - return { - "title": { - "title": [ - { - "type": "text", - "text": { - "content": self.title, - }, - }, - ], - }, - } - - -@dataclass -class PageWithDatabaseParentProperties: - # TODO: Implement when database parent is supported for `create_page` tool - pass - - -@dataclass -class DatabaseProperties: - # TODO: Implement when create_database tool is implemented - pass diff --git a/toolkits/notion/arcade_notion_toolkit/utils.py b/toolkits/notion/arcade_notion_toolkit/utils.py deleted file mode 100644 index 3cc13e6e..00000000 --- a/toolkits/notion/arcade_notion_toolkit/utils.py +++ /dev/null @@ -1,245 +0,0 @@ -from typing import Any -from uuid import UUID - -import httpx -from arcade_tdk import ToolContext - -from arcade_notion_toolkit.constants import ENDPOINTS, NOTION_API_URL, UNTITLED_TITLE - - -def is_page_id(candidate: str) -> bool: - """ - Determine if the provided candidate string has the structure of a valid Notion page ID. - Page IDs are UUID values. - - Args: - candidate (str): The candidate string to check. - - Returns: - bool: True if the candidate has the structure of a valid Notion page ID, False otherwise. - """ - try: - UUID(candidate) - except ValueError: - return False - - return True - - -def get_url(endpoint: str, **kwargs: Any) -> str: - """ - Constructs the full URL for a specified notion endpoint. - - Args: - endpoint (str): The endpoint key from ENDPOINTS. - **kwargs: Additional parameters to format the URL. - - Returns: - str: The complete URL for the specified endpoint. - """ - return f"{NOTION_API_URL}{ENDPOINTS[endpoint].format(**kwargs)}" - - -def get_headers(context: ToolContext) -> dict[str, str]: - """ - Retrieves the headers for a given context. - - Args: - context (ToolContext): The context containing authorization and other information. - - Returns: - dict[str, str]: A dictionary containing the headers for the Notion API request. - """ - return { - "Authorization": context.get_auth_token_or_empty(), - "Content-Type": "application/json", - "Notion-Version": "2022-06-28", - } - - -def remove_none_values(payload: dict[str, Any]) -> dict[str, Any]: - """ - Removes all keys with None values from a dictionary. - - Args: - payload (dict[str, Any]): The dictionary to remove None values from. - - Returns: - dict[str, Any]: A dictionary with all None values removed. - """ - return {k: v for k, v in payload.items() if v is not None} - - -def extract_title(item: dict) -> str: - """ - Extracts a human-readable title from a page or database, or a block if possible. - - Args: - item (dict): The item to extract the title from. - - Returns: - str: The human-readable title of the item. - """ - properties: dict = item.get("properties", {}) - # Case 1: Extract title from a database object. - if item["object"] == "database" and "title" in item: - return "".join([t.get("plain_text", "") for t in item.get("title", [])]) - - # Case 2: Extract title from a page object that is parented by the workspace or a page - if item["object"] == "page" and "title" in properties: - return "".join([t["plain_text"] for t in properties["title"].get("title", [])]) - - # Case 3: Extract title from a page object that is parented a database - elif item["object"] == "page": - for prop in properties.values(): - if isinstance(prop, dict) and prop.get("type") == "title": - return "".join([t.get("plain_text", "") for t in prop.get("title", [])]) - - # Case 4: Extract title from a child page block object - if item.get("object") == "block": - block_type = item.get("type") - if block_type == "child_page": - title: str = item.get("child_page", {}).get("title", UNTITLED_TITLE) - return title - # For text-based blocks, try extracting rich_text. - if block_type in ["paragraph", "heading_1", "heading_2", "heading_3"]: - rich_text = item.get(block_type, {}).get("rich_text", []) - return "".join([t.get("plain_text", "") for t in rich_text]) or block_type - - return UNTITLED_TITLE - - -def simplify_search_result(item: dict) -> dict: - """ - Simplifies a 'search by title' result from the Notion API. - Takes a page object or database object and extracts only the necessary data. - - Args: - item (dict): The search result to simplify. - - Returns: - dict: A simplified search result - """ - title = extract_title(item) - - return { - "id": item.get("id"), - "object": item.get("object"), - "parent": item.get("parent"), - "created_time": item.get("created_time"), - "last_edited_time": item.get("last_edited_time"), - "title": title, - "url": item.get("url"), - "public_url": item.get("public_url"), - } - - -async def get_next_page( - client: httpx.AsyncClient, - url: str, - headers: dict, - params: dict | None = None, - cursor: str | None = None, -) -> tuple[dict, bool, str]: - """ - Retrieves the next page of results from a Notion API endpoint. - This is a helper function that is useful when paginating through Notion API responses. - - Args: - client (httpx.AsyncClient): The HTTP client to use for the request. - url (str): The URL of the endpoint to request. - headers (dict): The headers to use for the request. - params (dict | None): The parameters to use for the request. - cursor (str | None): The cursor to use for the request. - - Returns: - tuple[dict, bool, str]: A tuple containing the results, a boolean indicating if there is a - next page, and the next cursor. - """ - params = params or {} - if cursor: - params["start_cursor"] = cursor - elif "start_cursor" in params: - del params["start_cursor"] - - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - data = response.json() - return data, data.get("has_more", False), data.get("next_cursor") - - -async def get_page_url(context: ToolContext, page_id: str) -> str: - """ - Retrieves the URL of a page from the Notion API. - - Args: - context (ToolContext): The context containing authorization and other information. - page_id (str): The ID of the page to get the URL of. - - Returns: - str: The URL of the page or an empty string if the page's metadata cannot be retrieved. - """ - url = get_url("retrieve_a_page", page_id=page_id) - headers = get_headers(context) - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - if response.status_code != 200: - return "" - data = response.json() - return data.get("url", "") # type: ignore[no-any-return] - - -def build_workspace_structure(items: list[dict[str, Any]]) -> dict[str, list]: - """Build a tree structure from a flat list of Notion objects. - - Args: - items (list[dict[str, Any]]): A list of Notion objects. - - Returns: - dict[str, list]: A tree structure of the workspace. - """ - # For each item, we initialize a children list and then attach it - # under its parent if one exists. - nodes = {} - for item in items: - node = item.copy() - node["children"] = [] - nodes[node["id"]] = node - - roots = [] - for node in nodes.values(): - parent = node.get("parent", {}) - parent_type = parent.get("type") - if parent_type == "workspace": - # No parent beyond workspace i.e., the node is a root. - roots.append(node) - elif parent_type == "page_id": - parent_id = parent.get("page_id") - if parent_id and parent_id in nodes: - nodes[parent_id]["children"].append(node) - else: - roots.append(node) - elif parent_type == "database_id": - parent_id = parent.get("database_id") - if parent_id and parent_id in nodes: - nodes[parent_id]["children"].append(node) - else: - roots.append(node) - else: - # Fallback: if parent's type is missing or unrecognized, then treat as root. - roots.append(node) - - def prune_node(node: dict) -> dict: - """Get rid of all of the unnecessary fields in a node""" - pruned_node = { - "id": node["id"], - "title": node["title"], - "type": node["object"], - "url": node["url"], - } - if node.get("children"): - pruned_node["children"] = [prune_node(child) for child in node["children"]] - - return pruned_node - - return {"workspace": [prune_node(root) for root in roots]} diff --git a/toolkits/notion/conftest.py b/toolkits/notion/conftest.py deleted file mode 100644 index 91849416..00000000 --- a/toolkits/notion/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) diff --git a/toolkits/notion/evals/constants.py b/toolkits/notion/evals/constants.py deleted file mode 100644 index 8b3cb4bb..00000000 --- a/toolkits/notion/evals/constants.py +++ /dev/null @@ -1,65 +0,0 @@ -SMALL_PAGE_CONTENT = """## Why Build Tools with Arcade? - -Arcade solves key challenges for agent developers: - -1. **Auth Native to Agents**: Authentication designed for agentic workflows — the right token is always available for each user without complex integration work. - -2. **Multi-Tenant Tool Calling**: Enable your agent to take actions AS the specific user of the agent - -3. **Better Agent Capabilities**: Build tools that securely connect to the services your users want your agent to integrate with (Gmail, Slack, Google Drive, Zoom, etc.) without complex integration code. - -4. **Clean Codebase**: Eliminate environment variables full of API keys and complex OAuth implementations from your application code. - -5. **Flexible Integration**: Choose your integration approach: - - - LLM API for the simplest experience with hundreds of pre-built tools - - Tools API for direct execution control - - Auth API for authentication-only integration - - Framework connectors for LangChain, CrewAI and others - -6. **Zero Schema Maintenance**: Tool definitions generate automatically from code annotations and translate to any LLM format. - -7. **Built-in Evaluation**: Evaluate your tools across user scenarios, llms, and context with Arcade's tool calling evaluation framework. Ensure your tools are working as expected and are useful for your agents. - -8. **Complete Tooling Ecosystem**: Built-in evaluation framework, scalable execution infrastructure, and flexible deployment options (including VPC, Docker, and Kubernetes). - -Arcade lets you focus on creating useful tool functionality rather than solving complex authentication, deployment, and integration challenges. -""" # noqa: E501 - -# A conversation where a user asks the AI to get the content of a page named 'Arcade Notes' -GET_SMALL_PAGE_CONTENT_CONVERSATION = [ - {"role": "user", "content": "get 'Arcade Notes'"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_92Vhl75I8KEKQfjihS7l53DL", - "type": "function", - "function": { - "name": "Notion_GetPageContentByTitle", - "arguments": '{"title":"Arcade Notes"}', - }, - } - ], - }, - { - "role": "tool", - "content": "# Arcade Notes\nFirst, make sure you have these pre-requisites installed on your system: \n- **Python 3.10**\xa0or higherVerify your Python version by running\xa0`python --version`\xa0or\xa0`python3 --version`\xa0in your terminal. \n- **pip**: The Python package installer should be available. It's typically included with Python. \n- **Arcade Account**: Sign up for an\xa0[Arcade account](https://api.arcade.dev/signup?utm_source=docs&utm_medium=page&utm_campaign=custom-tools)\xa0if you haven't already. \nLet's set up Arcade and give it a try! \n### **Obtain an API key** \nInstall the Arcade CLI and SDK and log in. Your Arcade API key will be printed to the console as well as saved to\xa0`~/.arcade/credentials.yaml`. \npip install arcade-aiarcade login\n### **Try\xa0**`arcade chat` \nWith Arcade CLI installed, you can test outour API with the\xa0`arcade chat`\xa0command: \narcade chat\nThis launches a chat with the Arcade Cloud Engine (hosted at\xa0`api.arcade.dev`). All pre-built Arcade tools are available to use. \nFor example, try asking: \nstar the ArcadeAI/arcade-ai repo on Github\nArcade will ask you to authorize with GitHub, and then the AI assistant will star the\xa0[ArcadeAI/arcade-ai](https://github.com/ArcadeAI/arcade-ai)\xa0repo on your behalf. \nYou'll see output similar to this: \nAssistant (gpt-4o):I starred the ArcadeAI/arcade-ai repo on Github for you!\nYou can use Ctrl-C to exit the chat at any time. \n### **Try more tools** \nTry prompting the AI with different requests: \n- Search the web to find when The Winds of Winter will be released \n- Summarize my emails from today \n- What's playing on my Spotify right now? \nTo see all tools available in the Cloud, run: \narcade show\nGreat job! 🎉 You've set up Arcade and used your first tool. \n### **Next steps** \n- [Learn about tools](https://docs.arcade.dev/home/use-tools/tools-overview)\xa0andhow they work \n- Learn how to\xa0[call tools with models](https://docs.arcade.dev/home/use-tools/call-tools-with-models) \n- Or, jump ahead to building your own\xa0[custom tools](https://docs.arcade.dev/home/build-tools/create-a-toolkit) \n", # noqa: E501 - "tool_call_id": "call_92Vhl75I8KEKQfjihS7l53DL", - "name": "Notion_GetPageContentByTitle", - }, - { - "role": "assistant", - "content": "Here are the notes on setting up and using Arcade:\n\n### Pre-requisites\nMake sure you have the following installed on your system:\n-**Python 3.10 or higher**: Verify using `python --version` or `python3 --version` in your terminal.\n- **pip**: The Python package installer, typically included with Python.\n- **Arcade Account**: Sign up for an [Arcade account](https://api.arcade.dev/signup?utm_source=docs&utm_medium=page&utm_campaign=custom-tools) if you haven't already.\n\n### Setting up Arcade\n\n#### Obtain an API key\n1. Install the Arcade CLI and SDK.\n2. Log in to Arcade: Run the command `arcade login`.\n3. Your Arcade API key will be printed to the console and saved in `~/.arcade/credentials.yaml`.\n\n#### Try `arcade chat`\n- With Arcade CLI installed, you can test the API using `arcade chat`.\n- This command launches a chat with the Arcade Cloud Engine (hosted at `api.arcade.dev`), giving you access to all pre-built Arcade tools.\n \nExample prompt: \n- Ask to star the `ArcadeAI/arcade-ai` repo on GitHub. The AI assistant will star the repo for you after authorization.\n\nEvent example output:\n```\nAssistant (gpt-4o): I starred the ArcadeAI/arcade-ai repo on Github for you!\n```\n\nUse `Ctrl-C` to exit the chat anytime.\n\n### Explore More Tools\n- Try different requests like searching the web, summarizing emails, or checking your current Spotify play.\n- Run `arcade show` to see all available tools in the Cloud.\n\n### Next Steps\n- [Learn about tools](https://docs.arcade.dev/home/use-tools/tools-overview) and how they function.\n- Understand how to [call tools with models](https://docs.arcade.dev/home/use-tools/call-tools-with-models).\n- Begin building your own [custom tools](https://docs.arcade.dev/home/build-tools/create-a-toolkit).\n\nCongratulations on setting up Arcade and trying your first tool! 🎉", # noqa: E501 - }, -] - -SMALL_PAGE_CONTENT_TO_APPEND = """ -# Next steps -Here are the next steps -1. Add ability to append to the end of a page -2. Make it Pythonic -## Next next steps -**Write** [evals](https://github.com/ArcadeAI/arcade-ai) -""" diff --git a/toolkits/notion/evals/eval_notion_pages.py b/toolkits/notion/evals/eval_notion_pages.py deleted file mode 100644 index f531428f..00000000 --- a/toolkits/notion/evals/eval_notion_pages.py +++ /dev/null @@ -1,238 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_notion_toolkit -from arcade_notion_toolkit.tools import ( - append_content_to_end_of_page, - create_page, - get_page_content_by_id, - get_page_content_by_title, -) -from evals.constants import ( - GET_SMALL_PAGE_CONTENT_CONVERSATION, - SMALL_PAGE_CONTENT, - SMALL_PAGE_CONTENT_TO_APPEND, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_notion_toolkit) - - -@tool_eval() -def create_page_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools creating a Notion page""" - suite = EvalSuite( - name="Notion Create Page Evaluation", - system_message=( - "You are an AI assistant that has access to the user's Notion workspace. " - "You can take actions on the user's Notion workspace on behalf of the user." - ), - catalog=catalog, - rubric=rubric, - ) - - # Easy case - suite.add_case( - name="Create page easy difficulty", - user_message=( - "Create a page with the title '07/11/2027' and content '* drank a slurpie' " - "under the parent page 'Daily Standup'." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_page, - args={ - "parent_title": "Daily Standup", - "title": "07/11/2027", - "content": "* drank a slurpie", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="parent_title", weight=0.34), - SimilarityCritic(critic_field="title", weight=0.33, similarity_threshold=0.95), - SimilarityCritic(critic_field="content", weight=0.33, similarity_threshold=0.95), - ], - ) - - # Medium case - suite.add_case( - name="Create page medium difficulty", - user_message=( - f"Create a page with the title 'Why Use Arcade?' and content {SMALL_PAGE_CONTENT}" - "under the parent page 'Arcade Notes'." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_page, - args={ - "parent_title": "Arcade Notes", - "title": "Why Use Arcade?", - "content": SMALL_PAGE_CONTENT, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="parent_title", weight=0.34), - SimilarityCritic(critic_field="title", weight=0.33, similarity_threshold=0.95), - SimilarityCritic(critic_field="content", weight=0.33, similarity_threshold=0.95), - ], - ) - - # Hard case - suite.add_case( - name="Create page hard difficulty", - user_message=(f"Add {SMALL_PAGE_CONTENT} as a subpage. Name it 'Why Use Arcade?'"), - expected_tool_calls=[ - ExpectedToolCall( - func=create_page, - args={ - "parent_title": "Arcade Notes", - "title": "Why Use Arcade?", - "content": SMALL_PAGE_CONTENT, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="parent_title", weight=0.34), - SimilarityCritic(critic_field="title", weight=0.33, similarity_threshold=0.95), - SimilarityCritic(critic_field="content", weight=0.33, similarity_threshold=0.95), - ], - additional_messages=GET_SMALL_PAGE_CONTENT_CONVERSATION, - ) - return suite - - -@tool_eval() -def get_page_content_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting the content of a Notion page""" - suite = EvalSuite( - name="Notion Get Page Content By ID Evaluation", - system_message=( - "You are an AI assistant that has access to the user's Notion workspace. " - "You can take actions on the user's Notion workspace on behalf of the user." - ), - catalog=catalog, - rubric=rubric, - ) - - # Easy case - suite.add_case( - name="Get page content by id easy difficulty", - user_message="Get the content of the page with id 1b37a62b04d48079a902ce69ed7e7240", - expected_tool_calls=[ - ExpectedToolCall( - func=get_page_content_by_id, - args={ - "page_id": "1b37a62b04d48079a902ce69ed7e7240", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="page_id", weight=1), - ], - ) - - # Medium case - suite.add_case( - name="Get page content medium difficulty", - user_message=( - "Summarize the main points in 1b37a62b04d48079a902ce69ed7e7240. " - "Also, does 'Tool Calling with Arcade' actually talk about tools?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_page_content_by_id, - args={ - "page_id": "1b37a62b04d48079a902ce69ed7e7240", - }, - ), - ExpectedToolCall( - func=get_page_content_by_title, - args={ - "title": "Tool Calling with Arcade", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="page_id", weight=0.5), - BinaryCritic(critic_field="title", weight=0.5), - ], - ) - - # Hard case - suite.add_case( - name="Get page content hard difficulty", - user_message=( - "Compare it's main points against 'Tool Calling with Arcade' and " - "'Tool Execution with Arcade'" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_page_content_by_title, - args={ - "title": "Tool Calling with Arcade", - }, - ), - ExpectedToolCall( - func=get_page_content_by_title, - args={ - "title": "Tool Execution with Arcade", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="title", weight=1), - ], - additional_messages=GET_SMALL_PAGE_CONTENT_CONVERSATION, - ) - - return suite - - -@tool_eval() -def append_page_content_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools appending content to an existing Notion page""" - suite = EvalSuite( - name="Notion Append Content To End Of Page", - system_message=( - "You are an AI assistant that has access to the user's Notion workspace. " - "You can take actions on the user's Notion workspace on behalf of the user." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Append page content", - user_message=f"Add this to the end of that page:\n{SMALL_PAGE_CONTENT_TO_APPEND}", - expected_tool_calls=[ - ExpectedToolCall( - func=append_content_to_end_of_page, - args={ - "page_id_or_title": "Arcade Notes", - "content": SMALL_PAGE_CONTENT_TO_APPEND, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="page_id_or_title", weight=0.5), - SimilarityCritic(critic_field="content", weight=0.5, similarity_threshold=0.95), - ], - additional_messages=GET_SMALL_PAGE_CONTENT_CONVERSATION, - ) - return suite diff --git a/toolkits/notion/evals/eval_notion_search.py b/toolkits/notion/evals/eval_notion_search.py deleted file mode 100644 index 6aa202ad..00000000 --- a/toolkits/notion/evals/eval_notion_search.py +++ /dev/null @@ -1,254 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_notion_toolkit -from arcade_notion_toolkit.enums import ObjectType -from arcade_notion_toolkit.tools import ( - get_object_metadata, - get_workspace_structure, - search_by_title, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_notion_toolkit) - -# Additional messages -TOOL_CALLING_CONVERSATION = [ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "Hello! How can I assist you today?"}, - {"role": "user", "content": "explain LLM tool calling"}, - { - "role": "assistant", - "content": "Tool calling in the context of Large Language Models (LLMs) refers to the process where the model uses external tools, plugins, orfunctions to gather, process, or enhance information. This allows the model to access more current or specific data and perform actions that are beyond itstrained capabilities.\n\nHere's a breakdown of how this works:\n\n1. **Identification of Needs**: The LLM can identify when a task or query requires dataor actions that are not within its built-in knowledge. This could be due to the data being too recent, too specialized, or involving actions that requireinterfacing with external systems.\n\n2. **Tool Selection**: The LLM selects appropriate tools or functions that are registered and permitted for use. Thisselection is based on the task's requirements, such as retrieving data, performing calculations, or managing content.\n\n3. **Parameter Configuration**:The LLM prepares the necessary input parameters for the tools. These parameters tell the tools what specific actions to take or what data toretrieve.\n\n4. **Execution**: The tools are executed. Some tools run asynchronously, allowing the LLM to perform other tasks while waiting for theresults.\n\n5. **Result Integration**: The LLM integrates the results from the tools back into the conversation or task. It uses this information to answerquestions, perform further analysis, or update content as needed.\n\n6. **Feedback Loop**: Often, the results are looped back into the model's reasoningprocess, which can then adjust its line of questioning or actions based on the new data.\n\nThis system enhances the LLM's flexibility, making itapplicable to a wider range of real-world applications by utilizing up-to-date and specialized information through these tools.", # noqa: E501 - }, -] - - -@tool_eval() -def search_by_title_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools searching for objects by title.""" - suite = EvalSuite( - name="Notion Search Tools Evaluation", - system_message=( - "You are an AI assistant that has access to the user's Notion workspace. " - "You can take actions on the user's Notion workspace on behalf of the user." - ), - catalog=catalog, - rubric=rubric, - ) - - # Easy case - suite.add_case( - name="Search by title easy difficulty", - user_message="Search for my page with the title 'Daily Standup'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_by_title, - args={ - "query": "Daily Standup", - "select": ObjectType.PAGE, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.6, similarity_threshold=0.95), - BinaryCritic(critic_field="select", weight=0.4), - ], - ) - - # Medium case - suite.add_case( - name="Search by title medium difficulty", - user_message=( - "so i was just thinking about LLMs and how to create an agent. " - "I remember that tools are important for some reason. " - "do i have a page or db about tool calling?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=search_by_title, - args={ - "query": "tool calling", - "select": None, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.6), - BinaryCritic(critic_field="select", weight=0.4), - ], - ) - - # Hard case - suite.add_case( - name="Search by title hard difficulty", - user_message=( - "do i have any notes about any of those breakdown points? " - "Actually, do I have any notes about the 2nd, 3rd, or 5th points?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=search_by_title, - args={ - "query": "Tool Selection", - "select": None, - }, - ), - ExpectedToolCall( - func=search_by_title, - args={ - "query": "Parameter Configuration", - "select": None, - }, - ), - ExpectedToolCall( - func=search_by_title, - args={ - "query": "Result Integration", - "select": None, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.8), - BinaryCritic(critic_field="select", weight=0.2), - ], - additional_messages=TOOL_CALLING_CONVERSATION, - ) - - return suite - - -@tool_eval() -def get_object_metadata_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting object metadata.""" - suite = EvalSuite( - name="Notion Get Object Metadata Evaluation", - system_message=( - "You are an AI assistant that has access to the user's Notion workspace. " - "You can take actions on the user's Notion workspace on behalf of the user." - ), - catalog=catalog, - rubric=rubric, - ) - - # Easy case - suite.add_case( - name="Get object metadata easy difficulty", - user_message="Get any metadata about my page with the title 'Daily Standup'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_object_metadata, - args={ - "object_title": "Daily Standup", - "object_type": ObjectType.PAGE, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="object_title", weight=0.8, similarity_threshold=0.95), - BinaryCritic(critic_field="object_type", weight=0.2), - ], - ) - - # Medium case - suite.add_case( - name="Get object metadata medium difficulty", - user_message="Get the id, url, and last edited time of 'Daily Standup'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_object_metadata, - args={ - "object_title": "Daily Standup", - "object_type": None, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="object_title", weight=0.8, similarity_threshold=0.95), - BinaryCritic(critic_field="object_type", weight=0.2), - ], - ) - - # Hard case - suite.add_case( - name="Get object metadata hard difficulty", - user_message=( - "oh I have page about that second point. " - "This page here https://www.notion.so/be633bf1dfa0436db259571129a590e5. " - "When was it created?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_object_metadata, - args={ - "object_id": "be633bf1dfa0436db259571129a590e5", - "object_type": ObjectType.PAGE, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="object_id", weight=0.8), - BinaryCritic(critic_field="object_type", weight=0.2), - ], - additional_messages=TOOL_CALLING_CONVERSATION, - ) - - return suite - - -@tool_eval() -def get_workspace_structure_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting the workspace structure.""" - suite = EvalSuite( - name="Notion Get Workspace Structure Evaluation", - system_message=( - "You are an AI assistant that has access to the user's Notion workspace. " - "You can take actions on the user's Notion workspace on behalf of the user." - ), - catalog=catalog, - rubric=rubric, - ) - - # Easy case - suite.add_case( - name="Get workspace structure easy difficulty", - user_message="Get my workspace tree structure", - expected_tool_calls=[ - ExpectedToolCall(func=get_workspace_structure, args={}), - ], - ) - - # Medium case - suite.add_case( - name="Get workspace structure medium difficulty", - user_message="I'm trying to figure out where my 'Daily Standup' page is. " - "Can you help me find it?", - expected_tool_calls=[ - ExpectedToolCall(func=get_workspace_structure, args={}), - ], - ) - - # Hard case - suite.add_case( - name="Get workspace structure hard difficulty", - user_message="list pages that are subpages of my 'Daily Standup' page", - expected_tool_calls=[ - ExpectedToolCall(func=get_workspace_structure, args={}), - ], - ) - return suite diff --git a/toolkits/notion/pyproject.toml b/toolkits/notion/pyproject.toml deleted file mode 100644 index 2b45dd6d..00000000 --- a/toolkits/notion/pyproject.toml +++ /dev/null @@ -1,56 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_notion_toolkit" -version = "0.2.0" -description = "Arcade.dev LLM tools for Notion" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_notion_toolkit/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_notion_toolkit",] diff --git a/toolkits/notion/tests/__init__.py b/toolkits/notion/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/notion/tests/test_block_to_markdown_converter.py b/toolkits/notion/tests/test_block_to_markdown_converter.py deleted file mode 100644 index 8d4c9434..00000000 --- a/toolkits/notion/tests/test_block_to_markdown_converter.py +++ /dev/null @@ -1,163 +0,0 @@ -import pytest - -from arcade_notion_toolkit.block_to_markdown_converter import BlockToMarkdownConverter - - -@pytest.mark.asyncio -async def test_convert_paragraph(): - block = { - "type": "paragraph", - "paragraph": { - "rich_text": [ - { - "plain_text": "Hello, world!", - "annotations": { - "bold": False, - "italic": False, - "code": False, - "strikethrough": False, - "underline": False, - "color": "default", - }, - "text": {"content": "Hello, world!", "link": None}, - "type": "text", - } - ], - "color": "default", - }, - } - converter = BlockToMarkdownConverter(context=None) - result = await converter.convert_block(block) - assert result == "Hello, world! \n" - - -@pytest.mark.asyncio -async def test_convert_heading_1(): - block = { - "type": "heading_1", - "heading_1": { - "rich_text": [ - { - "plain_text": "Heading Test", - "annotations": { - "bold": True, - "italic": False, - "code": False, - "strikethrough": False, - "underline": False, - "color": "default", - }, - "text": {"content": "Heading Test", "link": None}, - "type": "text", - } - ], - "color": "default", - }, - } - converter = BlockToMarkdownConverter(context=None) - result = await converter.convert_block(block) - expected = "# **Heading Test** \n" - assert result == expected - - -@pytest.mark.asyncio -async def test_convert_bulleted_list_item(): - block = { - "type": "bulleted_list_item", - "bulleted_list_item": { - "rich_text": [ - { - "plain_text": "list item", - "annotations": { - "bold": False, - "italic": False, - "code": False, - "strikethrough": False, - "underline": False, - "color": "default", - }, - "text": {"content": "list item", "link": None}, - "type": "text", - } - ], - "color": "default", - }, - } - converter = BlockToMarkdownConverter(context=None) - result = await converter.convert_block(block) - expected = "- list item \n" - assert result == expected - - -@pytest.mark.asyncio -async def test_convert_equation(): - block = {"type": "equation", "equation": {"expression": "x+1=2"}} - converter = BlockToMarkdownConverter(context=None) - result = await converter.convert_block(block) - expected = "$$ x+1=2 $$ \n" - assert result == expected - - -@pytest.mark.asyncio -async def test_convert_child_page(monkeypatch): - block = { - "type": "child_page", - "id": "child123", - "child_page": { - "rich_text": [ - { - "plain_text": "Child Title", - "annotations": { - "bold": False, - "italic": False, - "code": False, - "strikethrough": False, - "underline": False, - "color": "default", - }, - "text": {"content": "Child Title", "link": None}, - "type": "text", - } - ], - "title": "Child Title", - }, - } - - async def fake_get_page_url(context, block_id): - return f"http://example.com/{block_id}" - - monkeypatch.setattr( - "arcade_notion_toolkit.block_to_markdown_converter.get_page_url", fake_get_page_url - ) - converter = BlockToMarkdownConverter(context=None) - result = await converter.convert_block(block) - expected = "[Child Title](http://example.com/child123) \n" - assert result == expected - - -@pytest.mark.asyncio -async def test_fallback_plaintext(): - block = { - "type": "unsupported-type", - "unsupported-type": { - "rich_text": [ - { - "plain_text": "Fallback text", - "annotations": { - "bold": False, - "italic": False, - "code": False, - "strikethrough": False, - "underline": False, - "color": "default", - }, - "text": {"content": "Fallback text", "link": None}, - "type": "text", - } - ] - }, - } - converter = BlockToMarkdownConverter(context=None) - result = await converter.convert_block(block) - expected = "Fallback text" - assert result == expected diff --git a/toolkits/notion/tests/test_tools_pages.py b/toolkits/notion/tests/test_tools_pages.py deleted file mode 100644 index 6c03c3f4..00000000 --- a/toolkits/notion/tests/test_tools_pages.py +++ /dev/null @@ -1,246 +0,0 @@ -import pytest - -# Simulates a single block with no children -fake_get_next_page_simple = ( - { - "results": [ - { - "object": "block", - "id": "block1", - "has_children": False, - "type": "paragraph", - "paragraph": { - "rich_text": [ - { - "plain_text": "Hello World", - "type": "text", - "text": {"content": "Hello World", "link": None}, - "annotations": { - "bold": False, - "italic": False, - "underline": False, - "strikethrough": False, - "code": False, - "color": "default", - }, - "href": None, - } - ] - }, - } - ] - }, - False, - None, -) - -# Simulates a parent block with a child block -fake_get_next_page_nested = ( - { - "results": [ - { - "object": "block", - "id": "parent_block", - "has_children": True, - "type": "paragraph", - "paragraph": { - "rich_text": [ - { - "plain_text": "Parent Block", - "type": "text", - "text": {"content": "Parent Block", "link": None}, - "annotations": { - "bold": False, - "italic": False, - "underline": False, - "strikethrough": False, - "code": False, - "color": "default", - }, - "href": None, - } - ] - }, - } - ] - }, - False, - None, -) - -fake_get_next_page_parent_block = ( - { - "results": [ - { - "object": "block", - "id": "child_block", - "has_children": False, - "type": "paragraph", - "paragraph": { - "rich_text": [ - { - "plain_text": "Child Block", - "type": "text", - "text": {"content": "Child Block", "link": None}, - "annotations": { - "bold": False, - "italic": False, - "underline": False, - "strikethrough": False, - "code": False, - "color": "default", - }, - "href": None, - } - ] - }, - } - ] - }, - False, - None, -) - - -@pytest.fixture -def setup_notion_pages(monkeypatch): - from arcade_notion_toolkit.tools import pages - - monkeypatch.setattr(pages, "get_headers", lambda ctx: {"Authorization": "Bearer test"}) - monkeypatch.setattr( - pages, "get_url", lambda endpoint, block_id=None: f"https://dummy/{block_id}" - ) - return pages - - -@pytest.mark.asyncio -async def test_get_page_content_by_id_simple(mock_context, monkeypatch, setup_notion_pages): - pages = setup_notion_pages - - # Patch get_object_metadata to return a dummy page with title 'Test Page' - async def fake_get_object_metadata(context, object_id=None, **kwargs): - return { - "id": object_id, - "object": "page", - "properties": {"title": {"title": [{"plain_text": "Test Page"}]}}, - } - - monkeypatch.setattr(pages, "get_object_metadata", fake_get_object_metadata) - - # Patch get_next_page to - async def fake_get_next_page(client, url, headers, params, cursor): - return fake_get_next_page_simple - - monkeypatch.setattr(pages, "get_next_page", fake_get_next_page) - - # Call the function under test - result = await pages.get_page_content_by_id(mock_context, "test_page_id") - expected = "# Test Page\nHello World \n" - assert result == expected - - -@pytest.mark.asyncio -async def test_get_page_content_by_id_nested(mock_context, monkeypatch, setup_notion_pages): - pages = setup_notion_pages - - # Patch get_object_metadata to return a dummy page with title 'Test Nested' - async def fake_get_object_metadata(context, object_id=None, **kwargs): - return { - "id": object_id, - "object": "page", - "properties": {"title": {"title": [{"plain_text": "Test Nested"}]}}, - } - - monkeypatch.setattr(pages, "get_object_metadata", fake_get_object_metadata) - - # Patch get_next_page - async def fake_get_next_page(client, url, headers, params, cursor): - if url == "https://dummy/test_nested": - return fake_get_next_page_nested - elif url == "https://dummy/parent_block": - return fake_get_next_page_parent_block - return ({"results": []}, False, None) - - monkeypatch.setattr(pages, "get_next_page", fake_get_next_page) - - # Call the function under test - result = await pages.get_page_content_by_id(mock_context, "test_nested") - expected = "# Test Nested\nParent Block \n Child Block \n" - assert result == expected - - -@pytest.mark.asyncio -async def test_append_content_to_end_of_page_with_large_content( - mock_context, monkeypatch, setup_notion_pages -): - pages = setup_notion_pages - - # Mock is_page_id to return True - monkeypatch.setattr(pages, "is_page_id", lambda x: True) - - # Create 150 dummy blocks (more than the 100 chunk size) - dummy_blocks = [] - for i in range(150): - dummy_blocks.append({ - "object": "block", - "type": "paragraph", - "paragraph": {"rich_text": [{"type": "text", "text": {"content": f"Block {i}"}}]}, - }) - - # Mock convert_markdown_to_blocks to return our 150 blocks - def fake_convert_markdown_to_blocks(content): - return dummy_blocks - - monkeypatch.setattr(pages, "convert_markdown_to_blocks", fake_convert_markdown_to_blocks) - - # Mock get_page_url to return a dummy URL - async def fake_get_page_url(context, page_id): - return f"https://notion.so/page/{page_id}" - - monkeypatch.setattr(pages, "get_page_url", fake_get_page_url) - - # Track the HTTP requests - request_count = 0 - request_payloads = [] - - class MockResponse: - def raise_for_status(self): - pass - - class MockClient: - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - async def patch(self, url, headers, json): - nonlocal request_count - request_count += 1 - request_payloads.append(json) - return MockResponse() - - # Mock httpx.AsyncClient - monkeypatch.setattr(pages.httpx, "AsyncClient", MockClient) - - _ = await pages.append_content_to_end_of_page( - mock_context, "test_page_id", "Large content that will be converted to 150 blocks" - ) - - # Verify chunking behavior: 150 blocks should be split into 2 requests - # First request: 100 blocks (0-99) - # Second request: 50 blocks (100-149) - assert request_count == 2 - assert len(request_payloads) == 2 - - # Verify first chunk has 100 blocks - first_chunk = request_payloads[0]["children"] - assert len(first_chunk) == 100 - assert first_chunk[0]["paragraph"]["rich_text"][0]["text"]["content"] == "Block 0" - assert first_chunk[99]["paragraph"]["rich_text"][0]["text"]["content"] == "Block 99" - - # Verify second chunk has 50 blocks - second_chunk = request_payloads[1]["children"] - assert len(second_chunk) == 50 - assert second_chunk[0]["paragraph"]["rich_text"][0]["text"]["content"] == "Block 100" - assert second_chunk[49]["paragraph"]["rich_text"][0]["text"]["content"] == "Block 149" diff --git a/toolkits/notion/tests/test_tools_search.py b/toolkits/notion/tests/test_tools_search.py deleted file mode 100644 index bd7f6cb2..00000000 --- a/toolkits/notion/tests/test_tools_search.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - - -@pytest.mark.asyncio -async def test_search_by_title() -> None: - pass diff --git a/toolkits/notion/tests/test_utils.py b/toolkits/notion/tests/test_utils.py deleted file mode 100644 index 010603c1..00000000 --- a/toolkits/notion/tests/test_utils.py +++ /dev/null @@ -1,99 +0,0 @@ -import pytest - -from arcade_notion_toolkit.utils import is_page_id, simplify_search_result - - -@pytest.mark.parametrize( - "item, expected_title", - [ - # Case 1: Database object with top-level "title" - ( - { - "id": "db1", - "object": "database", - "title": [{"plain_text": "Database Title"}], - "parent": {"type": "workspace", "workspace": True}, - "created_time": "2021-01-01T00:00:00.000Z", - "last_edited_time": "2021-01-02T00:00:00.000Z", - "url": "https://notion.so/database/db1", - "public_url": "https://notion.so/database/public/db1", - }, - "Database Title", - ), - # Case 2: Page object with properties "Title" - ( - { - "id": "page1", - "object": "page", - "properties": { - "Income Item": { - "id": "title", - "title": [ - { - "annotations": { - "bold": False, - "code": False, - "color": "default", - "italic": False, - "strikethrough": False, - "underline": False, - }, - "href": None, - "plain_text": "Page title with database parent", - "text": { - "content": "Page title with database parent", - "link": None, - }, - "type": "text", - } - ], - "type": "title", - }, - }, - "parent": {"database_id": "db1"}, - "created_time": "2021-01-03T00:00:00.000Z", - "last_edited_time": "2021-01-04T00:00:00.000Z", - "url": "https://notion.so/page/page1", - "public_url": "https://notion.so/page/public/page1", - }, - "Page title with database parent", - ), - # Case 3: Page object with properties "title" - ( - { - "id": "page2", - "object": "page", - "properties": {"title": {"title": [{"plain_text": "Page Title from title prop"}]}}, - "parent": {"page_id": "parent_id"}, - "created_time": "2021-01-05T00:00:00.000Z", - "last_edited_time": "2021-01-06T00:00:00.000Z", - "url": "https://notion.so/page/page2", - "public_url": "https://notion.so/page/public/page2", - }, - "Page Title from title prop", - ), - ], -) -def test_simplify_search_result(item, expected_title): - simplified = simplify_search_result(item) - assert simplified["title"] == expected_title - assert simplified["id"] == item.get("id") - assert simplified["object"] == item.get("object") - assert simplified["parent"] == item.get("parent") - assert simplified["created_time"] == item.get("created_time") - assert simplified["last_edited_time"] == item.get("last_edited_time") - assert simplified["url"] == item.get("url") - assert simplified["public_url"] == item.get("public_url") - - -@pytest.mark.parametrize( - "candidate, expected_result", - [ - ("1ae7a62b04d480cd8f30fe64b5354cc0", True), - ("1b37a62b-04d4-8079-a902-ce69ed7e7240", True), - ("1b37a62b04d48079ad7e7240", False), - ("OAuth 2 In Plain English", False), - ], -) -def test_is_page_id(candidate, expected_result): - assert is_page_id(candidate) == expected_result diff --git a/toolkits/outlook_calendar/.pre-commit-config.yaml b/toolkits/outlook_calendar/.pre-commit-config.yaml deleted file mode 100644 index 7bbdadc3..00000000 --- a/toolkits/outlook_calendar/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/outlook_calendar/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/outlook_calendar/.ruff.toml b/toolkits/outlook_calendar/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/outlook_calendar/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/outlook_calendar/LICENSE b/toolkits/outlook_calendar/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/outlook_calendar/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/outlook_calendar/Makefile b/toolkits/outlook_calendar/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/outlook_calendar/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/__init__.py b/toolkits/outlook_calendar/arcade_outlook_calendar/__init__.py deleted file mode 100644 index 430f39c2..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_outlook_calendar.tools import ( - create_event, - get_event, - list_events_in_time_range, -) - -__all__ = ["create_event", "get_event", "list_events_in_time_range"] diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/_utils.py b/toolkits/outlook_calendar/arcade_outlook_calendar/_utils.py deleted file mode 100644 index c6ce1577..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/_utils.py +++ /dev/null @@ -1,225 +0,0 @@ -import re -from datetime import datetime -from typing import Any - -import pytz -from arcade_tdk.errors import ToolExecutionError -from kiota_abstractions.base_request_configuration import RequestConfiguration -from kiota_abstractions.headers_collection import HeadersCollection -from msgraph import GraphServiceClient -from msgraph.generated.users.item.mailbox_settings.mailbox_settings_request_builder import ( - MailboxSettingsRequestBuilder, -) - -from arcade_outlook_calendar.constants import WINDOWS_TO_IANA - - -def validate_date_times(start_date_time: str, end_date_time: str) -> None: - """ - Validate date times are in ISO 8601 format and - that end time is after start time (ignoring timezone offsets). - - Args: - start_date_time: The start date time string to validate. - end_date_time: The end date time string to validate. - - Raises: - ValueError: If the date times are not in ISO 8601 format - ToolExecutionError: If end time is not after start time. - - Note: - This function ignores timezone offsets. - """ - # parse into offset-aware datetimes - start_aware = datetime.fromisoformat(start_date_time) - end_aware = datetime.fromisoformat(end_date_time) - - # drop tzinfo to treat both as naïve local times - start_naive = start_aware.replace(tzinfo=None) - end_naive = end_aware.replace(tzinfo=None) - - if start_naive >= end_naive: - raise ToolExecutionError( - message="Start time must be before end time", - developer_message=( - f"The start time '{start_naive}' is not before the end time '{end_naive}'" - ), - ) - - -def prepare_meeting_body( - body: str, custom_meeting_url: str | None, is_online_meeting: bool -) -> tuple[str, bool]: - """Prepare meeting body and determine final online meeting status. - - Args: - body: The original meeting body text - custom_meeting_url: Custom URL for the meeting, if one exists - is_online_meeting: Whether this should be an online meeting - - Returns: - tuple: (Updated meeting body, final online meeting status) - - Note: - If a custom meeting URL is provided, is_online_meeting will be set to False - to prevent Microsoft from generating its own meeting URL. The custom meeting - URL will then be added to the body of the meeting. - """ - is_online_meeting = not custom_meeting_url and is_online_meeting - - if custom_meeting_url: - body = f"""{body}\n -......................................................................... -Join online meeting -{custom_meeting_url}""" - - return body, is_online_meeting - - -def validate_emails(emails: list[str]) -> None: - """Validate a list of email addresses. - - Args: - emails: The list of email addresses to validate. - - Raises: - ToolExecutionError: If any email address is invalid. - """ - invalid_emails = [] - for email in emails: - if not is_valid_email(email): - invalid_emails.append(email) - if invalid_emails: - raise ToolExecutionError(message=f"Invalid email address(es): {', '.join(invalid_emails)}") - - -def is_valid_email(email: str) -> bool: - """Simple check to see if an email address is valid. - - Args: - email: The email address to check. - - Returns: - True if the email address is valid, False otherwise. - """ - pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" - return re.match(pattern, email) is not None - - -def remove_timezone_offset(date_time: str) -> str: - """Remove the timezone offset from the date_time string.""" - return re.sub(r"[+-][0-9]{2}:[0-9]{2}$|Z$", "", date_time) - - -def replace_timezone_offset(date_time: str, time_zone_offset: str) -> str: - """Replace the timezone offset in the date_time string with the time_zone_offset. - - If the date_time str already contains a timezone offset, it will be replaced. - If the date_time str does not contain a timezone offset, the time_zone_offset will be appended - - Args: - date_time: The date_time string to replace the timezone offset in. - time_zone_offset: The timezone offset to replace the existing timezone offset with. - - Returns: - The date_time string with the timezone offset replaced or appended. - """ - date_time = remove_timezone_offset(date_time) - return f"{date_time}{time_zone_offset}" - - -def convert_timezone_to_offset(time_zone: str) -> str: - """ - Convert a timezone (Windows or IANA) to ISO 8601 offset. - First tries Windows timezone format, then IANA, then falls back to UTC if both fail. - - Args: - time_zone: The timezone (Windows or IANA) to convert to ISO 8601 offset. - - Returns: - The timezone offset in ISO 8601 format (e.g. '+08:00', '-07:00', or 'Z' for UTC) - """ - # Try Windows timezone format - iana_timezone = WINDOWS_TO_IANA.get(time_zone) - if iana_timezone: - try: - tz = pytz.timezone(iana_timezone) - now = datetime.now(tz) - tz_offset = now.strftime("%z") - - if len(tz_offset) == 5: # +HHMM format - tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format - return tz_offset # noqa: TRY300 - except (pytz.exceptions.UnknownTimeZoneError, ValueError): - pass - - # Try IANA timezone format - try: - tz = pytz.timezone(time_zone) - now = datetime.now(tz) - tz_offset = now.strftime("%z") - - if len(tz_offset) == 5: # +HHMM format - tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format - return tz_offset # noqa: TRY300 - except (pytz.exceptions.UnknownTimeZoneError, ValueError): - # Fallback to UTC - return "Z" - - -async def get_default_calendar_timezone(client: GraphServiceClient) -> str: - """Get the authenticated user's default calendar's timezone. - - Args: - client: The GraphServiceClient to use to get - the authenticated user's default calendar's timezone. - - Returns: - The timezone in "Windows timezone format" or "IANA timezone format". - """ - query_params = MailboxSettingsRequestBuilder.MailboxSettingsRequestBuilderGetQueryParameters( - select=["timeZone"] - ) - request_config = RequestConfiguration( - query_parameters=query_params, - ) - response = await client.me.mailbox_settings.get(request_config) - - if response and response.time_zone: - return response.time_zone - return "UTC" - - -def create_timezone_headers(time_zone: str) -> HeadersCollection: - """ - Create headers with timezone preference. - - Args: - time_zone: The timezone to set in the headers. - - Returns: - Headers collection with timezone preference set. - """ - headers = HeadersCollection() - headers.try_add("Prefer", f'outlook.timezone="{time_zone}"') - return headers - - -def create_timezone_request_config( - time_zone: str, query_parameters: Any | None = None -) -> RequestConfiguration: - """ - Create a request configuration with timezone headers and optional query parameters. - - Args: - time_zone: The timezone to set in the headers. - query_parameters: Optional query parameters to include in the configuration. - - Returns: - Request configuration with timezone headers and optional query parameters. - """ - headers = create_timezone_headers(time_zone) - return RequestConfiguration( - headers=headers, - query_parameters=query_parameters, - ) diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/client.py b/toolkits/outlook_calendar/arcade_outlook_calendar/client.py deleted file mode 100644 index e11d257a..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/client.py +++ /dev/null @@ -1,26 +0,0 @@ -import datetime -from typing import Any - -from azure.core.credentials import AccessToken, TokenCredential -from msgraph import GraphServiceClient - -DEFAULT_SCOPE = "https://graph.microsoft.com/.default" - - -class StaticTokenCredential(TokenCredential): - """Implementation of TokenCredential protocol to be provided to the MSGraph SDK client""" - - def __init__(self, token: str): - self._token = token - - def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken: - # An expiration is required by MSGraph SDK. Set to 1 hour from now. - expires_on = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + 3600 - return AccessToken(self._token, expires_on) - - -def get_client(token: str) -> GraphServiceClient: - """Create and return a MSGraph SDK client, given the provided token.""" - token_credential = StaticTokenCredential(token) - - return GraphServiceClient(token_credential, scopes=[DEFAULT_SCOPE]) diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/constants.py b/toolkits/outlook_calendar/arcade_outlook_calendar/constants.py deleted file mode 100644 index 678e3771..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/constants.py +++ /dev/null @@ -1,138 +0,0 @@ -# Maps "Windows timezone format" to "IANA timezone format" -# Does not include all Windows timezones. -WINDOWS_TO_IANA = { - "Dateline Standard Time": "Etc/GMT+12", - "UTC-11": "Etc/GMT+11", - "Aleutian Standard Time": "America/Adak", - "Hawaiian Standard Time": "Pacific/Honolulu", - "Marquesas Standard Time": "Pacific/Marquesas", - "Alaskan Standard Time": "America/Anchorage", - "UTC-09": "Etc/GMT+9", - "Pacific Standard Time (Mexico)": "America/Tijuana", - "UTC-08": "Etc/GMT+8", - "Pacific Standard Time": "America/Los_Angeles", - "US Mountain Standard Time": "America/Phoenix", - "Mountain Standard Time (Mexico)": "America/Chihuahua", - "Mountain Standard Time": "America/Denver", - "Central America Standard Time": "America/Guatemala", - "Central Standard Time": "America/Chicago", - "Easter Island Standard Time": "Pacific/Easter", - "Central Standard Time (Mexico)": "America/Mexico_City", - "Canada Central Standard Time": "America/Regina", - "SA Pacific Standard Time": "America/Bogota", - "Eastern Standard Time (Mexico)": "America/Cancun", - "Eastern Standard Time": "America/New_York", - "Haiti Standard Time": "America/Port-au-Prince", - "Cuba Standard Time": "America/Havana", - "US Eastern Standard Time": "America/Indianapolis", - "Turks And Caicos Standard Time": "America/Grand_Turk", - "Paraguay Standard Time": "America/Asuncion", - "Atlantic Standard Time": "America/Halifax", - "Venezuela Standard Time": "America/Caracas", - "Central Brazilian Standard Time": "America/Cuiaba", - "SA Western Standard Time": "America/La_Paz", - "Pacific SA Standard Time": "America/Santiago", - "Newfoundland Standard Time": "America/St_Johns", - "Tocantins Standard Time": "America/Araguaina", - "E. South America Standard Time": "America/Sao_Paulo", - "SA Eastern Standard Time": "America/Cayenne", - "Argentina Standard Time": "America/Buenos_Aires", - "Greenland Standard Time": "America/Godthab", - "Montevideo Standard Time": "America/Montevideo", - "Magallanes Standard Time": "America/Punta_Arenas", - "Saint Pierre Standard Time": "America/Miquelon", - "Bahia Standard Time": "America/Bahia", - "UTC-02": "Etc/GMT+2", - "Azores Standard Time": "Atlantic/Azores", - "Cape Verde Standard Time": "Atlantic/Cape_Verde", - "UTC": "Etc/UTC", - "GMT Standard Time": "Europe/London", - "Greenwich Standard Time": "Atlantic/Reykjavik", - "W. Europe Standard Time": "Europe/Berlin", - "Central Europe Standard Time": "Europe/Budapest", - "Romance Standard Time": "Europe/Paris", - "Central European Standard Time": "Europe/Warsaw", - "W. Central Africa Standard Time": "Africa/Lagos", - "Jordan Standard Time": "Asia/Amman", - "GTB Standard Time": "Europe/Bucharest", - "Middle East Standard Time": "Asia/Beirut", - "Egypt Standard Time": "Africa/Cairo", - "E. Europe Standard Time": "Europe/Chisinau", - "Syria Standard Time": "Asia/Damascus", - "West Bank Standard Time": "Asia/Hebron", - "South Africa Standard Time": "Africa/Johannesburg", - "FLE Standard Time": "Europe/Kiev", - "Israel Standard Time": "Asia/Jerusalem", - "Kaliningrad Standard Time": "Europe/Kaliningrad", - "Sudan Standard Time": "Africa/Khartoum", - "Libya Standard Time": "Africa/Tripoli", - "Namibia Standard Time": "Africa/Windhoek", - "Arabic Standard Time": "Asia/Baghdad", - "Turkey Standard Time": "Europe/Istanbul", - "Arab Standard Time": "Asia/Riyadh", - "Belarus Standard Time": "Europe/Minsk", - "Russian Standard Time": "Europe/Moscow", - "E. Africa Standard Time": "Africa/Nairobi", - "Iran Standard Time": "Asia/Tehran", - "Arabian Standard Time": "Asia/Dubai", - "Astrakhan Standard Time": "Europe/Astrakhan", - "Azerbaijan Standard Time": "Asia/Baku", - "Russia Time Zone 3": "Europe/Samara", - "Mauritius Standard Time": "Indian/Mauritius", - "Saratov Standard Time": "Europe/Saratov", - "Georgian Standard Time": "Asia/Tbilisi", - "Volgograd Standard Time": "Europe/Volgograd", - "Caucasus Standard Time": "Asia/Yerevan", - "Afghanistan Standard Time": "Asia/Kabul", - "West Asia Standard Time": "Asia/Tashkent", - "Ekaterinburg Standard Time": "Asia/Yekaterinburg", - "Pakistan Standard Time": "Asia/Karachi", - "India Standard Time": "Asia/Calcutta", - "Sri Lanka Standard Time": "Asia/Colombo", - "Nepal Standard Time": "Asia/Kathmandu", - "Central Asia Standard Time": "Asia/Almaty", - "Bangladesh Standard Time": "Asia/Dhaka", - "Omsk Standard Time": "Asia/Omsk", - "Myanmar Standard Time": "Asia/Rangoon", - "SE Asia Standard Time": "Asia/Bangkok", - "Altai Standard Time": "Asia/Barnaul", - "W. Mongolia Standard Time": "Asia/Hovd", - "North Asia Standard Time": "Asia/Krasnoyarsk", - "N. Central Asia Standard Time": "Asia/Novosibirsk", - "Tomsk Standard Time": "Asia/Tomsk", - "China Standard Time": "Asia/Shanghai", - "North Asia East Standard Time": "Asia/Irkutsk", - "Singapore Standard Time": "Asia/Singapore", - "W. Australia Standard Time": "Australia/Perth", - "Taipei Standard Time": "Asia/Taipei", - "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", - "North Korea Standard Time": "Asia/Pyongyang", - "Aus Central W. Standard Time": "Australia/Eucla", - "Transbaikal Standard Time": "Asia/Chita", - "Tokyo Standard Time": "Asia/Tokyo", - "Korea Standard Time": "Asia/Seoul", - "Yakutsk Standard Time": "Asia/Yakutsk", - "Cen. Australia Standard Time": "Australia/Adelaide", - "AUS Central Standard Time": "Australia/Darwin", - "E. Australia Standard Time": "Australia/Brisbane", - "AUS Eastern Standard Time": "Australia/Sydney", - "West Pacific Standard Time": "Pacific/Port_Moresby", - "Tasmania Standard Time": "Australia/Hobart", - "Vladivostok Standard Time": "Asia/Vladivostok", - "Lord Howe Standard Time": "Australia/Lord_Howe", - "Bougainville Standard Time": "Pacific/Bougainville", - "Russia Time Zone 10": "Asia/Srednekolymsk", - "Magadan Standard Time": "Asia/Magadan", - "Norfolk Standard Time": "Pacific/Norfolk", - "Sakhalin Standard Time": "Asia/Sakhalin", - "Central Pacific Standard Time": "Pacific/Guadalcanal", - "Russia Time Zone 11": "Asia/Kamchatka", - "New Zealand Standard Time": "Pacific/Auckland", - "UTC+12": "Etc/GMT-12", - "Fiji Standard Time": "Pacific/Fiji", - "Chatham Islands Standard Time": "Pacific/Chatham", - "UTC+13": "Etc/GMT-13", - "Tonga Standard Time": "Pacific/Tongatapu", - "Samoa Standard Time": "Pacific/Apia", - "Line Islands Standard Time": "Pacific/Kiritimati", -} diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/models.py b/toolkits/outlook_calendar/arcade_outlook_calendar/models.py deleted file mode 100644 index b65201b9..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/models.py +++ /dev/null @@ -1,288 +0,0 @@ -import re -from dataclasses import dataclass, field -from typing import Any - -from bs4 import BeautifulSoup -from msgraph.generated.models.attendee import Attendee as GraphAttendee -from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.event import Event as GraphEvent -from msgraph.generated.models.event_type import EventType as GraphEventType -from msgraph.generated.models.free_busy_status import FreeBusyStatus as GraphFreeBusyStatus -from msgraph.generated.models.importance import Importance as GraphImportance -from msgraph.generated.models.item_body import ItemBody as GraphItemBody -from msgraph.generated.models.location import Location as GraphLocation -from msgraph.generated.models.recipient import Recipient as GraphRecipient -from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus -from msgraph.generated.models.response_type import ResponseType as GraphResponseType - - -@dataclass -class Attendee: - """An attendee of a calendar event.""" - - name: str = "" - address: str = "" - response: str = "" - - @classmethod - def from_sdk(cls, attendee: GraphAttendee) -> "Attendee": - """Convert a Microsoft Graph SDK Attendee object to an Attendee dataclass.""" - return cls( - name=attendee.email_address.name - if attendee.email_address and attendee.email_address.name - else "", - address=attendee.email_address.address - if attendee.email_address and attendee.email_address.address - else "", - response=attendee.status.response - if attendee.status and attendee.status.response - else "", - ) - - def to_dict(self) -> dict[str, str]: - return { - "name": self.name, - "address": self.address, - "response": self.response, - } - - def to_sdk(self) -> GraphAttendee: - """Convert an Attendee dataclass to a Microsoft Graph SDK Attendee object.""" - return GraphAttendee( - email_address=GraphEmailAddress(name=self.name, address=self.address), - status=GraphResponseStatus( - response=GraphResponseType(self.response) - if self.response - else GraphResponseType.None_ - ), - ) - - -@dataclass -class Organizer: - """The organizer of an event.""" - - name: str = "" - address: str = "" - - @classmethod - def from_sdk(cls, organizer: GraphRecipient) -> "Organizer": - """Convert a Microsoft Graph SDK Organizer object to an Organizer dataclass.""" - return cls( - name=organizer.email_address.name - if organizer.email_address and organizer.email_address.name - else "", - address=organizer.email_address.address - if organizer.email_address and organizer.email_address.address - else "", - ) - - def to_dict(self) -> dict[str, str]: - return { - "name": self.name, - "address": self.address, - } - - def to_sdk(self) -> GraphRecipient: - """Convert an Organizer dataclass to a Microsoft Graph SDK Organizer object.""" - recipient = GraphRecipient( - email_address=GraphEmailAddress(name=self.name, address=self.address) - ) - return recipient - - -@dataclass -class DateTimeTimeZone: - """Time information for an event.""" - - date_time: str = "" - time_zone: str = "" - - @classmethod - def from_sdk(cls, date_time_time_zone: GraphDateTimeTimeZone) -> "DateTimeTimeZone": - """Convert a Microsoft Graph SDK DateTimeTimeZone object to a TimeInfo dataclass.""" - return cls( - date_time=date_time_time_zone.date_time or "", - time_zone=date_time_time_zone.time_zone or "", - ) - - def to_dict(self) -> dict[str, str]: - return { - "dateTime": self.date_time, - "timeZone": self.time_zone, - } - - def to_sdk(self) -> GraphDateTimeTimeZone: - """Convert a TimeInfo dataclass to a Microsoft Graph SDK DateTimeTimeZone object.""" - return GraphDateTimeTimeZone(date_time=self.date_time, time_zone=self.time_zone) - - -@dataclass -class ResponseStatus: - """The response status for an event.""" - - response: str = "" - - @classmethod - def from_sdk(cls, response_status: GraphResponseStatus) -> "ResponseStatus": - """Convert a Microsoft Graph SDK ResponseStatus object to a ResponseStatus dataclass.""" - response_value = ( - str(response_status.response.value) - if response_status.response and hasattr(response_status.response, "value") - else "" - ) - return cls(response=response_value) - - def to_dict(self) -> dict[str, str]: - return { - "response": self.response, - } - - def to_sdk(self) -> GraphResponseStatus: - """Convert a ResponseStatus dataclass to a Microsoft Graph SDK ResponseStatus object.""" - return GraphResponseStatus(response=GraphResponseType(self.response)) - - -@dataclass -class Event: - """A calendar event in Outlook.""" - - attendees: list[Attendee] = field(default_factory=list) - body: str = "" - end: DateTimeTimeZone | None = None - has_attachments: bool = False - importance: str = "" - is_all_day: bool = False - is_cancelled: bool = False - is_draft: bool = False - is_online_meeting: bool = False - is_organizer: bool = False - location: str = "" - online_meeting_url: str = "" - organizer: Organizer | None = None - id: str = "" - response_status: ResponseStatus | None = None - show_as: str = "" - start: DateTimeTimeZone | None = None - subject: str = "" - type: str = "" - web_link: str = "" - event_id: str = "" # The unique identifier of the event. Read-only. - - @staticmethod - def _safe_str(value: Any) -> str: - if not value: - return "" - if isinstance(value, bytes | bytearray): - return value.decode("utf-8", errors="ignore") - return str(value) - - @staticmethod - def _safe_bool(value: Any) -> bool: - return bool(value) - - @staticmethod - def _parse_body(mime: str) -> str: - if not mime: - return "" - soup = BeautifulSoup(mime, "html.parser") - text = soup.get_text(separator=" ") - # Replace multiple newlines with a single newline - text = re.sub(r"\n+", "\n", text) - # Replace multiple spaces with a single space - text = re.sub(r"\s+", " ", text) - # Replace sequences of dots (likely from horizontal lines) with a single newline - text = re.sub(r"\.{3,}", "\n---\n", text) - # Remove leading/trailing whitespace from each line - text = "\n".join(line.strip() for line in text.split("\n")) - return text - - @classmethod - def from_sdk(cls, event: GraphEvent) -> "Event": - """Convert a Microsoft Graph SDK Event object to an Event dataclass.""" - body_mime = event.body.content if event.body and event.body.content else "" - body = cls._parse_body(body_mime) - - attendees = [Attendee.from_sdk(a) for a in event.attendees if a] if event.attendees else [] - start = DateTimeTimeZone.from_sdk(event.start) if event.start else None - end = DateTimeTimeZone.from_sdk(event.end) if event.end else None - organizer = Organizer.from_sdk(event.organizer) if event.organizer else None - response_status = ( - ResponseStatus.from_sdk(event.response_status) if event.response_status else None - ) - - return cls( - attendees=attendees, - body=body, - end=end, - has_attachments=cls._safe_bool(event.has_attachments), - importance=cls._safe_str(str(event.importance.value)) if event.importance else "", - is_all_day=cls._safe_bool(event.is_all_day), - is_cancelled=cls._safe_bool(event.is_cancelled), - is_draft=cls._safe_bool(event.is_draft), - is_online_meeting=cls._safe_bool(event.is_online_meeting), - is_organizer=cls._safe_bool(event.is_organizer), - location=cls._safe_str(event.location.display_name if event.location else ""), - online_meeting_url=cls._safe_str(event.online_meeting_url), - organizer=organizer, - id=cls._safe_str(event.id), - response_status=response_status, - show_as=cls._safe_str(str(event.show_as.value)) if event.show_as else "", - start=start, - subject=cls._safe_str(event.subject), - type=cls._safe_str(str(event.type.value)) if event.type else "", - web_link=cls._safe_str(event.web_link), - event_id=cls._safe_str(event.id), - ) - - def to_dict(self) -> dict[str, Any]: - """Converts the Event dataclass to a dictionary.""" - return { - "attendees": [attendee.to_dict() for attendee in self.attendees], - "body": self.body, - "end": self.end.to_dict() if self.end else None, - "has_attachments": self.has_attachments, - "importance": self.importance, - "is_all_day": self.is_all_day, - "is_cancelled": self.is_cancelled, - "is_draft": self.is_draft, - "is_online_meeting": self.is_online_meeting, - "is_organizer": self.is_organizer, - "location": self.location, - "online_meeting_url": self.online_meeting_url, - "organizer": self.organizer.to_dict() if self.organizer else None, - "id": self.id, - "response_status": self.response_status.to_dict() if self.response_status else None, - "show_as": self.show_as, - "start": self.start.to_dict() if self.start else None, - "subject": self.subject, - "type": self.type, - "web_link": self.web_link, - "event_id": self.event_id, - } - - def to_sdk(self) -> GraphEvent: - """Convert an Event dataclass to a Microsoft Graph SDK Event object.""" - return GraphEvent( - attendees=[attendee.to_sdk() for attendee in self.attendees], - body=GraphItemBody(content=self.body), - end=self.end.to_sdk() if self.end else None, - has_attachments=self.has_attachments, - importance=GraphImportance(self.importance) if self.importance else None, - is_all_day=self.is_all_day, - is_cancelled=self.is_cancelled, - is_draft=self.is_draft, - is_online_meeting=self.is_online_meeting, - is_organizer=self.is_organizer, - location=GraphLocation(display_name=self.location), - online_meeting_url=self.online_meeting_url, - organizer=self.organizer.to_sdk() if self.organizer else None, - id=self.id, - response_status=self.response_status.to_sdk() if self.response_status else None, - show_as=GraphFreeBusyStatus(self.show_as) if self.show_as else None, - start=self.start.to_sdk() if self.start else None, - subject=self.subject, - type=GraphEventType(self.type) if self.type else None, - web_link=self.web_link, - ) diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/__init__.py b/toolkits/outlook_calendar/arcade_outlook_calendar/tools/__init__.py deleted file mode 100644 index f4dde4c0..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from arcade_outlook_calendar.tools.create_event import create_event -from arcade_outlook_calendar.tools.get_event import get_event -from arcade_outlook_calendar.tools.list_events_in_time_range import ( - list_events_in_time_range, -) - -__all__ = ["create_event", "get_event", "list_events_in_time_range"] diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/create_event.py b/toolkits/outlook_calendar/arcade_outlook_calendar/tools/create_event.py deleted file mode 100644 index 562a1acf..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/create_event.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft - -from arcade_outlook_calendar._utils import ( - create_timezone_request_config, - get_default_calendar_timezone, - prepare_meeting_body, - remove_timezone_offset, - validate_date_times, - validate_emails, -) -from arcade_outlook_calendar.client import get_client -from arcade_outlook_calendar.models import ( - Attendee, - DateTimeTimeZone, - Event, -) - - -@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadWrite"])) -async def create_event( - context: ToolContext, - subject: Annotated[str, "The text of the event's subject (title) line."], - body: Annotated[str, "The body of the event"], - start_date_time: Annotated[ - str, - "The datetime of the event's start, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:00:00", - ], - end_date_time: Annotated[ - str, - "The datetime of the event's end, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:30:00", - ], - location: Annotated[str | None, "The location of the event"] = None, - attendee_emails: Annotated[ - list[str] | None, - "The email addresses of the attendees of the event. " - "Must be valid email addresses e.g., username@domain.com.", - ] = None, - is_online_meeting: Annotated[ - bool, "Whether the event is an online meeting. Defaults to False" - ] = False, - custom_meeting_url: Annotated[ - str | None, - "The URL of the online meeting. If not provided and is_online_meeting is True, " - "then a url will be generated for you", - ] = None, -) -> Annotated[dict, "A dictionary containing the created event details"]: - """Create an event in the authenticated user's default calendar. - - Ignores timezone offsets provided in the start_date_time and end_date_time parameters. - Instead, uses the user's default calendar timezone to filter events. - If the user has not set a timezone for their calendar, then the timezone will be UTC. - """ - # Validate & cleanse inputs - validate_emails(attendee_emails or []) - validate_date_times(start_date_time, end_date_time) - body, is_online_meeting = prepare_meeting_body(body, custom_meeting_url, is_online_meeting) - - client = get_client(context.get_auth_token_or_empty()) - - time_zone = await get_default_calendar_timezone(client) - start_date_time = remove_timezone_offset(start_date_time) - end_date_time = remove_timezone_offset(end_date_time) - event = Event( - subject=subject, - body=body, - start=DateTimeTimeZone(date_time=start_date_time, time_zone=time_zone), - end=DateTimeTimeZone(date_time=end_date_time, time_zone=time_zone), - location=location or "", - attendees=[Attendee(address=attendee) for attendee in attendee_emails or []], - is_online_meeting=is_online_meeting, - ).to_sdk() - request_config = create_timezone_request_config(time_zone) - - response = await client.me.events.post(body=event, request_configuration=request_config) - - return Event.from_sdk(response).to_dict() # type: ignore[arg-type] diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/get_event.py b/toolkits/outlook_calendar/arcade_outlook_calendar/tools/get_event.py deleted file mode 100644 index 901ce251..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/get_event.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft - -from arcade_outlook_calendar._utils import ( - create_timezone_request_config, - get_default_calendar_timezone, -) -from arcade_outlook_calendar.client import get_client -from arcade_outlook_calendar.models import Event - - -@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"])) -async def get_event( - context: ToolContext, - event_id: Annotated[str, "The ID of the event to get"], -) -> Annotated[dict, "A dictionary containing the event details"]: - """Get an event by its ID from the user's calendar.""" - client = get_client(context.get_auth_token_or_empty()) - - time_zone = await get_default_calendar_timezone(client) - request_config = create_timezone_request_config(time_zone) - - response = await client.me.events.by_event_id(event_id).get( - request_configuration=request_config - ) - - return Event.from_sdk(response).to_dict() # type: ignore[arg-type] diff --git a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/list_events_in_time_range.py b/toolkits/outlook_calendar/arcade_outlook_calendar/tools/list_events_in_time_range.py deleted file mode 100644 index d33d32fd..00000000 --- a/toolkits/outlook_calendar/arcade_outlook_calendar/tools/list_events_in_time_range.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from msgraph.generated.users.item.calendar.calendar_view.calendar_view_request_builder import ( - CalendarViewRequestBuilder, -) - -from arcade_outlook_calendar._utils import ( - convert_timezone_to_offset, - create_timezone_request_config, - get_default_calendar_timezone, - replace_timezone_offset, - validate_date_times, -) -from arcade_outlook_calendar.client import get_client -from arcade_outlook_calendar.models import Event - - -@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"])) -async def list_events_in_time_range( - context: ToolContext, - start_date_time: Annotated[ - str, - "The start date and time of the time range, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:00:00", - ], - end_date_time: Annotated[ - str, - "The end date and time of the time range, represented in " - "ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:30:00", - ], - limit: Annotated[int, "The maximum number of events to return. Max 1000. Defaults to 10"] = 10, -) -> Annotated[dict, "A dictionary containing a list of events"]: - """List events in the user's calendar in a specific time range. - - Ignores timezone offsets provided in the start_date_time and end_date_time parameters. - Instead, uses the user's default calendar timezone to filter events. - If the user has not set a timezone for their calendar, then the timezone will be UTC. - """ - # Validate inputs - validate_date_times(start_date_time, end_date_time) - - client = get_client(context.get_auth_token_or_empty()) - time_zone = await get_default_calendar_timezone(client) - time_zone_offset = convert_timezone_to_offset(time_zone) - start_date_time = replace_timezone_offset(start_date_time, time_zone_offset) - end_date_time = replace_timezone_offset(end_date_time, time_zone_offset) - query_params = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters( - start_date_time=start_date_time, - end_date_time=end_date_time, - top=max(1, min(limit, 1000)), - ) - request_config = create_timezone_request_config(time_zone, query_params) - - response = await client.me.calendar.calendar_view.get(request_config) - events = [Event.from_sdk(event).to_dict() for event in response.value] # type: ignore[union-attr] - - return {"events": events, "num_events": len(events)} diff --git a/toolkits/outlook_calendar/evals/additional_messages.py b/toolkits/outlook_calendar/evals/additional_messages.py deleted file mode 100644 index ab54780e..00000000 --- a/toolkits/outlook_calendar/evals/additional_messages.py +++ /dev/null @@ -1,28 +0,0 @@ -get_event_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-22, Tuesday."}, - {"role": "user", "content": "show me my meetings for today"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_def456", - "type": "function", - "function": { - "name": "Microsoft_ListEventsInTimeRange", - "arguments": '{"start_date_time":"2025-04-22T00:00:00","end_date_time":"2025-04-22T23:59:59"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"events":[{"attendees":[{"email":"john@example.com","name":"John Smith"},{"email":"alice@example.com","name":"Alice Johnson"}],"body":"Quarterly review meeting","end":{"date_time":"2025-04-22T15:00:00.0000000","time_zone":"Pacific Standard Time"},"has_attachments":true,"id":"AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4","importance":"high","is_all_day":false,"is_cancelled":false,"is_online_meeting":true,"is_organizer":true,"location":"Online","online_meeting_url":"https://teams.microsoft.com/l/meetup-join/meeting_id","organizer":{"email":"user@example.com","name":"User Name"},"start":{"date_time":"2025-04-22T14:00:00.0000000","time_zone":"Pacific Standard Time"},"subject":"Q1 Review","web_link":"https://outlook.office365.com/owa/?itemid=AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4&exvsurl=1&path=/calendar/item"}],"num_events":1}', - "tool_call_id": "call_def456", - "name": "Microsoft_ListEventsInTimeRange", - }, - { - "role": "assistant", - "content": "You have 1 meeting scheduled for today:\n\n1. **Q1 Review** - Today, 2:00 PM - 3:00 PM\n Location: Online\n Attendees: John Smith, Alice Johnson\n This is a high importance meeting with attachments.", - }, -] diff --git a/toolkits/outlook_calendar/evals/eval_create_event.py b/toolkits/outlook_calendar/evals/eval_create_event.py deleted file mode 100644 index b1eefd13..00000000 --- a/toolkits/outlook_calendar/evals/eval_create_event.py +++ /dev/null @@ -1,94 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_outlook_calendar import create_event - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(create_event, "OutlookCalendar") - - -@tool_eval() -def outlook_calendar_create_event_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Calendar create event tool.""" - suite = EvalSuite( - name="Outlook Calendar Create Event Evaluation", - system_message=( - "You are an AI that has access to tools to view and manage calendar events. " - "The current time date and time is April 25, 2025, 5:18 PM PST." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create virtual event", - user_message=( - "schedule a virtual team meeting 'Standup' tomorrow at 3pm for 1 hour. " - "john@example.com and sarah@example.com need to be there" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_event, - args={ - "subject": "Standup", - "start_date_time": "2025-04-26T15:00:00", - "end_date_time": "2025-04-26T16:00:00", - "attendee_emails": ["john@example.com", "sarah@example.com"], - "is_online_meeting": True, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 5), - BinaryCritic(critic_field="start_date_time", weight=1 / 5), - BinaryCritic(critic_field="end_date_time", weight=1 / 5), - BinaryCritic(critic_field="attendee_emails", weight=1 / 5), - BinaryCritic(critic_field="is_online_meeting", weight=1 / 5), - ], - ) - - suite.add_case( - name="Create event with physical location and virtual link", - user_message=( - "schedule a team meeting 'All hands' tomorrow at 3pm for 1 hour. " - "john@example.com and sarah@example.com need to be there. " - "The meeting will be in Conference Room A, but there will be a virtual link " - "for those who cannot attend in person." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_event, - args={ - "subject": "All hands", - "start_date_time": "2025-04-26T15:00:00", - "end_date_time": "2025-04-26T16:00:00", - "location": "Conference Room A", - "attendee_emails": ["john@example.com", "sarah@example.com"], - "is_online_meeting": True, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=1 / 6), - BinaryCritic(critic_field="start_date_time", weight=1 / 6), - BinaryCritic(critic_field="end_date_time", weight=1 / 6), - SimilarityCritic(critic_field="location", weight=1 / 6), - BinaryCritic(critic_field="attendee_emails", weight=1 / 6), - BinaryCritic(critic_field="is_online_meeting", weight=1 / 6), - ], - ) - - return suite diff --git a/toolkits/outlook_calendar/evals/eval_get_event.py b/toolkits/outlook_calendar/evals/eval_get_event.py deleted file mode 100644 index d2274c70..00000000 --- a/toolkits/outlook_calendar/evals/eval_get_event.py +++ /dev/null @@ -1,53 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_outlook_calendar import get_event -from evals.additional_messages import get_event_additional_messages - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(get_event, "OutlookCalendar") - - -@tool_eval() -def outlook_calendar_get_event_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Calendar get event tool.""" - suite = EvalSuite( - name="Outlook Calendar Get Event Evaluation", - system_message=( - "You are an AI that has access to tools to view and manage calendar events. " - "The current time date and time is April 25, 2025, 5:18 PM PST." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get event by id after listing events", - user_message="tell me more about the first event", - expected_tool_calls=[ - ExpectedToolCall( - func=get_event, - args={ - "event_id": "AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="event_id", weight=1.0), - ], - additional_messages=get_event_additional_messages, - ) - - return suite diff --git a/toolkits/outlook_calendar/evals/eval_list_events_in_time_range.py b/toolkits/outlook_calendar/evals/eval_list_events_in_time_range.py deleted file mode 100644 index a917516e..00000000 --- a/toolkits/outlook_calendar/evals/eval_list_events_in_time_range.py +++ /dev/null @@ -1,77 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_outlook_calendar import list_events_in_time_range - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(list_events_in_time_range, "OutlookCalendar") - - -@tool_eval() -def outlook_calendar_list_events_in_time_range_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Calendar list events tool.""" - suite = EvalSuite( - name="Outlook Calendar List Events Evaluation", - system_message=( - "You are an AI that has access to tools to view and manage calendar events. " - "The current time date and time is Friday, April 25, 2025, 5:18 PM PST." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List events in time range", - user_message="what are my meetings on monday", - expected_tool_calls=[ - ExpectedToolCall( - func=list_events_in_time_range, - args={ - "start_date_time": "2025-04-28T00:00:00", - "end_date_time": "2025-04-28T23:59:59", - }, - ) - ], - critics=[ - DatetimeCritic(critic_field="start_date_time", weight=0.5), - DatetimeCritic(critic_field="end_date_time", weight=0.5), - ], - ) - - suite.add_case( - name="List events in time range with limit", - user_message=( - "get my first 10 meetings for the next work-week through thursday, " - "starting tuesday (mon is holiday)" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=list_events_in_time_range, - args={ - "start_date_time": "2025-04-29T00:00:00", - "end_date_time": "2025-05-01T23:59:59", - "limit": 10, - }, - ) - ], - critics=[ - DatetimeCritic(critic_field="start_date_time", weight=0.3), - DatetimeCritic(critic_field="end_date_time", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.4), - ], - ) - - return suite diff --git a/toolkits/outlook_calendar/pyproject.toml b/toolkits/outlook_calendar/pyproject.toml deleted file mode 100644 index a9f7fb48..00000000 --- a/toolkits/outlook_calendar/pyproject.toml +++ /dev/null @@ -1,61 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_outlook_calendar" -version = "1.0.0" -description = "rcade.dev LLM tools for Outlook Calendar" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "msgraph-sdk>=1.28.0,<2.0.0", - "beautifulsoup4>=4.10.0,<5.0.0", - "pytz>=2024.2,<2025.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_outlook_calendar/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_outlook_calendar",] diff --git a/toolkits/outlook_calendar/tests/__init__.py b/toolkits/outlook_calendar/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/outlook_calendar/tests/test_models.py b/toolkits/outlook_calendar/tests/test_models.py deleted file mode 100644 index 480229c9..00000000 --- a/toolkits/outlook_calendar/tests/test_models.py +++ /dev/null @@ -1,385 +0,0 @@ -import pytest -from msgraph.generated.models.attendee import Attendee as GraphAttendee -from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.event import Event as GraphEvent -from msgraph.generated.models.location import Location as GraphLocation -from msgraph.generated.models.recipient import Recipient as GraphRecipient -from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus -from msgraph.generated.models.response_type import ResponseType as GraphResponseType - -from arcade_outlook_calendar.models import ( - Attendee, - DateTimeTimeZone, - Event, - Organizer, - ResponseStatus, -) - - -class DummyBody: - def __init__(self, content): - self.content = content - - -class DummyEventType: - def __init__(self, value): - self.value = value - - -class DummyImportance: - def __init__(self, value): - self.value = value - - -class DummyFreeBusyStatus: - def __init__(self, value): - self.value = value - - -class DummyResponseType: - def __init__(self, value): - self.value = value - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - {"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"}, - {"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"}, - ), - ( - {"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"}, - {"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"}, - ), - ( - {"name": None, "address": None, "response": "none"}, - {"name": "", "address": "", "response": "none"}, - ), - ], -) -def test_attendee_conversion(input_data, expected): - sdk_attendee = GraphAttendee() - sdk_attendee.email_address = GraphEmailAddress() - sdk_attendee.email_address.name = input_data["name"] - sdk_attendee.email_address.address = input_data["address"] - sdk_attendee.status = GraphResponseStatus() - sdk_attendee.status.response = GraphResponseType(input_data["response"]) - - # Test from_sdk method - attendee = Attendee.from_sdk(sdk_attendee) - assert attendee.name == expected["name"] - assert attendee.address == expected["address"] - assert attendee.response == expected["response"] - - # Test to_dict method - dict_result = attendee.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = attendee.to_sdk() - assert sdk_result.email_address.name == expected["name"] - assert sdk_result.email_address.address == expected["address"] - assert sdk_result.status.response == GraphResponseType(expected["response"]) - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - {"name": "Jane Smith", "address": "jane.smith@example.com"}, - {"name": "Jane Smith", "address": "jane.smith@example.com"}, - ), - ( - {"name": "", "address": "unknown@example.com"}, - {"name": "", "address": "unknown@example.com"}, - ), - ({"name": None, "address": None}, {"name": "", "address": ""}), - ], -) -def test_organizer_conversion(input_data, expected): - sdk_organizer = GraphRecipient() - sdk_organizer.email_address = GraphEmailAddress() - sdk_organizer.email_address.name = input_data["name"] - sdk_organizer.email_address.address = input_data["address"] - - # Test from_sdk method - organizer = Organizer.from_sdk(sdk_organizer) - assert organizer.name == expected["name"] - assert organizer.address == expected["address"] - - # Test to_dict method - dict_result = organizer.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = organizer.to_sdk() - assert sdk_result.email_address.name == expected["name"] - assert sdk_result.email_address.address == expected["address"] - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - {"date_time": "2023-05-10T14:00:00", "time_zone": "Pacific Standard Time"}, - {"dateTime": "2023-05-10T14:00:00", "timeZone": "Pacific Standard Time"}, - ), - ({"date_time": "", "time_zone": "UTC"}, {"dateTime": "", "timeZone": "UTC"}), - ({"date_time": None, "time_zone": None}, {"dateTime": "", "timeZone": ""}), - ], -) -def test_date_time_time_zone_conversion(input_data, expected): - sdk_date_time = GraphDateTimeTimeZone() - sdk_date_time.date_time = input_data["date_time"] - sdk_date_time.time_zone = input_data["time_zone"] - - # Test from_sdk method - date_time_tz = DateTimeTimeZone.from_sdk(sdk_date_time) - assert date_time_tz.date_time == (input_data["date_time"] or "") - assert date_time_tz.time_zone == (input_data["time_zone"] or "") - - # Test to_dict method - dict_result = date_time_tz.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = date_time_tz.to_sdk() - assert sdk_result.date_time == date_time_tz.date_time - assert sdk_result.time_zone == date_time_tz.time_zone - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ({"response": "accepted"}, {"response": "accepted"}), - ({"response": "declined"}, {"response": "declined"}), - ({"response": "none"}, {"response": "none"}), - ], -) -def test_response_status_conversion(input_data, expected): - sdk_response_status = GraphResponseStatus() - sdk_response_status.response = GraphResponseType(input_data["response"]) - - # Test from_sdk method - response_status = ResponseStatus.from_sdk(sdk_response_status) - assert response_status.response == expected["response"] - - # Test to_dict method - dict_result = response_status.to_dict() - assert dict_result == expected - - # Test to_sdk method - sdk_result = response_status.to_sdk() - assert sdk_result.response == GraphResponseType(expected["response"]) - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - { - "body_content": "

Team Meeting

", - "has_attachments": True, - "importance": "high", - "is_all_day": False, - "is_cancelled": False, - "is_draft": False, - "is_online_meeting": True, - "is_organizer": True, - "location": "Conference Room A", - "online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123", - "id": "event-123", - "show_as": "busy", - "subject": "Weekly Team Sync", - "type": "singleInstance", - "web_link": "https://outlook.office.com/calendar/item/123", - "attendees": [ - {"name": "Alice", "address": "alice@example.com", "response": "accepted"}, - { - "name": "Bob", - "address": "bob@example.com", - "response": "tentativelyAccepted", - }, - ], - "organizer": {"name": "Manager", "address": "manager@example.com"}, - "start": {"date_time": "2023-05-10T10:00:00", "time_zone": "Eastern Standard Time"}, - "end": {"date_time": "2023-05-10T11:00:00", "time_zone": "Eastern Standard Time"}, - "response_status": {"response": "accepted"}, - }, - { - "body": "Team Meeting", - "has_attachments": True, - "importance": "high", - "is_all_day": False, - "is_cancelled": False, - "is_draft": False, - "is_online_meeting": True, - "is_organizer": True, - "location": "Conference Room A", - "online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123", - "id": "event-123", - "show_as": "busy", - "subject": "Weekly Team Sync", - "type": "singleInstance", - "web_link": "https://outlook.office.com/calendar/item/123", - "event_id": "event-123", - "attendees": [ - {"name": "Alice", "address": "alice@example.com", "response": "accepted"}, - { - "name": "Bob", - "address": "bob@example.com", - "response": "tentativelyAccepted", - }, - ], - "organizer": {"name": "Manager", "address": "manager@example.com"}, - "start": {"dateTime": "2023-05-10T10:00:00", "timeZone": "Eastern Standard Time"}, - "end": {"dateTime": "2023-05-10T11:00:00", "timeZone": "Eastern Standard Time"}, - "response_status": {"response": "accepted"}, - }, - ), - ( - { - "body_content": "

All day event description

", - "has_attachments": False, - "importance": "normal", - "is_all_day": True, - "is_cancelled": True, - "is_draft": True, - "is_online_meeting": False, - "is_organizer": False, - "location": "", - "online_meeting_url": "", - "id": "event-456", - "show_as": "free", - "subject": "Company Holiday", - "type": "occurrence", - "web_link": "https://outlook.office.com/calendar/item/456", - "attendees": [], - "organizer": {"name": "HR Department", "address": "hr@example.com"}, - "start": {"date_time": "2023-07-04T00:00:00", "time_zone": "UTC"}, - "end": {"date_time": "2023-07-05T00:00:00", "time_zone": "UTC"}, - "response_status": {"response": "notResponded"}, - }, - { - "body": "All day event description", - "has_attachments": False, - "importance": "normal", - "is_all_day": True, - "is_cancelled": True, - "is_draft": True, - "is_online_meeting": False, - "is_organizer": False, - "location": "", - "online_meeting_url": "", - "id": "event-456", - "show_as": "free", - "subject": "Company Holiday", - "type": "occurrence", - "web_link": "https://outlook.office.com/calendar/item/456", - "event_id": "event-456", - "attendees": [], - "organizer": {"name": "HR Department", "address": "hr@example.com"}, - "start": {"dateTime": "2023-07-04T00:00:00", "timeZone": "UTC"}, - "end": {"dateTime": "2023-07-05T00:00:00", "timeZone": "UTC"}, - "response_status": {"response": "notResponded"}, - }, - ), - ], -) -def test_event_conversion(input_data, expected): - def make_graph_attendee(attendee_data): - attendee = GraphAttendee() - attendee.email_address = GraphEmailAddress() - attendee.email_address.name = attendee_data.get("name", "") - attendee.email_address.address = attendee_data.get("address", "") - attendee.status = GraphResponseStatus() - attendee.status.response = GraphResponseType(attendee_data.get("response", "")) - return attendee - - def make_graph_organizer(organizer_data): - organizer = GraphRecipient() - organizer.email_address = GraphEmailAddress() - organizer.email_address.name = organizer_data.get("name", "") - organizer.email_address.address = organizer_data.get("address", "") - return organizer - - def make_graph_date_time(date_time_data): - date_time = GraphDateTimeTimeZone() - date_time.date_time = date_time_data.get("date_time", "") - date_time.time_zone = date_time_data.get("time_zone", "") - return date_time - - sdk_event = GraphEvent() - sdk_event.body = DummyBody(input_data["body_content"]) - sdk_event.has_attachments = input_data["has_attachments"] - sdk_event.importance = DummyImportance(input_data["importance"]) - sdk_event.is_all_day = input_data["is_all_day"] - sdk_event.is_cancelled = input_data["is_cancelled"] - sdk_event.is_draft = input_data["is_draft"] - sdk_event.is_online_meeting = input_data["is_online_meeting"] - sdk_event.is_organizer = input_data["is_organizer"] - sdk_event.location = GraphLocation(display_name=input_data["location"]) - sdk_event.online_meeting_url = input_data["online_meeting_url"] - sdk_event.id = input_data["id"] - sdk_event.show_as = DummyFreeBusyStatus(input_data["show_as"]) - sdk_event.subject = input_data["subject"] - sdk_event.type = DummyEventType(input_data["type"]) - sdk_event.web_link = input_data["web_link"] - sdk_event.attendees = [make_graph_attendee(a) for a in input_data["attendees"]] - sdk_event.organizer = make_graph_organizer(input_data["organizer"]) - sdk_event.start = make_graph_date_time(input_data["start"]) - sdk_event.end = make_graph_date_time(input_data["end"]) - sdk_event.response_status = GraphResponseStatus() - sdk_event.response_status.response = GraphResponseType( - input_data["response_status"]["response"] - ) - - # Test from_sdk method - event = Event.from_sdk(sdk_event) - assert event.body == expected["body"] - assert event.has_attachments == expected["has_attachments"] - assert event.importance == expected["importance"] - assert event.is_all_day == expected["is_all_day"] - assert event.is_cancelled == expected["is_cancelled"] - assert event.is_draft == expected["is_draft"] - assert event.is_online_meeting == expected["is_online_meeting"] - assert event.is_organizer == expected["is_organizer"] - assert event.location == expected["location"] - assert event.online_meeting_url == expected["online_meeting_url"] - assert event.id == expected["id"] - assert event.show_as == expected["show_as"] - assert event.subject == expected["subject"] - assert event.type == expected["type"] - assert event.web_link == expected["web_link"] - assert event.event_id == expected["event_id"] - assert len(event.attendees) == len(expected["attendees"]) - for i, attendee in enumerate(event.attendees): - assert attendee.name == expected["attendees"][i]["name"] - assert attendee.address == expected["attendees"][i]["address"] - assert attendee.response == expected["attendees"][i]["response"] - if event.start: - assert event.start.date_time == expected["start"]["dateTime"] - assert event.start.time_zone == expected["start"]["timeZone"] - if event.end: - assert event.end.date_time == expected["end"]["dateTime"] - assert event.end.time_zone == expected["end"]["timeZone"] - if event.organizer: - assert event.organizer.name == expected["organizer"]["name"] - assert event.organizer.address == expected["organizer"]["address"] - if event.response_status: - assert event.response_status.response == expected["response_status"]["response"] - - # Test to_dict method - dict_result = event.to_dict() - assert dict_result["body"] == expected["body"] - assert dict_result["subject"] == expected["subject"] - assert dict_result["event_id"] == expected["event_id"] - - # Test to_sdk method - sdk_result = event.to_sdk() - assert sdk_result.subject == event.subject - assert sdk_result.is_all_day == event.is_all_day - assert sdk_result.location.display_name == event.location - assert len(sdk_result.attendees) == len(event.attendees) diff --git a/toolkits/outlook_calendar/tests/test_utils.py b/toolkits/outlook_calendar/tests/test_utils.py deleted file mode 100644 index 6fc59b18..00000000 --- a/toolkits/outlook_calendar/tests/test_utils.py +++ /dev/null @@ -1,118 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_outlook_calendar._utils import ( - convert_timezone_to_offset, - is_valid_email, - remove_timezone_offset, - replace_timezone_offset, - validate_date_times, - validate_emails, -) - - -@pytest.mark.parametrize( - "start_date_time, end_date_time, error_type", - [ - ( - "2026-01-01T10:00:00", - "2026-01-01T17:00:00", - None, - ), - # end_date_time before start_date_time - ( - "2026-01-01T10:00:00", - "2026-01-01T10:00:00", - ToolExecutionError, - ), - # end_date_time before start_date_time because timezone offset is ignored - ( - "2026-01-01T10:00:00-07:00", - "2026-01-01T09:00:00-08:00", - ToolExecutionError, - ), - # not ISO 8601 format - ( - "20260101T10:00:00", - "2026-01-0109:00:00", - ValueError, - ), - ], -) -def test_validate_date_times(start_date_time, end_date_time, error_type): - if error_type: - with pytest.raises(error_type): - validate_date_times(start_date_time, end_date_time) - else: - validate_date_times(start_date_time, end_date_time) - - -@pytest.mark.parametrize( - "emails, expect_error", - [ - (["test@test.com"], False), - (["test@test.com", "test@test.com.au"], False), - (["test@test.com", "test@test.com.au."], True), - (["#$&*@test.com"], True), - ], -) -def test_validate_emails(emails, expect_error): - if expect_error: - with pytest.raises(ToolExecutionError): - validate_emails(emails) - else: - validate_emails(emails) - - -@pytest.mark.parametrize( - "email, is_valid", - [ - ("test@test.com", True), - ("test@test", False), - ("test@test.com.au", True), - ("test@test.com.au.", False), - ], -) -def test_is_valid_email(email, is_valid): - assert is_valid_email(email) == is_valid - - -@pytest.mark.parametrize( - "input_date_time, expected_date_time", - [ - ("2021-01-01T10:00:00+07:00", "2021-01-01T10:00:00"), - ("2021-01-01T10:00:00-07:00", "2021-01-01T10:00:00"), - ("2021-01-01T10:00:00Z", "2021-01-01T10:00:00"), - ], -) -def test_remove_timezone_offset(input_date_time, expected_date_time): - assert remove_timezone_offset(input_date_time) == expected_date_time - - -@pytest.mark.parametrize( - "input_date_time, time_zone_offset, expected_date_time", - [ - # without existing offset - ("2021-01-01T10:00:00", "+07:00", "2021-01-01T10:00:00+07:00"), - ("2021-01-01T10:00:00", "-07:00", "2021-01-01T10:00:00-07:00"), - ("2021-01-01T10:00:00", "Z", "2021-01-01T10:00:00Z"), - # with existing offset - ("2021-01-01T10:00:00+07:00", "+04:00", "2021-01-01T10:00:00+04:00"), - ("2021-01-01T10:00:00-07:00", "-09:00", "2021-01-01T10:00:00-09:00"), - ("2021-01-01T10:00:00-07:00", "Z", "2021-01-01T10:00:00Z"), - ], -) -def test_replace_timezone_offset(input_date_time, time_zone_offset, expected_date_time): - assert replace_timezone_offset(input_date_time, time_zone_offset) == expected_date_time - - -@pytest.mark.parametrize( - "time_zone, expected_offset", - [ - ("Central Asia Standard Time", "+05:00"), # Windows timezone format - ("America/New_York", "-04:00"), # IANA timezone format - ("Not a valid timezone", "Z"), # Fallback to UTC - ], -) -def test_convert_timezone_to_offset(time_zone, expected_offset): - assert convert_timezone_to_offset(time_zone) == expected_offset diff --git a/toolkits/outlook_mail/.pre-commit-config.yaml b/toolkits/outlook_mail/.pre-commit-config.yaml deleted file mode 100644 index 0672d232..00000000 --- a/toolkits/outlook_mail/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/outlook_mail/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/outlook_mail/.ruff.toml b/toolkits/outlook_mail/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/outlook_mail/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/outlook_mail/LICENSE b/toolkits/outlook_mail/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/outlook_mail/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/outlook_mail/Makefile b/toolkits/outlook_mail/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/outlook_mail/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/outlook_mail/arcade_outlook_mail/__init__.py b/toolkits/outlook_mail/arcade_outlook_mail/__init__.py deleted file mode 100644 index 545f4d7c..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -from arcade_outlook_mail.tools import ( - create_and_send_email, - create_draft_email, - list_emails, - list_emails_by_property, - list_emails_in_folder, - reply_to_email, - send_draft_email, - update_draft_email, -) - -__all__ = [ - # Read - "list_emails", - "list_emails_by_property", - "list_emails_in_folder", - # Send - "create_and_send_email", - "send_draft_email", - "reply_to_email", - # Write - "create_draft_email", - "update_draft_email", -] diff --git a/toolkits/outlook_mail/arcade_outlook_mail/_utils.py b/toolkits/outlook_mail/arcade_outlook_mail/_utils.py deleted file mode 100644 index 3f9f2795..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/_utils.py +++ /dev/null @@ -1,120 +0,0 @@ -from arcade_tdk import ToolContext -from msgraph.generated.models.message_collection_response import MessageCollectionResponse -from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import ( - MessagesRequestBuilder as MailFolderMessagesRequestBuilder, -) -from msgraph.generated.users.item.messages.item.reply.reply_post_request_body import ( - ReplyPostRequestBody, -) -from msgraph.generated.users.item.messages.item.reply_all.reply_all_post_request_body import ( - ReplyAllPostRequestBody, -) -from msgraph.generated.users.item.messages.messages_request_builder import ( - MessagesRequestBuilder as UserMessagesRequestBuilder, -) - -from arcade_outlook_mail.client import get_client -from arcade_outlook_mail.constants import DEFAULT_MESSAGE_FIELDS -from arcade_outlook_mail.enums import ( - EmailFilterProperty, - FilterOperator, - ReplyType, -) - - -def remove_none_values(data: dict) -> dict: - """Remove all keys with None values from the dictionary.""" - return {k: v for k, v in data.items() if v is not None} - - -def _create_filter_expression( - property_: EmailFilterProperty | None = None, - operator: FilterOperator | None = None, - value: str | None = None, -) -> str | None: - if property_ and operator and value: - property_value = property_.value - operator_value = operator.value - - # Never use quotes around 'value' for booleans and numerics - value_quote = "'" - if value.lower() in ["true", "false"] or value.isdigit(): - value_quote = "" - - # Handle function operators (e.g., contains, startsWith, endsWith) - if operator.is_function(): - filter_expr = f"{operator_value}({property_value}, {value_quote}{value}{value_quote})" - else: - # Handle comparison operators (e.g., eq, ne, gt, ge, lt, le) - filter_expr = f"{property_value} {operator_value} {value_quote}{value}{value_quote}" - - if property_value == EmailFilterProperty.RECEIVED_DATE_TIME: - filter_expr = filter_expr - else: # Since receivedDateTime is in orderby, it must be in filter: https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0&tabs=http#optional-query-parameters - filter_expr = f"receivedDateTime ge 1900-01-01T00:00:00Z and {filter_expr}" - - return filter_expr - - return None - - -def prepare_list_emails_request_config( - limit: int, - property_: EmailFilterProperty | None = None, - operator: FilterOperator | None = None, - value: str | None = None, -) -> MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration: - """Prepare a request configuration for listing emails.""" - limit = max(1, min(limit, 100)) # limit must be between 1 and 100 - - orderby = ["receivedDateTime DESC"] - filter_expr = _create_filter_expression(property_, operator, value) - - query_params = MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters( - count=True, - select=DEFAULT_MESSAGE_FIELDS, - orderby=orderby, - filter=filter_expr, - top=limit, - ) - return MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration( - query_parameters=query_params, - ) - - -async def fetch_emails( - message_builder: MailFolderMessagesRequestBuilder | UserMessagesRequestBuilder, - pagination_token: str | None = None, - request_config: MailFolderMessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration - | None = None, -) -> MessageCollectionResponse: - """Fetch emails from the user's mailbox. - - Microsoft Graph Python SDK does not support pagination (as of 2025-04-17), - so we use raw URL for pagination if a pagination token is provided. - """ - if pagination_token: - return await message_builder.with_url(pagination_token).get() # type: ignore[return-value] - return await message_builder.get(request_configuration=request_config) # type: ignore[return-value, arg-type] - - -async def send_reply_email( - context: ToolContext, - message_id: str, - body: str, - reply_type: ReplyType, -) -> dict: - """Send a reply email to the sender or all recipients of an existing email.""" - client = get_client(context.get_auth_token_or_empty()) - - if reply_type == ReplyType.REPLY: - reply_request_body = ReplyPostRequestBody(comment=body) - await client.me.messages.by_message_id(message_id).reply.post(reply_request_body) - elif reply_type == ReplyType.REPLY_ALL: - reply_all_request_body = ReplyAllPostRequestBody(comment=body) - await client.me.messages.by_message_id(message_id).reply_all.post(reply_all_request_body) - - return { - "success": True, - "message": "Email sent successfully", - } diff --git a/toolkits/outlook_mail/arcade_outlook_mail/client.py b/toolkits/outlook_mail/arcade_outlook_mail/client.py deleted file mode 100644 index e11d257a..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/client.py +++ /dev/null @@ -1,26 +0,0 @@ -import datetime -from typing import Any - -from azure.core.credentials import AccessToken, TokenCredential -from msgraph import GraphServiceClient - -DEFAULT_SCOPE = "https://graph.microsoft.com/.default" - - -class StaticTokenCredential(TokenCredential): - """Implementation of TokenCredential protocol to be provided to the MSGraph SDK client""" - - def __init__(self, token: str): - self._token = token - - def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken: - # An expiration is required by MSGraph SDK. Set to 1 hour from now. - expires_on = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + 3600 - return AccessToken(self._token, expires_on) - - -def get_client(token: str) -> GraphServiceClient: - """Create and return a MSGraph SDK client, given the provided token.""" - token_credential = StaticTokenCredential(token) - - return GraphServiceClient(token_credential, scopes=[DEFAULT_SCOPE]) diff --git a/toolkits/outlook_mail/arcade_outlook_mail/constants.py b/toolkits/outlook_mail/arcade_outlook_mail/constants.py deleted file mode 100644 index 6daef4d6..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -DEFAULT_MESSAGE_FIELDS = [ - "bccRecipients", - "body", - "ccRecipients", - "conversationId", - "conversationIndex", - "flag", - "from", - "hasAttachments", - "importance", - "isDraft", - "isRead", - "receivedDateTime", - "replyTo", - "subject", - "toRecipients", - "webLink", -] diff --git a/toolkits/outlook_mail/arcade_outlook_mail/enums.py b/toolkits/outlook_mail/arcade_outlook_mail/enums.py deleted file mode 100644 index ce68162f..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/enums.py +++ /dev/null @@ -1,65 +0,0 @@ -from enum import Enum - - -class WellKnownFolderNames(str, Enum): - """Well-known folder names that are created for users by default. - Instead of using the ID of these folders, you can use the well-known folder names. - For a list of all well-known folder names, see: https://learn.microsoft.com/en-us/graph/api/resources/mailfolder?view=graph-rest-1.0 - """ - - DELETED_ITEMS = "deleteditems" - DRAFTS = "drafts" - INBOX = "inbox" - JUNK_EMAIL = "junkemail" - SENT_ITEMS = "sentitems" - STARRED = "starred" - TODO = "tasks" - - -class ReplyType(str, Enum): - """The type of reply to send to an email.""" - - REPLY = "reply" - REPLY_ALL = "reply_all" - - -class EmailFilterProperty(str, Enum): - """The property to filter the emails by.""" - - # Basic properties - SUBJECT = "subject" - CONVERSATION_ID = "conversationId" - RECEIVED_DATE_TIME = "receivedDateTime" - SENDER = "sender/emailAddress/address" - - -class FilterOperator(str, Enum): - """The operator to use for the filter. - - For a full list of possible operators, see: https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions - """ - - # Equality operators - EQUAL = "eq" # example: $filter=conversationId eq 'hello' - NOT_EQUAL = "ne" # example: $filter=subject ne 'hello' - - # Relational operators - GREATER_THAN = "gt" # example: $filter=receivedDateTime gt 2024-01-01 - GREATER_THAN_OR_EQUAL_TO = "ge" # example: $filter=receivedDateTime ge 2024-01-01 - LESS_THAN = "lt" # example: $filter=receivedDateTime lt 2024-01-01 - LESS_THAN_OR_EQUAL_TO = "le" # example: $filter=receivedDateTime le 2024-01-01 - - # Functions - STARTS_WITH = "startsWith" # example: $filter=startsWith(subject, 'hello') - ENDS_WITH = "endsWith" # example: $filter=endsWith(subject, 'hello') - CONTAINS = "contains" # example: $filter=contains(subject, 'hello') - - def is_operator(self) -> bool: - """Check if the operator is a comparison operator.""" - operators = [self.EQUAL, self.NOT_EQUAL] - return self in operators - - def is_function(self) -> bool: - """Check if the operator is a function.""" - functions = [self.STARTS_WITH, self.ENDS_WITH, self.CONTAINS] - return self in functions diff --git a/toolkits/outlook_mail/arcade_outlook_mail/message.py b/toolkits/outlook_mail/arcade_outlook_mail/message.py deleted file mode 100644 index b7e2ec7f..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/message.py +++ /dev/null @@ -1,218 +0,0 @@ -import re -from dataclasses import dataclass, field -from typing import Any - -from bs4 import BeautifulSoup -from msgraph.generated.models.body_type import BodyType -from msgraph.generated.models.email_address import EmailAddress -from msgraph.generated.models.item_body import ItemBody -from msgraph.generated.models.message import Message as GraphMessage -from msgraph.generated.models.recipient import Recipient as GraphRecipient - - -@dataclass -class Recipient: - """A recipient of an email message.""" - - email_address: str = "" - name: str = "" - - @classmethod - def from_sdk(cls, recipient: GraphRecipient) -> "Recipient": - """Convert a Microsoft Graph SDK Recipient object to a Recipient dataclass.""" - address = ( - recipient.email_address.address - if recipient and recipient.email_address and recipient.email_address.address - else "" - ) - name = ( - recipient.email_address.name - if recipient and recipient.email_address and recipient.email_address.name - else "" - ) - return cls(email_address=address, name=name) - - def to_dict(self) -> dict[str, str]: - return {"email_address": self.email_address, "name": self.name} - - def to_sdk(self) -> GraphRecipient: - """Converts the Recipient dataclass to a Microsoft Graph SDK Recipient object.""" - recipient = GraphRecipient() - email_address = EmailAddress() - email_address.address = self.email_address - email_address.name = self.name - recipient.email_address = email_address - return recipient - - -@dataclass -class Message: - """An email message in Outlook.""" - - bcc_recipients: list[Recipient] = field(default_factory=list) - cc_recipients: list[Recipient] = field(default_factory=list) - reply_to: list[Recipient] = field(default_factory=list) - to_recipients: list[Recipient] = field(default_factory=list) - from_: Recipient = field(default_factory=Recipient) - subject: str = "" - body: str = "" - conversation_id: str = "" - conversation_index: str = "" - flag: dict[str, str] = field(default_factory=dict) - has_attachments: bool = False - importance: str = "" - is_read: bool = False - received_date_time: str = "" - web_link: str = "" - is_draft: bool = True - message_id: str = "" # The unique identifier of the email message. Read-only. - - @staticmethod - def _safe_str(value: Any) -> str: - if not value: - return "" - if isinstance(value, bytes | bytearray): - return value.decode("utf-8", errors="ignore") - return str(value) - - @staticmethod - def _safe_bool(value: Any) -> bool: - return bool(value) - - @staticmethod - def _parse_body(mime: str) -> str: - if not mime: - return "" - soup = BeautifulSoup(mime, "html.parser") - text = soup.get_text(separator=" ") - # Replace multiple newlines with a single newline - text = re.sub(r"\n+", "\n", text) - # Replace multiple spaces with a single space - text = re.sub(r"\s+", " ", text) - # Remove leading/trailing whitespace from each line - text = "\n".join(line.strip() for line in text.split("\n")) - - return text - - @staticmethod - def _parse_importance(value: Any) -> str: - return value.value if getattr(value, "value", None) else "" - - @staticmethod - def _parse_flag(flag: Any) -> dict[str, str]: - if not flag: - return {"flag_status": "", "due_date_time": ""} - status = flag.flag_status.value if getattr(flag, "flag_status", None) else "" - due = "" - if getattr(flag, "due_date_time", None) and getattr(flag.due_date_time, "date_time", None): - due = Message._safe_str(flag.due_date_time.date_time) - return {"flag_status": status, "due_date_time": due} - - @classmethod - def from_sdk(cls, msg: GraphMessage) -> "Message": - """Convert a Microsoft Graph SDK Message object to a Message dataclass.""" - text = cls._parse_body(msg.body.content if msg.body and msg.body.content else "") - return cls( - bcc_recipients=[ - Recipient.from_sdk(recipient) for recipient in msg.bcc_recipients or [] - ], - cc_recipients=[Recipient.from_sdk(recipient) for recipient in msg.cc_recipients or []], - reply_to=[Recipient.from_sdk(recipient) for recipient in msg.reply_to or []], - to_recipients=[Recipient.from_sdk(recipient) for recipient in msg.to_recipients or []], - from_=Recipient.from_sdk(msg.from_) if msg.from_ else Recipient(), - subject=cls._safe_str(msg.subject), - body=text, - conversation_id=cls._safe_str(msg.conversation_id), - conversation_index=( - msg.conversation_index.decode("utf-8", errors="ignore") - if isinstance(msg.conversation_index, bytes | bytearray) - else cls._safe_str(msg.conversation_index) - ), - flag=cls._parse_flag(msg.flag), - has_attachments=cls._safe_bool(msg.has_attachments), - importance=cls._parse_importance(msg.importance), - is_read=cls._safe_bool(msg.is_read), - received_date_time=( - msg.received_date_time.isoformat() if msg.received_date_time else "" - ), - web_link=cls._safe_str(msg.web_link), - is_draft=cls._safe_bool(msg.is_draft), - message_id=cls._safe_str(msg.id), - ) - - def to_sdk(self) -> GraphMessage: - """Converts the Message dataclass to a Microsoft Graph SDK Message object.""" - sdk_msg = GraphMessage() - sdk_msg.subject = self.subject - body_obj = ItemBody() - body_obj.content = self.body - body_obj.content_type = BodyType.Text - sdk_msg.body = body_obj - sdk_msg.is_draft = self.is_draft - sdk_msg.to_recipients = [r.to_sdk() for r in self.to_recipients] - sdk_msg.cc_recipients = [r.to_sdk() for r in self.cc_recipients] - sdk_msg.bcc_recipients = [r.to_sdk() for r in self.bcc_recipients] - sdk_msg.reply_to = [r.to_sdk() for r in self.reply_to] - - return sdk_msg - - def to_dict(self) -> dict[str, Any]: - """Converts the Message dataclass to a dictionary.""" - return { - "bcc_recipients": [recipient.to_dict() for recipient in self.bcc_recipients], - "cc_recipients": [recipient.to_dict() for recipient in self.cc_recipients], - "reply_to": [recipient.to_dict() for recipient in self.reply_to], - "to_recipients": [recipient.to_dict() for recipient in self.to_recipients], - "from": self.from_.to_dict(), - "subject": self.subject, - "body": self.body, - "conversation_id": self.conversation_id, - "conversation_index": self.conversation_index, - "flag": self.flag, - "has_attachments": self.has_attachments, - "importance": self.importance, - "is_read": self.is_read, - "received_date_time": self.received_date_time, - "web_link": self.web_link, - "is_draft": self.is_draft, - "message_id": self.message_id, - } - - def update_recipient_lists( - self, - to_add: list[str] | None = None, - to_remove: list[str] | None = None, - cc_add: list[str] | None = None, - cc_remove: list[str] | None = None, - bcc_add: list[str] | None = None, - bcc_remove: list[str] | None = None, - ) -> None: - """Update each recipient list of the message. - - This function updates the recipient lists of the message by first adding new recipients - and then removing existing recipients. Therefore, if an email address is both - added and removed, then it will not be included in the returned list. - """ - for attr, add_emails_input, remove_emails_input in ( - ("to_recipients", to_add, to_remove), - ("cc_recipients", cc_add, cc_remove), - ("bcc_recipients", bcc_add, bcc_remove), - ): - current_recipients = getattr(self, attr) or [] - # Add recipients - existing_emails = {r.email_address.lower() for r in current_recipients} - new_additions = [ - Recipient(email_address=email) - for email in (add_emails_input or []) - if email.lower() not in existing_emails - ] - # Remove recipients - updated_list = current_recipients + new_additions - remove_emails = {email.lower() for email in (remove_emails_input or [])} - updated_list = [ - recipient - for recipient in updated_list - if recipient.email_address.lower() not in remove_emails - ] - # Update the message's attribute with the new list - setattr(self, attr, updated_list) diff --git a/toolkits/outlook_mail/arcade_outlook_mail/tools/__init__.py b/toolkits/outlook_mail/arcade_outlook_mail/tools/__init__.py deleted file mode 100644 index 0ae8d773..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/tools/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -from arcade_outlook_mail.tools.read import ( - list_emails, - list_emails_by_property, - list_emails_in_folder, -) -from arcade_outlook_mail.tools.send import ( - create_and_send_email, - reply_to_email, - send_draft_email, -) -from arcade_outlook_mail.tools.write import ( - create_draft_email, - update_draft_email, -) - -__all__ = [ - # Read - "list_emails", - "list_emails_by_property", - "list_emails_in_folder", - # Send - "create_and_send_email", - "reply_to_email", - "send_draft_email", - # Write - "create_draft_email", - "update_draft_email", -] diff --git a/toolkits/outlook_mail/arcade_outlook_mail/tools/read.py b/toolkits/outlook_mail/arcade_outlook_mail/tools/read.py deleted file mode 100644 index e63c52e9..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/tools/read.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from arcade_tdk.errors import ToolExecutionError - -from arcade_outlook_mail._utils import ( - fetch_emails, - prepare_list_emails_request_config, - remove_none_values, -) -from arcade_outlook_mail.client import get_client -from arcade_outlook_mail.enums import ( - EmailFilterProperty, - FilterOperator, - WellKnownFolderNames, -) -from arcade_outlook_mail.message import Message - - -@tool(requires_auth=Microsoft(scopes=["Mail.Read"])) -async def list_emails( - context: ToolContext, - limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[dict, "A dictionary containing a list of emails"]: - """List emails in the user's mailbox across all folders. - - Since this tool lists email across all folders, it may return sent items, drafts, - and other items that are not in the inbox. - """ - client = get_client(context.get_auth_token_or_empty()) - request_config = prepare_list_emails_request_config(limit) - message_builder = client.me.messages - - response = await fetch_emails(message_builder, pagination_token, request_config) - messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []] - pagination_token = response.odata_next_link - - result = { - "messages": messages, - "num_messages": len(messages), - "pagination_token": pagination_token, - } - result = remove_none_values(result) - return result - - -@tool(requires_auth=Microsoft(scopes=["Mail.Read"])) -async def list_emails_in_folder( - context: ToolContext, - well_known_folder_name: Annotated[ - WellKnownFolderNames | None, - "The name of the folder to list emails from. Defaults to None.", - ] = None, - folder_id: Annotated[ - str | None, - "The ID of the folder to list emails from if the folder is not a well-known folder. " - "Defaults to None.", - ] = None, - limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[ - dict, "A dictionary containing a list of emails and a pagination token, if applicable" -]: - """List the user's emails in the specified folder. - - Exactly one of `well_known_folder_name` or `folder_id` MUST be provided. - """ - if not (bool(well_known_folder_name) ^ bool(folder_id)): - raise ToolExecutionError( - message="Exactly one of `well_known_folder_name` or `folder_id` must be provided." - ) - folder_name = well_known_folder_name.value if well_known_folder_name else folder_id - client = get_client(context.get_auth_token_or_empty()) - request_config = prepare_list_emails_request_config(limit) - message_builder = client.me.mail_folders.by_mail_folder_id(folder_name).messages # type: ignore [arg-type] - - response = await fetch_emails(message_builder, pagination_token, request_config) - messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []] - pagination_token = response.odata_next_link - - result = { - "messages": messages, - "num_messages": len(messages), - "pagination_token": pagination_token, - } - result = remove_none_values(result) - return result - - -@tool(requires_auth=Microsoft(scopes=["Mail.Read"])) -async def list_emails_by_property( - context: ToolContext, - property: Annotated[EmailFilterProperty, "The property to filter the emails by."], # noqa: A002 - operator: Annotated[FilterOperator, "The operator to use for the filter."], - value: Annotated[str, "The value to filter the emails by"], - limit: Annotated[int, "The number of messages to return. Max is 100. Defaults to 5."] = 5, - pagination_token: Annotated[ - str | None, "The pagination token to continue a previous request" - ] = None, -) -> Annotated[dict, "A dictionary containing a list of emails"]: - """List emails in the user's mailbox across all folders filtering by a property.""" - client = get_client(context.get_auth_token_or_empty()) - request_config = prepare_list_emails_request_config(limit, property, operator, value) - message_builder = client.me.messages - - response = await fetch_emails(message_builder, pagination_token, request_config) - messages = [Message.from_sdk(msg).to_dict() for msg in response.value or []] - pagination_token = response.odata_next_link - - result = { - "messages": messages, - "num_messages": len(messages), - "pagination_token": pagination_token, - } - result = remove_none_values(result) - return result diff --git a/toolkits/outlook_mail/arcade_outlook_mail/tools/send.py b/toolkits/outlook_mail/arcade_outlook_mail/tools/send.py deleted file mode 100644 index 22dacd78..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/tools/send.py +++ /dev/null @@ -1,94 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from msgraph.generated.users.item.send_mail.send_mail_post_request_body import ( - SendMailPostRequestBody, -) - -from arcade_outlook_mail._utils import send_reply_email -from arcade_outlook_mail.client import get_client -from arcade_outlook_mail.enums import ReplyType -from arcade_outlook_mail.message import Message, Recipient - - -@tool(requires_auth=Microsoft(scopes=["Mail.Send"])) -async def create_and_send_email( - context: ToolContext, - subject: Annotated[str, "The subject of the email to create"], - body: Annotated[str, "The body of the email to create"], - to_recipients: Annotated[ - list[str], "The email addresses that will be the recipients of the email" - ], - cc_recipients: Annotated[ - list[str] | None, "The email addresses that will be the CC recipients of the email." - ] = None, - bcc_recipients: Annotated[ - list[str] | None, - "The email addresses that will be the BCC recipients of the email.", - ] = None, -) -> Annotated[dict, "A dictionary containing the created email details"]: - """Create and immediately send a new email in Outlook to the specified recipients""" - client = get_client(context.get_auth_token_or_empty()) - message = Message( - subject=subject, - body=body, - to_recipients=[Recipient(email_address=email) for email in to_recipients], - cc_recipients=[Recipient(email_address=email) for email in cc_recipients or []], - bcc_recipients=[Recipient(email_address=email) for email in bcc_recipients or []], - ).to_sdk() - - send_mail_request_body = SendMailPostRequestBody( - message=message, - save_to_sent_items=True, - ) - - await client.me.send_mail.post(send_mail_request_body) - - return { - "success": True, - "message": "Email sent successfully", - } - - -@tool(requires_auth=Microsoft(scopes=["Mail.Send"])) -async def send_draft_email( - context: ToolContext, - message_id: Annotated[str, "The ID of the draft email to send"], -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """Send an existing draft email in Outlook - - This tool can send any un-sent email: - - draft - - reply-draft - - reply-all draft - - forward draft - """ - client = get_client(context.get_auth_token_or_empty()) - - await client.me.messages.by_message_id(message_id).send.post() - - return { - "success": True, - "message": "Email sent successfully", - } - - -@tool(requires_auth=Microsoft(scopes=["Mail.Send"])) -async def reply_to_email( - context: ToolContext, - message_id: Annotated[str, "The ID of the email to reply to"], - body: Annotated[str, "The body of the reply to the email"], - reply_type: Annotated[ - ReplyType, - f"Specify {ReplyType.REPLY} to reply only to the sender or " - f"{ReplyType.REPLY_ALL} to reply to all recipients. " - f"Defaults to {ReplyType.REPLY}.", - ] = ReplyType.REPLY, -) -> Annotated[dict, "A dictionary containing the sent email details"]: - """Reply to an existing email in Outlook. - - Use this tool to reply to the sender or all recipients of the email. - Specify the reply_type to determine the scope of the reply. - """ - return await send_reply_email(context, message_id, body, reply_type) diff --git a/toolkits/outlook_mail/arcade_outlook_mail/tools/write.py b/toolkits/outlook_mail/arcade_outlook_mail/tools/write.py deleted file mode 100644 index f80e8f60..00000000 --- a/toolkits/outlook_mail/arcade_outlook_mail/tools/write.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Microsoft -from arcade_tdk.errors import ToolExecutionError - -from arcade_outlook_mail.client import get_client -from arcade_outlook_mail.message import Message, Recipient - - -@tool(requires_auth=Microsoft(scopes=["Mail.ReadWrite"])) -async def create_draft_email( - context: ToolContext, - subject: Annotated[str, "The subject of the draft email to create"], - body: Annotated[str, "The body of the draft email to create"], - to_recipients: Annotated[ - list[str], "The email addresses that will be the recipients of the draft email" - ], - cc_recipients: Annotated[ - list[str] | None, - "The email addresses that will be the CC recipients of the draft email.", - ] = None, - bcc_recipients: Annotated[ - list[str] | None, - "The email addresses that will be the BCC recipients of the draft email.", - ] = None, -) -> Annotated[dict, "A dictionary containing the created email details"]: - """Compose a new draft email in Outlook""" - client = get_client(context.get_auth_token_or_empty()) - - message = Message( - subject=subject, - body=body, - to_recipients=[Recipient(email_address=email) for email in to_recipients], - cc_recipients=[Recipient(email_address=email) for email in cc_recipients or []], - bcc_recipients=[Recipient(email_address=email) for email in bcc_recipients or []], - is_draft=True, - ).to_sdk() - - response = await client.me.messages.post(message) - draft_message = Message.from_sdk(response).to_dict() # type: ignore [arg-type] - - return draft_message - - -@tool(requires_auth=Microsoft(scopes=["Mail.ReadWrite"])) -async def update_draft_email( - context: ToolContext, - message_id: Annotated[str, "The ID of the draft email to update"], - subject: Annotated[ - str | None, - "The new subject of the draft email. If provided, the existing subject will be overwritten", - ] = None, - body: Annotated[ - str | None, - "The new body of the draft email. If provided, the existing body will be overwritten", - ] = None, - to_add: Annotated[list[str] | None, "Email addresses to add as 'To' recipients."] = None, - to_remove: Annotated[ - list[str] | None, - "Email addresses to remove from the current 'To' recipients.", - ] = None, - cc_add: Annotated[ - list[str] | None, - "Email addresses to add as 'CC' recipients.", - ] = None, - cc_remove: Annotated[ - list[str] | None, - "Email addresses to remove from the current 'CC' recipients.", - ] = None, - bcc_add: Annotated[ - list[str] | None, - "Email addresses to add as 'BCC' recipients.", - ] = None, - bcc_remove: Annotated[ - list[str] | None, - "Email addresses to remove from the current 'BCC' recipients.", - ] = None, -) -> Annotated[dict, "A dictionary containing the updated email details"]: - """Update an existing draft email in Outlook. - - This tool overwrites the subject and body of a draft email (if provided), - and modifies its recipient lists by selectively adding or removing email addresses. - - This tool can update any un-sent email: - - draft - - reply-draft - - reply-all draft - - forward draft - """ - client = get_client(context.get_auth_token_or_empty()) - - # Get the draft email - draft_email_sdk = await client.me.messages.by_message_id(message_id).get() - - if draft_email_sdk is None: - raise ToolExecutionError(message=f"The draft email with ID {message_id} was not found.") - - # Update the draft email - draft_email = Message.from_sdk(draft_email_sdk) - draft_email.subject = subject if subject else draft_email.subject - draft_email.body = body if body else draft_email.body or "" - draft_email.update_recipient_lists( - to_add=to_add, - to_remove=to_remove, - cc_add=cc_add, - cc_remove=cc_remove, - bcc_add=bcc_add, - bcc_remove=bcc_remove, - ) - updated_draft_email = await client.me.messages.by_message_id(message_id).patch( - draft_email.to_sdk() - ) - - return Message.from_sdk(updated_draft_email).to_dict() # type: ignore [arg-type] diff --git a/toolkits/outlook_mail/evals/additional_messages.py b/toolkits/outlook_mail/evals/additional_messages.py deleted file mode 100644 index 809a21ec..00000000 --- a/toolkits/outlook_mail/evals/additional_messages.py +++ /dev/null @@ -1,83 +0,0 @@ -update_draft_email_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-22, Tuesday."}, - { - "role": "user", - "content": '"create a new draft email with subject \'Hello friends\' and body "\n"\'I\'ve gathered you all here to celebrate the launch of the new Arcade platform."\n "address it to e@arcade.dev and z@arcade.dev. also carbon copy to j@arcade.dev, "\n"f@arcade.dev, k@arcade.dev and finally to m@arcade.dev. also bcc to r@arcade.dev"', - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_lKw4S01FGe03oZeuW25roepy", - "type": "function", - "function": { - "name": "Microsoft_CreateDraftEmail", - "arguments": '{"subject":"Hello friends","body":"I\'ve gathered you all here to celebrate the launch of the new Arcade platform.","to_recipients":["e@arcade.dev","z@arcade.dev"],"cc_recipients":["j@arcade.dev","f@arcade.dev","k@arcade.dev","m@arcade.dev"],"bcc_recipients":["r@arcade.dev"]}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"bcc_recipients":[{"email_address":"r@arcade.dev","name":"r@arcade.dev"}],"body":"I\'ve gathered you all here to celebrate the launch of the new Arcade platform.","cc_recipients":[{"email_address":"j@arcade.dev","name":"j@arcade.dev"},{"email_address":"f@arcade.dev","name":"f@arcade.dev"},{"email_address":"k@arcade.dev","name":"k@arcade.dev"},{"email_address":"m@arcade.dev","name":"m@arcade.dev"}],"conversation_id":"AQQkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoAEAAskq2oM-moTbt3gDT_yK0e","conversation_index":"AQHbs6c0LJKtqDP5qE27d4A0/sitHg==","flag":{"due_date_time":"","flag_status":"notFlagged"},"from":{"email_address":"","name":""},"has_attachments":false,"importance":"normal","is_draft":true,"is_read":true,"message_id":"AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA","received_date_time":"2025-04-22T16:54:25+00:00","reply_to":[],"subject":"Hello friends","to_recipients":[{"email_address":"e@arcade.dev","name":"e@arcade.dev"},{"email_address":"z@arcade.dev","name":"z@arcade.dev"}],"web_link":"https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDwAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAAC%2FdpvAAAA\\u0026exvsurl=1\\u0026viewmodel=ReadMessageItem"}', - "tool_call_id": "call_lKw4S01FGe03oZeuW25roepy", - "name": "Microsoft_CreateDraftEmail", - }, - { - "role": "assistant", - "content": 'I have created a draft email with the subject "Hello friends" addressed to the specified recipients. You can view and edit the draft through [this link](https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDwAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAAC%2FdpvAAAA&exvsurl=1&viewmodel=ReadMessageItem).', - }, -] - -list_emails_with_pagination_token_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-21, Monday."}, - {"role": "user", "content": "get one email"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "type": "function", - "function": {"name": "Microsoft_ListEmails", "arguments": '{"limit":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"messages":[{"bcc_recipients":[],"body":"Microsoft account New app(s) have access to your data Arcade","cc_recipients":["e@arcade.dev],"conversation_id":"AQQkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoAEABOD15A17tWSaVHkmjhko1R","conversation_index":"AQHbsuDd Tg9eQNe7VkmlR5Jo4ZKNUQ==","flag":{"due_date_time":"","flag_status":"notFlagged"},"from":{"email_address":"account-security@accountprotect ion.microsoft.com","name":"Microsoft account team"},"has_attachments":false,"importance":"normal","is_draft":false,"is_read":true,"message_id":"AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA","received_date_time":"2025-04-21T17:14:39+00:00", "reply_to":[],"subject":"New app(s) connected to your Microsoft account","to_recipients":[{"email_address":"ericarcade@outlook.com","name":"ericarcade@outlook.com"}],"web_link":"https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAABc%2BezAAAA\\u0026exvsurl=1\\u0026viewmodel=ReadMessageItem"}],"num_messages":1,"pagination_token":"https://graph.microsoft.com/v1.0/me/messages?%24count=true&%24orderby=receivedDateTime+DESC&%24select=bccRecipients%2cbody%2cccRecipients%2cconversationId%2cconversationIndex%2cflag%2cfrom%2chasAttachments%2cimportance%2cisDraft%2cisRead%2creceivedDateTime%2creplyTo%2csubject%2ctoRecipients%2cwebLink&%24top=1&%24skip=1"}', - "tool_call_id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "name": "Microsoft_ListEmails", - }, - { - "role": "assistant", - "content": "Here is the most recent email you received:\n\n- **From:** Microsoft account team (account-security@accountprotection.microsoft.com)\n- **To:** e@outlook.com\n- **Subject:** New app(s) connected to your Microsoft account\n- **Received Date:** April 21, 2025\n- **Body:**\n ```\n Microsoft account\n\n New app(s) have access to your data Arcade connected to the Microsoft account *@outlook.com.```\n- **Link to email:** [Read in Outlook](https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAABc%2BezAAAA&exvsurl=1&viewmodel=ReadMessageItem)", - }, -] - -list_emails_with_pagination_token_additional_messages = [ - {"role": "system", "content": "Today is 2025-04-21, Monday."}, - {"role": "user", "content": "get one email"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "type": "function", - "function": {"name": "Microsoft_ListEmails", "arguments": '{"limit":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"messages":[{"bcc_recipients":[],"body":"Microsoft account New app(s) have access to your data Arcade","cc_recipients":[],"conversation_id":"AQQkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoAEABOD15A17tWSaVHkmjhko1R","conversation_index":"AQHbsuDdTg9eQNe7VkmlR5Jo4ZKNUQ==","flag":{"due_date_time":"","flag_status":"notFlagged"},"from":{"email_address":"account-security-noreply@accountprotection.microsoft.com","name":"Microsoft account team"},"has_attachments":false,"importance":"normal","is_draft":false,"is_read":true,"message_id":"AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA","received_date_time":"2025-04-21T17:14:39+00:00", "reply_to":[],"subject":"New app(s) connected to your Microsoft account","to_recipients":[{"email_address":"ericarcade@outlook.com","name":"ericarcade@outlook.com"}],"web_link":"https://outlook.live.com/owa/?I temID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAA Bc%2BezAAAA\\u0026exvsurl=1\\u0026viewmodel=ReadMessageItem"}],"num_messages":1,"pagination_token":"https://graph.microsoft.com/v1.0/me/messages?%24count=true&%24orderby=receivedDateTime+DESC&%24select=bccRecipients%2cbody%2cccRecipients%2cconversationId%2cconversationIndex%2cflag%2cfrom%2chasAttachments%2cimportance%2cisDraft%2cisRead%2creceivedDateTime%2creplyTo%2csubject%2ctoRecipients%2cwebLink&%24top=1&%24skip=1"}', - "tool_call_id": "call_jACvc3Gl1WHkqWgI8gdsIt0G", - "name": "Microsoft_ListEmails", - }, - { - "role": "assistant", - "content": "Here is the most recent email you received:\n\n- **From:** Microsoft account team (account-security-noreply@accountprotection.microsoft.com)\n- **To:** e@outlook.com\n- **Subject:** New app(s) connected to your Microsoft account\n- **Received Date:** April 21, 2025\n- **Body:**\n ```\n Microsoft account\n\n New app(s) have access to your data Arcade connected to the Microsoft account *@outlook.com.```\n- **Link to email:** [Read in Outlook](https://outlook.live.com/owa/?ItemID=AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4%2Bx%2BWeUwAAAIBDAAAAFuxokOLZRtDncM4%2Bx%2BWeUwAAAABc%2BezAAAA&exvsurl=1&viewmodel=ReadMessageItem)", - }, -] diff --git a/toolkits/outlook_mail/evals/eval_read.py b/toolkits/outlook_mail/evals/eval_read.py deleted file mode 100644 index 1864f7ce..00000000 --- a/toolkits/outlook_mail/evals/eval_read.py +++ /dev/null @@ -1,210 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_outlook_mail import ( - list_emails, - list_emails_by_property, - list_emails_in_folder, -) -from arcade_outlook_mail.enums import WellKnownFolderNames -from evals.additional_messages import ( - list_emails_with_pagination_token_additional_messages, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_tool(list_emails, "OutlookMail") -catalog.add_tool(list_emails_in_folder, "OutlookMail") -catalog.add_tool(list_emails_by_property, "OutlookMail") - - -@tool_eval() -def outlook_mail_read_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Tools Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List emails in mailbox", - user_message="get my five most recent emails", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails, - args={"limit": 5}, - ) - ], - critics=[ - BinaryCritic(critic_field="limit", weight=1.0), - ], - ) - - suite.add_case( - name="List emails in mailbox with pagination token", - user_message="get the next 3", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails, - args={ - "limit": 3, - "pagination_token": "https://graph.microsoft.com/v1.0/me/messages?%24count=true&%24orderby=receivedDateTime+DESC&%24select=bccRecipients%2cbody%2cccRecipients%2cconversationId%2cconversationIndex%2cflag%2cfrom%2chasAttachments%2cimportance%2cisDraft%2cisRead%2creceivedDateTime%2creplyTo%2csubject%2ctoRecipients%2cwebLink&%24top=1&%24skip=1", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="pagination_token", weight=0.8), - ], - additional_messages=list_emails_with_pagination_token_additional_messages, - ) - - suite.add_case( - name="List emails in well-known folder", - user_message="summarize my inbox", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_in_folder, - args={ - "well_known_folder_name": WellKnownFolderNames.INBOX, - "folder_id": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="well_known_folder_name", weight=0.5), - BinaryCritic(critic_field="folder_id", weight=0.5), - ], - ) - - suite.add_case( - name="List emails in folder by id", - user_message="get 5 from folder AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoALgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_in_folder, - args={ - "well_known_folder_name": None, - "folder_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoALgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", - "limit": 5, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="well_known_folder_name", weight=0.4), - BinaryCritic(critic_field="folder_id", weight=0.4), - BinaryCritic(critic_field="limit", weight=0.2), - ], - ) - - return suite - - -@tool_eval() -def outlook_mail_list_emails_by_property_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Tools Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List emails by subject", - user_message="get all emails that talk about The Green Bottle", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "subject", - "operator": "contains", - "value": "The Green Bottle", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - BinaryCritic(critic_field="value", weight=1 / 3), - ], - ) - - suite.extend_case( - name="List emails by thread", - user_message="get all emails in my thread 1k2jh324h92f24krjb34mtb43kj4bk3tmn34b3k4nnm3tb34mntb34mntb3m4bt3mn4bt3mn4btmnb34tmnb3t4mnb==34tkjh", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "conversationId", - "operator": "eq", - "value": "1k2jh324h92f24krjb34mtb43kj4bk3tmn34b3k4nnm3tb34mntb34mntb3m4bt3mn4bt3mn4btmnb34tmnb3t4mnb==34tkjh", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - BinaryCritic(critic_field="value", weight=1 / 3), - ], - ) - - suite.extend_case( - name="List emails by date", - user_message="Today is May 1st 2025. Get all emails that are a year old or older", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "receivedDateTime", - "operator": "le", - "value": "2024-05-01T00:00:00Z", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - DatetimeCritic(critic_field="value", weight=1 / 3, tolerance=timedelta(days=1)), - ], - ) - - suite.extend_case( - name="List emails by sender", - user_message="get all of my correspondence with the folks over at arcade.dev", - expected_tool_calls=[ - ExpectedToolCall( - func=list_emails_by_property, - args={ - "property": "sender/emailAddress/address", - "operator": "contains", - "value": "arcade.dev", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="property", weight=1 / 3), - BinaryCritic(critic_field="operator", weight=1 / 3), - BinaryCritic(critic_field="value", weight=1 / 3), - ], - ) - - return suite diff --git a/toolkits/outlook_mail/evals/eval_send.py b/toolkits/outlook_mail/evals/eval_send.py deleted file mode 100644 index c8eba7d7..00000000 --- a/toolkits/outlook_mail/evals/eval_send.py +++ /dev/null @@ -1,127 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_outlook_mail import ( - create_and_send_email, - reply_to_email, - send_draft_email, -) -from arcade_outlook_mail.enums import ReplyType -from evals.additional_messages import ( - list_emails_with_pagination_token_additional_messages, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_tool(create_and_send_email, "OutlookMail") -catalog.add_tool(send_draft_email, "OutlookMail") -catalog.add_tool(reply_to_email, "OutlookMail") - - -@tool_eval() -def outlook_mail_send_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Send Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create draft email", - user_message=( - "send an email to j@arcade.dev and e@arcade.dev. Title it 'Hello friends' and have it " - "say 'I've gathered you all here to celebrate the launch of the new Arcade platform.'" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_and_send_email, - args={ - "subject": "Hello friends", - "body": "I've gathered you all here to celebrate the launch of the new Arcade platform.", - "to_recipients": ["j@arcade.dev", "e@arcade.dev"], - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=0.3), - SimilarityCritic(critic_field="body", weight=0.3), - BinaryCritic(critic_field="to_recipients", weight=0.4), - ], - ) - - suite.add_case( - name="Update draft email", - user_message=( - "forward the draft AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA " - ), - expected_tool_calls=[ - ExpectedToolCall( - func=send_draft_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1), - ], - ) - - suite.add_case( - name="Reply all to email", - user_message=("Reply to everyone - 'sounds good to me'"), - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA", - "body": "sounds good to me", - "reply_type": ReplyType.REPLY_ALL, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1 / 3), - SimilarityCritic(critic_field="body", weight=1 / 3), - BinaryCritic(critic_field="reply_type", weight=1 / 3), - ], - additional_messages=list_emails_with_pagination_token_additional_messages, - ) - - suite.add_case( - name="Reply to email", - user_message=("Reply to the account security team - 'sounds good to me'"), - expected_tool_calls=[ - ExpectedToolCall( - func=reply_to_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDAAAAFuxokOLZRtDncM4_x_WeUwAAAABc_ezAAAA", - "body": "sounds good to me", - "reply_type": ReplyType.REPLY, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1 / 3), - SimilarityCritic(critic_field="body", weight=1 / 3), - BinaryCritic(critic_field="reply_type", weight=1 / 3), - ], - additional_messages=list_emails_with_pagination_token_additional_messages, - ) - - return suite diff --git a/toolkits/outlook_mail/evals/eval_write.py b/toolkits/outlook_mail/evals/eval_write.py deleted file mode 100644 index d341f669..00000000 --- a/toolkits/outlook_mail/evals/eval_write.py +++ /dev/null @@ -1,104 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_outlook_mail import create_draft_email, update_draft_email -from evals.additional_messages import ( - update_draft_email_additional_messages, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_tool(create_draft_email, "OutlookMail") -catalog.add_tool(update_draft_email, "OutlookMail") - - -@tool_eval() -def outlook_mail_write_eval_suite() -> EvalSuite: - """Create an evaluation suite for Outlook Mail tools.""" - suite = EvalSuite( - name="Outlook Mail Write Evaluation", - system_message=("You are an AI that has access to tools to send, read, and write emails."), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create draft email", - user_message=( - "create a new draft email with subject 'Hello friends' and body " - "'I've gathered you all here to celebrate the launch of the new Arcade platform." - "address it to e@arcade.dev and z@arcade.dev. also carbon copy to j@arcade.dev, " - "f@arcade.dev, k@arcade.dev and finally to m@arcade.dev. also bcc to r@arcade.dev" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_draft_email, - args={ - "subject": "Hello friends", - "body": "I've gathered you all here to celebrate the launch of the new Arcade platform.", - "to_recipients": ["e@arcade.dev", "z@arcade.dev"], - "cc_recipients": [ - "j@arcade.dev", - "f@arcade.dev", - "k@arcade.dev", - "m@arcade.dev", - ], - "bcc_recipients": ["r@arcade.dev"], - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="subject", weight=0.2), - SimilarityCritic(critic_field="body", weight=0.2), - BinaryCritic(critic_field="to_recipients", weight=0.2), - BinaryCritic(critic_field="cc_recipients", weight=0.2), - BinaryCritic(critic_field="bcc_recipients", weight=0.2), - ], - ) - - suite.add_case( - name="Update draft email", - user_message=( - "oh wait i think i messed up on some emails. I meant 'z', not 'e'. " - "Also, I forgot to bcc y@arcade.dev. Also, replace the period with an " - "exclamation point since I want to convey excitement. Oh I almost forgot, " - "Don't cc anyone." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=update_draft_email, - args={ - "message_id": "AQMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMHAFuxokOLZRtDncM4_x_WeUwAAAIBDwAAAFuxokOLZRtDncM4_x_WeUwAAAAC-dpvAAAA", - "body": "I've gathered you all here to celebrate the launch of the new Arcade platform!", - "to_add": ["z@arcade.dev"], - "to_remove": ["e@arcade.dev"], - "cc_remove": ["j@arcade.dev", "f@arcade.dev", "k@arcade.dev", "m@arcade.dev"], - "bcc_add": ["y@arcade.dev"], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="message_id", weight=1 / 6), - BinaryCritic(critic_field="body", weight=1 / 6), - BinaryCritic(critic_field="to_add", weight=1 / 6), - BinaryCritic(critic_field="to_remove", weight=1 / 6), - BinaryCritic(critic_field="cc_remove", weight=1 / 6), - BinaryCritic(critic_field="bcc_add", weight=1 / 6), - ], - additional_messages=update_draft_email_additional_messages, - ) - - return suite diff --git a/toolkits/outlook_mail/pyproject.toml b/toolkits/outlook_mail/pyproject.toml deleted file mode 100644 index 765458b6..00000000 --- a/toolkits/outlook_mail/pyproject.toml +++ /dev/null @@ -1,60 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_outlook_mail" -version = "1.0.0" -description = "Arcade.dev LLM tools for Outlook Mail" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "msgraph-sdk>=1.28.0,<2.0.0", - "beautifulsoup4>=4.10.0,<5.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<9.0.0", - "pytest-cov>=4.0.0,<5.0.0", - "pytest-mock>=3.11.1,<4.0.0", - "pytest-asyncio>=0.24.0,<1.0.0", - "mypy>=1.5.1,<2.0.0", - "pre-commit>=3.4.0,<4.0.0", - "tox>=4.11.1,<5.0.0", - "ruff>=0.7.4,<1.0.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_outlook_mail/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_outlook_mail",] diff --git a/toolkits/outlook_mail/tests/__init__.py b/toolkits/outlook_mail/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/outlook_mail/tests/test_message.py b/toolkits/outlook_mail/tests/test_message.py deleted file mode 100644 index 1ac53e09..00000000 --- a/toolkits/outlook_mail/tests/test_message.py +++ /dev/null @@ -1,249 +0,0 @@ -import pytest -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.message import Message as GraphMessage -from msgraph.generated.models.recipient import Recipient as GraphRecipient - -from arcade_outlook_mail.message import Message, Recipient - - -# Dummy classes to simulate SDK objects -class DummyBody: - def __init__(self, content): - self.content = content - - -class DummyFlagStatus: - def __init__(self, value): - self.value = value - - -class DummyImportance: - def __init__(self, value): - self.value = value - - -class DummyDueDateTime: - def __init__(self, date_time): - self.date_time = date_time - - -class DummyFlag: - def __init__(self, flag_status, due_date_time): - self.flag_status = DummyFlagStatus(flag_status) - self.due_date_time = DummyDueDateTime(due_date_time) - - -class DummyDateTime: - def __init__(self, date_str): - self.date_str = date_str - - def isoformat(self): - return self.date_str - - -def make_graph_recipient(rec_data): - recipient = GraphRecipient() - recipient.email_address = GraphEmailAddress() - recipient.email_address.address = rec_data["email_address"] - recipient.email_address.name = rec_data.get("name", "") - return recipient - - -@pytest.mark.parametrize( - "input_data, expected", - [ - ( - { - "body_content": "

Hello world

", - "subject": "Test subject", - "conversation_id": "conv-1", - "conversation_index": "conv-index", - "flag_status": "flagged", - "due_date_time": "2021-01-01T10:00:00", - "has_attachments": False, - "importance": "high", - "is_read": True, - "received_date_time": "2021-01-02T00:00:00", - "web_link": "http://example.com", - "is_draft": False, - "message_id": "1234", - "to_recipients": [{"email_address": "to@example.com", "name": "ToName"}], - "cc_recipients": [{"email_address": "cc@example.com", "name": "CcName"}], - "bcc_recipients": [{"email_address": "bcc@example.com", "name": "BccName"}], - "reply_to": [{"email_address": "reply@example.com", "name": "ReplyName"}], - "from_": {"email_address": "from@example.com", "name": "FromName"}, - "conversation_index_bytes": False, - }, - { - "body": "Hello world", - "subject": "Test subject", - "conversation_id": "conv-1", - "conversation_index": "conv-index", - "flag": {"flag_status": "flagged", "due_date_time": "2021-01-01T10:00:00"}, - "has_attachments": False, - "importance": "high", - "is_read": True, - "received_date_time": "2021-01-02T00:00:00", - "web_link": "http://example.com", - "is_draft": False, - "message_id": "1234", - "to_recipients": [{"email_address": "to@example.com", "name": "ToName"}], - "cc_recipients": [{"email_address": "cc@example.com", "name": "CcName"}], - "bcc_recipients": [{"email_address": "bcc@example.com", "name": "BccName"}], - "reply_to": [{"email_address": "reply@example.com", "name": "ReplyName"}], - "from_": {"email_address": "from@example.com", "name": "FromName"}, - }, - ), - ( - { - "body_content": "

Sample email message

", - "subject": "Another subject", - "conversation_id": "conv-2", - "conversation_index": b"byte-index", - "flag_status": "notFlaged", - "due_date_time": "", - "has_attachments": False, - "importance": "low", - "is_read": False, - "received_date_time": "", - "web_link": "", - "is_draft": True, - "message_id": "5678", - "to_recipients": [{"email_address": "user1@example.com", "name": "User1"}], - "cc_recipients": [], - "bcc_recipients": [], - "reply_to": [], - "from_": {"email_address": "sender@example.com", "name": "Sender"}, - "conversation_index_bytes": True, - }, - { - "body": "Sample email message", - "subject": "Another subject", - "conversation_id": "conv-2", - "conversation_index": "byte-index", - "flag": {"flag_status": "notFlaged", "due_date_time": ""}, - "has_attachments": False, - "importance": "low", - "is_read": False, - "received_date_time": "", - "web_link": "", - "is_draft": True, - "message_id": "5678", - "to_recipients": [{"email_address": "user1@example.com", "name": "User1"}], - "cc_recipients": [], - "bcc_recipients": [], - "reply_to": [], - "from_": {"email_address": "sender@example.com", "name": "Sender"}, - }, - ), - ], -) -def test_message_conversion(input_data, expected): - # Set up sdk message - sdk_message = GraphMessage() - sdk_message.body = ( - DummyBody(input_data["body_content"]) if "body_content" in input_data else None - ) - sdk_message.subject = input_data["subject"] - sdk_message.conversation_id = input_data["conversation_id"] - sdk_message.conversation_index = input_data["conversation_index"] - sdk_message.flag = ( - DummyFlag(input_data["flag_status"], input_data["due_date_time"]) - if "flag_status" in input_data - else None - ) - sdk_message.has_attachments = input_data["has_attachments"] - sdk_message.importance = DummyImportance(input_data["importance"]) - sdk_message.is_read = input_data["is_read"] - sdk_message.received_date_time = ( - DummyDateTime(input_data["received_date_time"]) - if input_data["received_date_time"] - else None - ) - sdk_message.web_link = input_data["web_link"] - sdk_message.is_draft = input_data["is_draft"] - sdk_message.id = input_data["message_id"] - sdk_message.to_recipients = [make_graph_recipient(r) for r in input_data["to_recipients"]] - sdk_message.cc_recipients = [make_graph_recipient(r) for r in input_data["cc_recipients"]] - sdk_message.bcc_recipients = [make_graph_recipient(r) for r in input_data["bcc_recipients"]] - sdk_message.reply_to = [make_graph_recipient(r) for r in input_data["reply_to"]] - sdk_message.from_ = make_graph_recipient(input_data["from_"]) - - # Convert to Arcade Message type - message = Message.from_sdk(sdk_message) - - # Ensure conversion is correct - assert message.body == expected["body"], "Body conversion mismatch" - assert message.subject == expected["subject"] - assert message.conversation_id == expected["conversation_id"] - assert message.conversation_index == expected["conversation_index"] - assert message.flag == expected["flag"] - assert message.has_attachments == expected["has_attachments"] - assert message.importance == expected["importance"] - assert message.is_read == expected["is_read"] - assert message.received_date_time == expected["received_date_time"] - assert message.web_link == expected["web_link"] - assert message.is_draft == expected["is_draft"] - assert message.message_id == expected["message_id"] - assert message.from_.email_address == expected["from_"]["email_address"] - assert message.from_.name == expected["from_"]["name"] - - def check_recipient_list(actual, exp_list): - assert len(actual) == len(exp_list) - for rec, exp in zip(actual, exp_list, strict=False): - assert rec.email_address == exp["email_address"] - assert rec.name == exp["name"] - - check_recipient_list(message.to_recipients, expected["to_recipients"]) - check_recipient_list(message.cc_recipients, expected["cc_recipients"]) - check_recipient_list(message.bcc_recipients, expected["bcc_recipients"]) - check_recipient_list(message.reply_to, expected["reply_to"]) - - -@pytest.mark.parametrize( - "initial, add_params, expected_to_recipients", - [ - # Add a "To" recipient - ( - {"to_recipients": []}, - {"to_add": ["new@example.com"]}, - [{"email_address": "new@example.com", "name": ""}], - ), - # Add a "To" recipient that already exists - ( - {"to_recipients": [{"email_address": "dup@example.com", "name": ""}]}, - {"to_add": ["dup@example.com"]}, - [ - {"email_address": "dup@example.com", "name": ""}, - ], - ), - # Remove a "To" recipient - ( - { - "to_recipients": [ - {"email_address": "a@example.com", "name": "A"}, - {"email_address": "b@example.com", "name": "B"}, - ] - }, - {"to_remove": ["a@example.com"]}, - [{"email_address": "b@example.com", "name": "B"}], - ), - # Add and remove a "To" recipient - ( - {"to_recipients": [{"email_address": "c@example.com", "name": "C"}]}, - {"to_add": ["d@example.com", "c@example.com"], "to_remove": ["c@example.com"]}, - [{"email_address": "d@example.com", "name": ""}], - ), - ], -) -def test_update_recipient_lists(initial, add_params, expected_to_recipients): - msg = Message() - msg.to_recipients = [ - Recipient(email_address=r["email_address"], name=r.get("name", "")) - for r in initial.get("to_recipients", []) - ] - msg.update_recipient_lists( - to_add=add_params.get("to_add"), to_remove=add_params.get("to_remove") - ) - result = [r.to_dict() for r in msg.to_recipients] - assert result == expected_to_recipients, f"Expected {expected_to_recipients}, got {result}" diff --git a/toolkits/outlook_mail/tests/test_recipient.py b/toolkits/outlook_mail/tests/test_recipient.py deleted file mode 100644 index a039064f..00000000 --- a/toolkits/outlook_mail/tests/test_recipient.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress -from msgraph.generated.models.recipient import Recipient as GraphRecipient - -from arcade_outlook_mail.message import Recipient - - -@pytest.mark.parametrize( - "input_sdk_recipient, expected_email, expected_name", - [ - ( - GraphRecipient(email_address=GraphEmailAddress(address="dev@arcade.dev", name="Dev")), - "dev@arcade.dev", - "Dev", - ), - ( - GraphRecipient(email_address=GraphEmailAddress(address="dev@arcade.dev")), - "dev@arcade.dev", - "", - ), - (GraphRecipient(email_address=GraphEmailAddress(name="Dev")), "", "Dev"), - (GraphRecipient(email_address=GraphEmailAddress()), "", ""), - (GraphRecipient(), "", ""), - ], -) -def test_recipient(input_sdk_recipient, expected_email, expected_name): - recipient = Recipient.from_sdk(input_sdk_recipient) - assert ( - recipient.email_address == expected_email - ), "SDK conversion didn't set email_address correctly" - assert recipient.name == expected_name, "SDK conversion didn't set name correctly" - - recipient_dict = recipient.to_dict() - expected_dict = {"email_address": expected_email, "name": expected_name} - assert recipient_dict == expected_dict, "to_dict conversion did not produce expected dictionary" - - actual_sdk_recipient = recipient.to_sdk() - assert ( - actual_sdk_recipient.email_address.address == expected_email - ), "to_sdk conversion produced wrong email address" - assert ( - actual_sdk_recipient.email_address.name == expected_name - ), "to_sdk conversion produced wrong name" diff --git a/toolkits/outlook_mail/tests/test_utils.py b/toolkits/outlook_mail/tests/test_utils.py deleted file mode 100644 index 563adad8..00000000 --- a/toolkits/outlook_mail/tests/test_utils.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest - -from arcade_outlook_mail._utils import _create_filter_expression -from arcade_outlook_mail.enums import EmailFilterProperty, FilterOperator - - -@pytest.mark.parametrize( - "property_, operator, value, expected_filter_expr", - [ - ( - EmailFilterProperty.SUBJECT, - FilterOperator.EQUAL, - "Hello", - "receivedDateTime ge 1900-01-01T00:00:00Z and subject eq 'Hello'", - ), - ( - EmailFilterProperty.SUBJECT, - FilterOperator.STARTS_WITH, - "He", - "receivedDateTime ge 1900-01-01T00:00:00Z and startsWith(subject, 'He')", - ), - ( - EmailFilterProperty.CONVERSATION_ID, - FilterOperator.EQUAL, - "12345askdfjh=wef67890", - "receivedDateTime ge 1900-01-01T00:00:00Z and conversationId eq '12345askdfjh=wef67890'", - ), - ( - EmailFilterProperty.CONVERSATION_ID, - FilterOperator.NOT_EQUAL, - "67890", - "receivedDateTime ge 1900-01-01T00:00:00Z and conversationId ne 67890", - ), - ( - EmailFilterProperty.RECEIVED_DATE_TIME, - FilterOperator.GREATER_THAN, - "2024-01-01", - "receivedDateTime gt '2024-01-01'", - ), - ( - EmailFilterProperty.SENDER, - FilterOperator.EQUAL, - "a@ex.com", - "receivedDateTime ge 1900-01-01T00:00:00Z and sender/emailAddress/address eq 'a@ex.com'", - ), - ( - EmailFilterProperty.SENDER, - FilterOperator.CONTAINS, - "joe", - "receivedDateTime ge 1900-01-01T00:00:00Z and contains(sender/emailAddress/address, 'joe')", - ), - ], -) -def test_create_filter_expression(property_, operator, value, expected_filter_expr): - assert _create_filter_expression(property_, operator, value) == expected_filter_expr diff --git a/toolkits/postgres/Makefile b/toolkits/postgres/Makefile deleted file mode 100644 index 7e2c686e..00000000 --- a/toolkits/postgres/Makefile +++ /dev/null @@ -1,53 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @uv run pre-commit install - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @uv run pre-commit install - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - coverage report - @echo "Generating coverage report" - coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --bump patch - -.PHONY: check -check: ## Run code quality tools. - @echo "🚀 Linting code: Running pre-commit" - @uv run pre-commit run -a - @echo "🚀 Static type checking: Running mypy" - @uv run mypy --config-file=pyproject.toml diff --git a/toolkits/postgres/arcade_postgres/__init__.py b/toolkits/postgres/arcade_postgres/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/postgres/arcade_postgres/database_engine.py b/toolkits/postgres/arcade_postgres/database_engine.py deleted file mode 100644 index e2aaf39c..00000000 --- a/toolkits/postgres/arcade_postgres/database_engine.py +++ /dev/null @@ -1,180 +0,0 @@ -from typing import Any, ClassVar -from urllib.parse import urlparse - -from arcade_tdk.errors import RetryableToolError -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine - -MAX_ROWS_RETURNED = 1000 -TEST_QUERY = "SELECT 1" - - -class DatabaseEngine: - _instance: ClassVar[None] = None - _engines: ClassVar[dict[str, AsyncEngine]] = {} - - @classmethod - async def get_instance(cls, connection_string: str) -> AsyncEngine: - parsed_url = urlparse(connection_string) - - # TODO: something strange with sslmode= and friends - # query_params = parse_qs(parsed_url.query) - # query_params = { - # k: v[0] for k, v in query_params.items() - # } # assume one value allowed for each query param - - async_connection_string = f"{parsed_url.scheme.replace('postgresql', 'postgresql+asyncpg')}://{parsed_url.netloc}{parsed_url.path}" - key = f"{async_connection_string}" - if key not in cls._engines: - cls._engines[key] = create_async_engine(async_connection_string) - - # try a simple query to see if the connection is valid - try: - async with cls._engines[key].connect() as connection: - await connection.execute(text(TEST_QUERY)) - return cls._engines[key] - except Exception: - await cls._engines[key].dispose() - - # try again - try: - async with cls._engines[key].connect() as connection: - await connection.execute(text(TEST_QUERY)) - return cls._engines[key] - except Exception as e: - raise RetryableToolError( - f"Connection failed: {e}", - developer_message="Connection to postgres failed.", - additional_prompt_content="Check the connection string and try again.", - ) from e - - @classmethod - async def get_engine(cls, connection_string: str) -> Any: - engine = await cls.get_instance(connection_string) - - class ConnectionContextManager: - def __init__(self, engine: AsyncEngine) -> None: - self.engine = engine - - async def __aenter__(self) -> AsyncEngine: - return self.engine - - async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - # Connection cleanup is handled by the async context manager - pass - - return ConnectionContextManager(engine) - - @classmethod - async def cleanup(cls) -> None: - """Clean up all cached engines. Call this when shutting down.""" - for engine in cls._engines.values(): - await engine.dispose() - cls._engines.clear() - - @classmethod - def clear_cache(cls) -> None: - """Clear the engine cache without disposing engines. Use with caution.""" - cls._engines.clear() - - @classmethod - def sanitize_query( # noqa: C901 - cls, - select_clause: str, - from_clause: str, - limit: int, - offset: int, - join_clause: str | None, - where_clause: str | None, - having_clause: str | None, - group_by_clause: str | None, - order_by_clause: str | None, - with_clause: str | None, - ) -> tuple[str, dict[str, Any]]: - # Remove the leading keywords from the clauses if they are present - if select_clause.strip().split(" ")[0].upper() == "SELECT": - select_clause = select_clause.strip()[6:] - - if from_clause.strip().split(" ")[0].upper() == "FROM": - from_clause = from_clause.strip()[4:] - - if join_clause and join_clause.strip().split(" ")[0].upper() == "JOIN": - join_clause = join_clause.strip()[4:] - - if where_clause and where_clause.strip().split(" ")[0].upper() == "WHERE": - where_clause = where_clause.strip()[5:] - - if group_by_clause and group_by_clause.strip().split(" ")[0].upper() == "GROUP BY": - group_by_clause = group_by_clause.strip()[8:] - - if order_by_clause and order_by_clause.strip().split(" ")[0].upper() == "ORDER BY": - order_by_clause = order_by_clause.strip()[8:] - - if having_clause and having_clause.strip().split(" ")[0].upper() == "HAVING": - having_clause = having_clause.strip()[6:] - - first_select_word = select_clause.strip().split(" ")[0].upper() - if first_select_word in [ - "INSERT", - "UPDATE", - "DELETE", - "CREATE", - "ALTER", - "DROP", - "TRUNCATE", - "REINDEX", - "VACUUM", - "ANALYZE", - "COMMENT", - ]: - raise RetryableToolError( - "Only SELECT queries are allowed.", - ) - - if select_clause.strip() == "*": - raise RetryableToolError( - "Do not use * in the select clause. Use a comma separated list of columns you wish to return.", - ) - - if limit > MAX_ROWS_RETURNED: - raise RetryableToolError( - f"Limit is too high. Maximum is {MAX_ROWS_RETURNED}.", - ) - - if offset < 0: - raise RetryableToolError( - "Offset must be greater than or equal to 0.", - developer_message="Offset must be greater than or equal to 0.", - ) - - if limit <= 0: - raise RetryableToolError( - "Limit must be greater than 0.", - developer_message="Limit must be greater than 0.", - ) - - # Build query with identifiers directly interpolated, but use parameters for values - parts = [] - if with_clause: - parts.append(f"WITH {with_clause}") - parts.append(f"SELECT {select_clause} FROM {from_clause}") # noqa: S608 - if join_clause: - parts.append(f"JOIN {join_clause}") - if where_clause: - parts.append(f"WHERE {where_clause}") - if group_by_clause: - parts.append(f"GROUP BY {group_by_clause}") - if having_clause: - parts.append(f"HAVING {having_clause}") - if order_by_clause: - parts.append(f"ORDER BY {order_by_clause}") - parts.append("LIMIT :limit OFFSET :offset") - query = " ".join(parts) - - # Only use parameters for values, not identifiers - parameters = { - "limit": limit, - "offset": offset, - } - - return query, parameters diff --git a/toolkits/postgres/arcade_postgres/tools/__init__.py b/toolkits/postgres/arcade_postgres/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/postgres/arcade_postgres/tools/postgres.py b/toolkits/postgres/arcade_postgres/tools/postgres.py deleted file mode 100644 index fd14daa9..00000000 --- a/toolkits/postgres/arcade_postgres/tools/postgres.py +++ /dev/null @@ -1,253 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import RetryableToolError -from sqlalchemy import inspect, text -from sqlalchemy.ext.asyncio import AsyncEngine - -from ..database_engine import MAX_ROWS_RETURNED, DatabaseEngine - - -@tool(requires_secrets=["DATABASE_CONNECTION_STRING"]) -async def discover_schemas( - context: ToolContext, -) -> list[str]: - """Discover all the schemas in the postgres database.""" - async with await DatabaseEngine.get_engine( - context.get_secret("DATABASE_CONNECTION_STRING") - ) as engine: - schemas = await _get_schemas(engine) - return schemas - - -@tool(requires_secrets=["DATABASE_CONNECTION_STRING"]) -async def discover_tables( - context: ToolContext, - schema_name: Annotated[ - str, "The database schema to discover tables in (default value: 'public')" - ] = "public", -) -> list[str]: - """Discover all the tables in the postgres database when the list of tables is not known. - - ALWAYS use this tool before any other tool that requires a table name. - """ - async with await DatabaseEngine.get_engine( - context.get_secret("DATABASE_CONNECTION_STRING") - ) as engine: - tables = await _get_tables(engine, schema_name) - return tables - - -@tool(requires_secrets=["DATABASE_CONNECTION_STRING"]) -async def get_table_schema( - context: ToolContext, - schema_name: Annotated[str, "The database schema to get the table schema of"], - table_name: Annotated[str, "The table to get the schema of"], -) -> list[str]: - """ - Get the schema/structure of a postgres table in the postgres database when the schema is not known, and the name of the table is provided. - - This tool should ALWAYS be used before executing any query. All tables in the query must be discovered first using the tool. - """ - async with await DatabaseEngine.get_engine( - context.get_secret("DATABASE_CONNECTION_STRING") - ) as engine: - return await _get_table_schema(engine, schema_name, table_name) - - -@tool(requires_secrets=["DATABASE_CONNECTION_STRING"]) -async def execute_select_query( - context: ToolContext, - select_clause: Annotated[ - str, - "This is the part of the SQL query that comes after the SELECT keyword wish a comma separated list of columns you wish to return. Do not include the SELECT keyword.", - ], - from_clause: Annotated[ - str, - "This is the part of the SQL query that comes after the FROM keyword. Do not include the FROM keyword.", - ], - limit: Annotated[ - int, - "The maximum number of rows to return. This is the LIMIT clause of the query. Default: 100.", - ] = 100, - offset: Annotated[ - int, "The number of rows to skip. This is the OFFSET clause of the query. Default: 0." - ] = 0, - join_clause: Annotated[ - str | None, - "This is the part of the SQL query that comes after the JOIN keyword. Do not include the JOIN keyword. If no join is needed, leave this blank.", - ] = None, - where_clause: Annotated[ - str | None, - "This is the part of the SQL query that comes after the WHERE keyword. Do not include the WHERE keyword. If no where clause is needed, leave this blank.", - ] = None, - having_clause: Annotated[ - str | None, - "This is the part of the SQL query that comes after the HAVING keyword. Do not include the HAVING keyword. If no having clause is needed, leave this blank.", - ] = None, - group_by_clause: Annotated[ - str | None, - "This is the part of the SQL query that comes after the GROUP BY keyword. Do not include the GROUP BY keyword. If no group by clause is needed, leave this blank.", - ] = None, - order_by_clause: Annotated[ - str | None, - "This is the part of the SQL query that comes after the ORDER BY keyword. Do not include the ORDER BY keyword. If no order by clause is needed, leave this blank.", - ] = None, - with_clause: Annotated[ - str | None, - "This is the part of the SQL query that comes after the WITH keyword when basing the query on a virtual table. If no WITH clause is needed, leave this blank.", - ] = None, -) -> list[str]: - """ - You have a connection to a postgres database. - Execute a SELECT query and return the results against the postgres database. No other queries (INSERT, UPDATE, DELETE, etc.) are allowed. - - ONLY use this tool if you have already loaded the schema of the tables you need to query. Use the tool to load the schema if not already known. - - The final query will be constructed as follows: - SELECT {select_query_part} FROM {from_clause} JOIN {join_clause} WHERE {where_clause} HAVING {having_clause} ORDER BY {order_by_clause} LIMIT {limit} OFFSET {offset} - - When running queries, follow these rules which will help avoid errors: - * Never "select *" from a table. Always select the columns you need. - * Always order your results by the most important columns first. If you aren't sure, order by the primary key. - * Always use case-insensitive queries to match strings in the query. - * Always trim strings in the query. - * Prefer LIKE queries over direct string matches or regex queries. - * Only join on columns that are indexed or the primary key. Do not join on arbitrary columns. - """ - async with await DatabaseEngine.get_engine( - context.get_secret("DATABASE_CONNECTION_STRING") - ) as engine: - try: - return await _execute_query( - engine, - select_clause=select_clause, - from_clause=from_clause, - limit=limit, - offset=offset, - join_clause=join_clause, - where_clause=where_clause, - having_clause=having_clause, - group_by_clause=group_by_clause, - order_by_clause=order_by_clause, - with_clause=with_clause, - ) - except Exception as e: - raise RetryableToolError( - f"Query failed: {e}", - developer_message=f"Query failed with parameters: select_clause={select_clause}, from_clause={from_clause}, limit={limit}, offset={offset}, join_clause={join_clause}, where_clause={where_clause}, having_clause={having_clause}, order_by_clause={order_by_clause}, with_clause={with_clause}.", - additional_prompt_content="Load the database schema or use the tool to discover the tables and try again.", - retry_after_ms=10, - ) from e - - -async def _get_schemas(engine: AsyncEngine) -> list[str]: - """Get all the schemas in the database""" - async with engine.connect() as conn: - - def get_schema_names(sync_conn: Any) -> list[str]: - return list(inspect(sync_conn).get_schema_names()) - - schemas: list[str] = await conn.run_sync(get_schema_names) - schemas = [schema for schema in schemas if schema != "information_schema"] - - return schemas - - -async def _get_tables(engine: AsyncEngine, schema_name: str) -> list[str]: - """Get all the tables in the database""" - async with engine.connect() as conn: - - def get_schema_names(sync_conn: Any) -> list[str]: - return list(inspect(sync_conn).get_schema_names()) - - schemas: list[str] = await conn.run_sync(get_schema_names) - tables = [] - for schema in schemas: - if schema == schema_name: - - def get_table_names(sync_conn: Any, s: str = schema) -> list[str]: - return list(inspect(sync_conn).get_table_names(schema=s)) - - these_tables = await conn.run_sync(get_table_names) - tables.extend(these_tables) - return tables - - -async def _get_table_schema(engine: AsyncEngine, schema_name: str, table_name: str) -> list[str]: - """Get the schema of a table""" - async with engine.connect() as connection: - - def get_columns(sync_conn: Any, t: str = table_name, s: str = schema_name) -> list[Any]: - return list(inspect(sync_conn).get_columns(t, s)) - - columns_table = await connection.run_sync(get_columns) - - # Get primary key information - pk_constraint = await connection.run_sync( - lambda sync_conn: inspect(sync_conn).get_pk_constraint(table_name, schema_name) - ) - primary_keys = set(pk_constraint.get("constrained_columns", [])) - - # Get index information - indexes = await connection.run_sync( - lambda sync_conn: inspect(sync_conn).get_indexes(table_name, schema_name) - ) - indexed_columns = set() - for index in indexes: - indexed_columns.update(index.get("column_names", [])) - - results = [] - for column in columns_table: - column_name = column["name"] - column_type = column["type"].python_type.__name__ - - # Build column description - description = f"{column_name}: {column_type}" - - # Add primary key indicator - if column_name in primary_keys: - description += " (PRIMARY KEY)" - - # Add index indicator - if column_name in indexed_columns: - description += " (INDEXED)" - - results.append(description) - - return results[:MAX_ROWS_RETURNED] - - -async def _execute_query( - engine: AsyncEngine, - select_clause: str, - from_clause: str, - limit: int, - offset: int, - join_clause: str | None, - where_clause: str | None, - having_clause: str | None, - group_by_clause: str | None, - order_by_clause: str | None, - with_clause: str | None, -) -> list[str]: - """Execute a query and return the results.""" - async with engine.connect() as connection: - query, parameters = DatabaseEngine.sanitize_query( - select_clause=select_clause, - from_clause=from_clause, - limit=limit, - offset=offset, - join_clause=join_clause, - where_clause=where_clause, - having_clause=having_clause, - group_by_clause=group_by_clause, - order_by_clause=order_by_clause, - with_clause=with_clause, - ) - print(f"Query: {query}") - print(f"Parameters: {parameters}") - result = await connection.execute(text(query), parameters) - rows = result.fetchall() - results = [str(row) for row in rows] - return results[:MAX_ROWS_RETURNED] diff --git a/toolkits/postgres/evals/eval_postgres.py b/toolkits/postgres/evals/eval_postgres.py deleted file mode 100644 index ef2517e1..00000000 --- a/toolkits/postgres/evals/eval_postgres.py +++ /dev/null @@ -1,94 +0,0 @@ -import arcade_postgres -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_postgres.tools.postgres import ( - discover_tables, - execute_query, - get_table_schema, -) -from arcade_tdk import ToolCatalog - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_postgres) - - -@tool_eval() -def sql_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="sql Tools Evaluation", - system_message=( - "You are an AI assistant with access to sql tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get user by id (schema known)", - user_message="Tell me the name and email of user #1 in my database. The table 'users' has the following schema: id: int, name: str, email: str, password_hash: str, created_at: datetime, updated_at: datetime", - expected_tool_calls=[ - ExpectedToolCall( - func=execute_query, args={"query": "SELECT name, email FROM users WHERE id = 1"} - ) - ], - rubric=rubric, - critics=[SimilarityCritic(critic_field="query", weight=1.0)], - ) - - suite.add_case( - name="Discover tables", - user_message="What tables are in my database?", - expected_tool_calls=[ - ExpectedToolCall(func=discover_tables, args={}), - ], - rubric=rubric, - ) - - suite.add_case( - name="Get table schema (schema provided)", - user_message="What columns are in the table 'public.users' in my database?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_table_schema, args={"schema_name": "public", "table_name": "users"} - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="schema_name", weight=0.5), - BinaryCritic(critic_field="table_name", weight=0.5), - ], - ) - - suite.add_case( - name="Get table schema (schema not provided)", - user_message="What columns are in the table 'users' in my database?", - additional_messages=[ - {"role": "user", "content": "When not provided, the schema is 'public'."} - ], - expected_tool_calls=[ - ExpectedToolCall( - func=get_table_schema, args={"schema_name": "public", "table_name": "users"} - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="schema_name", weight=0.5), - BinaryCritic(critic_field="table_name", weight=0.5), - ], - ) - - return suite diff --git a/toolkits/postgres/pyproject.toml b/toolkits/postgres/pyproject.toml deleted file mode 100644 index d39264e6..00000000 --- a/toolkits/postgres/pyproject.toml +++ /dev/null @@ -1,65 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_postgres" -version = "0.2.0" -description = "Tools to query and explore a postgres database" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "psycopg2-binary>=2.9.10", - "pydantic>=2.11.7", - "sqlalchemy>=2.0.41", - "psycopg2-binary>=2.9.10", - "asyncpg>=0.30.0", - "greenlet>=3.2.3", -] -[[project.authors]] -name = "evantahler" -email = "support@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_postgres/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] -asyncio_default_fixture_loop_scope = "function" - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_postgres",] diff --git a/toolkits/postgres/tests/__init__.py b/toolkits/postgres/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/postgres/tests/dump.sql b/toolkits/postgres/tests/dump.sql deleted file mode 100644 index a94b7ee1..00000000 --- a/toolkits/postgres/tests/dump.sql +++ /dev/null @@ -1,399 +0,0 @@ -DROP TABLE IF EXISTS "public"."messages"; --- This script only contains the table creation statements and does not fully represent the table in the database. Do not use it as a backup. --- Sequence and defined type -CREATE SEQUENCE IF NOT EXISTS messages_id_seq; --- Table Definition -CREATE TABLE "public"."messages" ( - "id" int4 NOT NULL DEFAULT nextval('messages_id_seq'::regclass), - "body" text NOT NULL, - "user_id" int4 NOT NULL, - "created_at" timestamp NOT NULL DEFAULT now(), - "updated_at" timestamp NOT NULL DEFAULT now(), - PRIMARY KEY ("id") -); -DROP TABLE IF EXISTS "public"."users"; --- This script only contains the table creation statements and does not fully represent the table in the database. Do not use it as a backup. --- Sequence and defined type -CREATE SEQUENCE IF NOT EXISTS users_id_seq; --- Table Definition -CREATE TABLE "public"."users" ( - "id" int4 NOT NULL DEFAULT nextval('users_id_seq'::regclass), - "name" varchar(256) NOT NULL, - "email" text NOT NULL, - "password_hash" text NOT NULL, - "created_at" timestamp NOT NULL DEFAULT now(), - "updated_at" timestamp NOT NULL DEFAULT now(), - "status" varchar, - PRIMARY KEY ("id") -); -INSERT INTO "public"."messages" ( - "id", - "body", - "user_id", - "created_at", - "updated_at" - ) -VALUES -- User 1 (Alice) - 3 messages - ( - 1, - 'Hello everyone!', - 1, - '2025-01-10 10:00:00.000000', - '2025-01-10 10:00:00.000000' - ), - ( - 2, - 'How is everyone doing today?', - 1, - '2025-01-10 11:30:00.000000', - '2025-01-10 11:30:00.000000' - ), - ( - 3, - 'Great to see you all here!', - 1, - '2025-01-10 14:15:00.000000', - '2025-01-10 14:15:00.000000' - ), - -- User 2 (Bob) - 2 messages - ( - 4, - 'Hi Alice! Doing well, thanks for asking.', - 2, - '2025-01-10 11:35:00.000000', - '2025-01-10 11:35:00.000000' - ), - ( - 5, - 'Anyone up for a game later?', - 2, - '2025-01-10 16:20:00.000000', - '2025-01-10 16:20:00.000000' - ), - -- User 3 (Charlie) - 3 messages - ( - 6, - 'Count me in for the game!', - 3, - '2025-01-10 16:25:00.000000', - '2025-01-10 16:25:00.000000' - ), - ( - 7, - 'What time works for everyone?', - 3, - '2025-01-10 16:30:00.000000', - '2025-01-10 16:30:00.000000' - ), - ( - 8, - 'I can play around 8 PM', - 3, - '2025-01-10 17:00:00.000000', - '2025-01-10 17:00:00.000000' - ), - -- User 4 (Diana) - 2 messages - ( - 9, - '8 PM works for me too!', - 4, - '2025-01-10 17:05:00.000000', - '2025-01-10 17:05:00.000000' - ), - ( - 10, - 'What game should we play?', - 4, - '2025-01-10 17:10:00.000000', - '2025-01-10 17:10:00.000000' - ), - -- User 5 (Evan) - 3 messages - ( - 11, - 'I suggest we try the new arcade game!', - 5, - '2025-01-10 17:15:00.000000', - '2025-01-10 17:15:00.000000' - ), - ( - 12, - 'It has great multiplayer features', - 5, - '2025-01-10 17:20:00.000000', - '2025-01-10 17:20:00.000000' - ), - ( - 13, - 'Perfect timing for a weekend session', - 5, - '2025-01-10 18:00:00.000000', - '2025-01-10 18:00:00.000000' - ), - -- User 6 (Fiona) - 2 messages - ( - 14, - 'Sounds like fun! I love arcade games.', - 6, - '2025-01-10 18:05:00.000000', - '2025-01-10 18:05:00.000000' - ), - ( - 15, - 'Should I bring snacks?', - 6, - '2025-01-10 18:10:00.000000', - '2025-01-10 18:10:00.000000' - ), - -- User 7 (George) - 3 messages - ( - 16, - 'Snacks are always welcome!', - 7, - '2025-01-10 18:15:00.000000', - '2025-01-10 18:15:00.000000' - ), - ( - 17, - 'I can bring some drinks', - 7, - '2025-01-10 18:20:00.000000', - '2025-01-10 18:20:00.000000' - ), - ( - 18, - 'This is going to be awesome', - 7, - '2025-01-10 19:00:00.000000', - '2025-01-10 19:00:00.000000' - ), - -- User 8 (Helen) - 2 messages - ( - 19, - 'I agree! Cannot wait for the game night.', - 8, - '2025-01-10 19:05:00.000000', - '2025-01-10 19:05:00.000000' - ), - ( - 20, - 'Should we set up a Discord call?', - 8, - '2025-01-10 19:10:00.000000', - '2025-01-10 19:10:00.000000' - ), - -- User 9 (Ian) - 3 messages - ( - 21, - 'Discord would be perfect for voice chat', - 9, - '2025-01-10 19:15:00.000000', - '2025-01-10 19:15:00.000000' - ), - ( - 22, - 'I will create a server for us', - 9, - '2025-01-10 19:20:00.000000', - '2025-01-10 19:20:00.000000' - ), - ( - 23, - 'Link will be shared in a few minutes', - 9, - '2025-01-10 19:25:00.000000', - '2025-01-10 19:25:00.000000' - ), - -- User 10 (Julia) - 2 messages - ( - 24, - 'Thanks Ian! You are the best.', - 10, - '2025-01-10 19:30:00.000000', - '2025-01-10 19:30:00.000000' - ), - ( - 25, - 'See you all at 8 PM!', - 10, - '2025-01-10 19:35:00.000000', - '2025-01-10 19:35:00.000000' - ), - -- Additional messages for Evan (user_id 5) - 10 more messages - ( - 26, - 'Just finished setting up the game server!', - 5, - '2025-01-10 20:00:00.000000', - '2025-01-10 20:00:00.000000' - ), - ( - 27, - 'Everyone should be able to connect now', - 5, - '2025-01-10 20:05:00.000000', - '2025-01-10 20:05:00.000000' - ), - ( - 28, - 'I added some custom maps too', - 5, - '2025-01-10 20:10:00.000000', - '2025-01-10 20:10:00.000000' - ), - ( - 29, - 'The graphics look amazing on this new version', - 5, - '2025-01-10 20:15:00.000000', - '2025-01-10 20:15:00.000000' - ), - ( - 30, - 'Hope you all enjoy the new features', - 5, - '2025-01-10 20:20:00.000000', - '2025-01-10 20:20:00.000000' - ), - ( - 31, - 'I also set up a leaderboard system', - 5, - '2025-01-10 20:25:00.000000', - '2025-01-10 20:25:00.000000' - ), - ( - 32, - 'We can track high scores now', - 5, - '2025-01-10 20:30:00.000000', - '2025-01-10 20:30:00.000000' - ), - ( - 33, - 'The game supports up to 8 players simultaneously', - 5, - '2025-01-10 20:35:00.000000', - '2025-01-10 20:35:00.000000' - ), - ( - 34, - 'I tested it earlier and it runs smoothly', - 5, - '2025-01-10 20:40:00.000000', - '2025-01-10 20:40:00.000000' - ), - ( - 35, - 'Cannot wait to see everyone online tonight!', - 5, - '2025-01-10 20:45:00.000000', - '2025-01-10 20:45:00.000000' - ); -INSERT INTO "public"."users" ( - "id", - "name", - "email", - "password_hash", - "created_at", - "updated_at", - "status" - ) -VALUES ( - 1, - 'Alice', - 'alice@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$tMg1Rd3IEDnp3iFKrqsF4Dsbw6/Cbf6seRB/H5bhaPg$zZj5yn4x3D3O3mDHcW2aczQNiYfAs3cw21XMEIgkF0E', - '2024-09-01 20:49:38.759432', - '2024-09-02 03:49:39.927', - 'active' - ), - ( - 2, - 'Bob', - 'bob@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$CvOMK1WUd99R7kYXpiBPNYw4OQP53pYIgeMnwz92mrE$HPthId4phMoPT1TWuCRHHCr9BSQA8XoUkQuB1HZsqTY', - '2024-09-02 17:49:23.377425', - '2024-09-02 17:49:23.377425', - 'active' - ), - ( - 3, - 'Charlie', - 'charlie@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$paCAAD1HVZkncP/WvecuUO6zFXp2/8BISpgr5rXRxps$M5kBFc9JHHGNw9SXnPu2ggpJY0mFFCska7TXMrllndo', - '2024-09-03 10:30:15.123456', - '2024-09-03 10:30:15.123456', - 'active' - ), - ( - 4, - 'Diana', - 'diana@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$xyz123ABC456DEF789GHI$SampleHashForDiana123', - '2024-09-04 14:20:30.654321', - '2024-09-04 14:20:30.654321', - 'active' - ), - ( - 5, - 'Evan', - 'evan@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$evanHash123$EvanPasswordHash456', - '2024-09-05 09:15:45.987654', - '2024-09-05 09:15:45.987654', - 'active' - ), - ( - 6, - 'Fiona', - 'fiona@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$fionaHash456$FionaPasswordHash789', - '2024-09-06 16:45:12.345678', - '2024-09-06 16:45:12.345678', - 'active' - ), - ( - 7, - 'George', - 'george@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$georgeHash789$GeorgePasswordHash012', - '2024-09-07 11:30:25.876543', - '2024-09-07 11:30:25.876543', - 'active' - ), - ( - 8, - 'Helen', - 'helen@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$helenHash012$HelenPasswordHash345', - '2024-09-08 13:25:40.234567', - '2024-09-08 13:25:40.234567', - 'active' - ), - ( - 9, - 'Ian', - 'ian@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$ianHash345$IanPasswordHash678', - '2024-09-09 08:40:55.765432', - '2024-09-09 08:40:55.765432', - 'active' - ), - ( - 10, - 'Julia', - 'julia@example.com', - '$argon2id$v=19$m=65536,t=2,p=1$juliaHash678$JuliaPasswordHash901', - '2024-09-10 15:55:18.123456', - '2024-09-10 15:55:18.123456', - 'active' - ); -ALTER TABLE "public"."messages" -ADD FOREIGN KEY ("user_id") REFERENCES "public"."users"("id"); --- set pk to 11 -ALTER SEQUENCE users_id_seq RESTART WITH 11; --- Indices -CREATE UNIQUE INDEX name_idx ON public.users USING btree (name); -CREATE UNIQUE INDEX email_idx ON public.users USING btree (email); -DROP INDEX IF EXISTS users_email_unique; -CREATE UNIQUE INDEX users_email_unique ON public.users USING btree (email); diff --git a/toolkits/postgres/tests/test_postgres.py b/toolkits/postgres/tests/test_postgres.py deleted file mode 100644 index 58e609d6..00000000 --- a/toolkits/postgres/tests/test_postgres.py +++ /dev/null @@ -1,189 +0,0 @@ -import os -from os import environ - -import pytest -import pytest_asyncio -from arcade_postgres.tools.postgres import ( - DatabaseEngine, - discover_schemas, - discover_tables, - execute_select_query, - get_table_schema, -) -from arcade_tdk import ToolContext, ToolSecretItem -from arcade_tdk.errors import RetryableToolError -from sqlalchemy import text -from sqlalchemy.ext.asyncio import create_async_engine - -DATABASE_CONNECTION_STRING = ( - environ.get("TEST_POSTGRES_DATABASE_CONNECTION_STRING") - or "postgresql://evan@localhost:5432/postgres" -) - - -@pytest.fixture -def mock_context(): - context = ToolContext() - context.secrets = [] - context.secrets.append( - ToolSecretItem(key="DATABASE_CONNECTION_STRING", value=DATABASE_CONNECTION_STRING) - ) - - return context - - -# before the tests, restore the database from the dump -@pytest_asyncio.fixture(autouse=True) -async def restore_database(): - with open(f"{os.path.dirname(__file__)}/dump.sql") as f: - engine = create_async_engine( - DATABASE_CONNECTION_STRING.replace("postgresql", "postgresql+asyncpg").split("?")[0] - ) - async with engine.connect() as c: - queries = f.read().split(";") - await c.execute(text("BEGIN")) - for query in queries: - if query.strip(): - await c.execute(text(query)) - await c.commit() - await engine.dispose() - - -@pytest_asyncio.fixture(autouse=True) -async def cleanup_engines(): - """Clean up database engines after each test to prevent connection leaks.""" - yield - # Clean up all cached engines after each test - await DatabaseEngine.cleanup() - - -@pytest.mark.asyncio -async def test_discover_schemas(mock_context) -> None: - assert await discover_schemas(mock_context) == ["public"] - - -@pytest.mark.asyncio -async def test_discover_tables(mock_context) -> None: - assert await discover_tables(mock_context) == ["users", "messages"] - - -@pytest.mark.asyncio -async def test_get_table_schema(mock_context) -> None: - assert await get_table_schema(mock_context, "public", "users") == [ - "id: int (PRIMARY KEY)", - "name: str (INDEXED)", - "email: str (INDEXED)", - "password_hash: str", - "created_at: datetime", - "updated_at: datetime", - "status: str", - ] - - assert await get_table_schema(mock_context, "public", "messages") == [ - "id: int (PRIMARY KEY)", - "body: str", - "user_id: int", - "created_at: datetime", - "updated_at: datetime", - ] - - -@pytest.mark.asyncio -async def test_execute_select_query(mock_context) -> None: - assert await execute_select_query( - mock_context, - select_clause="id, name, email", - from_clause="users", - where_clause="id = 1", - ) == [ - "(1, 'Alice', 'alice@example.com')", - ] - assert await execute_select_query( - mock_context, - select_clause="id, name, email", - from_clause="users", - order_by_clause="id", - limit=1, - offset=1, - ) == [ - "(2, 'Bob', 'bob@example.com')", - ] - - -@pytest.mark.asyncio -async def test_execute_select_query_with_keywords(mock_context) -> None: - assert await execute_select_query( - mock_context, - select_clause="SELECT id, name, email", - from_clause="FROM users", - limit=1, - ) == [ - "(1, 'Alice', 'alice@example.com')", - ] - - -@pytest.mark.asyncio -async def test_execute_select_query_with_join(mock_context) -> None: - assert await execute_select_query( - mock_context, - select_clause="u.id, u.name, u.email, m.id, m.body", - from_clause="users u", - join_clause="messages m ON u.id = m.user_id", - limit=1, - ) == [ - "(1, 'Alice', 'alice@example.com', 1, 'Hello everyone!')", - ] - - -@pytest.mark.asyncio -async def test_execute_select_query_with_group_by(mock_context) -> None: - assert await execute_select_query( - mock_context, - select_clause="u.name, COUNT(m.id) AS message_count", - from_clause="messages m", - join_clause="users u ON m.user_id = u.id", - group_by_clause="u.name", - order_by_clause="message_count DESC", - limit=2, - ) == [ - "('Evan', 13)", - "('Alice', 3)", - ] - - -@pytest.mark.asyncio -async def test_execute_select_query_with_no_results(mock_context) -> None: - # does not raise an error - assert ( - await execute_select_query( - mock_context, - select_clause="id, name, email", - from_clause="users", - where_clause="id = 9999999999", - ) - == [] - ) - - -@pytest.mark.asyncio -async def test_execute_select_query_with_problem(mock_context) -> None: - # 'foo' is not a valid id - with pytest.raises(RetryableToolError) as e: - await execute_select_query( - mock_context, - select_clause="*", - from_clause="users", - where_clause="id = 'foo'", - ) - assert "Do not use * in the select clause" in str(e.value) - - -@pytest.mark.asyncio -async def test_execute_select_query_rejects_non_select(mock_context) -> None: - with pytest.raises(RetryableToolError) as e: - await execute_select_query( - mock_context, - select_clause="INSERT INTO users (name, email, password_hash) VALUES ('Luigi', 'luigi@example.com', 'password')", - from_clause="users", - ) - assert "Only SELECT queries are allowed" in str(e.value) diff --git a/toolkits/reddit/.pre-commit-config.yaml b/toolkits/reddit/.pre-commit-config.yaml deleted file mode 100644 index 3870ea15..00000000 --- a/toolkits/reddit/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/reddit/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/reddit/.ruff.toml b/toolkits/reddit/.ruff.toml deleted file mode 100644 index 9519fe6c..00000000 --- a/toolkits/reddit/.ruff.toml +++ /dev/null @@ -1,44 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/reddit/LICENSE b/toolkits/reddit/LICENSE deleted file mode 100644 index 8c2d4f37..00000000 --- a/toolkits/reddit/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/reddit/Makefile b/toolkits/reddit/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/reddit/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/reddit/arcade_reddit/__init__.py b/toolkits/reddit/arcade_reddit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/reddit/arcade_reddit/client.py b/toolkits/reddit/arcade_reddit/client.py deleted file mode 100644 index d089d85e..00000000 --- a/toolkits/reddit/arcade_reddit/client.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any - -import httpx - - -class RedditClient: - BASE_URL = "https://oauth.reddit.com/" - - def __init__(self, token: str): - self.token = token - - async def request(self, method: str, path: str, **kwargs: Any) -> Any: - headers = { - "Authorization": f"Bearer {self.token}", - "User-Agent": "arcade-reddit", - } - async with httpx.AsyncClient() as client: - response = await client.request( - method, f"{self.BASE_URL}/{path.lstrip('/')}", headers=headers, **kwargs - ) - response.raise_for_status() - return response.json() - - async def get(self, path: str, **kwargs: Any) -> Any: - return await self.request("GET", path, **kwargs) - - async def post(self, path: str, **kwargs: Any) -> Any: - return await self.request("POST", path, **kwargs) diff --git a/toolkits/reddit/arcade_reddit/enums.py b/toolkits/reddit/arcade_reddit/enums.py deleted file mode 100644 index d6f5423f..00000000 --- a/toolkits/reddit/arcade_reddit/enums.py +++ /dev/null @@ -1,47 +0,0 @@ -from enum import Enum - - -class SubredditListingType(str, Enum): - HOT = "hot" - NEW = "new" - RISING = "rising" - TOP = "top" # time-based - CONTROVERSIAL = "controversial" # time-based - - def is_time_based(self) -> bool: - return self in [SubredditListingType.TOP, SubredditListingType.CONTROVERSIAL] - - -class RedditTimeFilter(str, Enum): - NOW = "NOW" - TODAY = "TODAY" - THIS_WEEK = "THIS_WEEK" - THIS_MONTH = "THIS_MONTH" - THIS_YEAR = "THIS_YEAR" - ALL_TIME = "ALL_TIME" - - def to_api_value(self) -> str: - _map = { - RedditTimeFilter.NOW: "hour", - RedditTimeFilter.TODAY: "day", - RedditTimeFilter.THIS_WEEK: "week", - RedditTimeFilter.THIS_MONTH: "month", - RedditTimeFilter.THIS_YEAR: "year", - RedditTimeFilter.ALL_TIME: "all", - } - return _map[self] - - -class RedditThingType(str, Enum): - """The type of a Reddit 'thing'. - - Typically used as a prefix for fullnames, e.g. t1_1234567890 - is the fullname of a comment with id 1234567890 - """ - - COMMENT = "t1" - ACCOUNT = "t2" - LINK = "t3" - MESSAGE = "t4" - SUBREDDIT = "t5" - AWARD = "t6" diff --git a/toolkits/reddit/arcade_reddit/tools/__init__.py b/toolkits/reddit/arcade_reddit/tools/__init__.py deleted file mode 100644 index 773e4c6f..00000000 --- a/toolkits/reddit/arcade_reddit/tools/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from arcade_reddit.tools.read import ( - check_subreddit_access, - get_content_of_multiple_posts, - get_content_of_post, - get_my_posts, - get_my_username, - get_posts_in_subreddit, - get_subreddit_rules, - get_top_level_comments, -) -from arcade_reddit.tools.submit import ( - comment_on_post, - reply_to_comment, - submit_text_post, -) - -__all__ = [ - "check_subreddit_access", - "comment_on_post", - "get_content_of_multiple_posts", - "get_content_of_post", - "get_my_posts", - "get_my_username", - "get_posts_in_subreddit", - "get_subreddit_rules", - "get_top_level_comments", - "reply_to_comment", - "submit_text_post", -] diff --git a/toolkits/reddit/arcade_reddit/tools/read.py b/toolkits/reddit/arcade_reddit/tools/read.py deleted file mode 100644 index e4bb16b3..00000000 --- a/toolkits/reddit/arcade_reddit/tools/read.py +++ /dev/null @@ -1,202 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Reddit -from arcade_tdk.errors import ToolExecutionError - -from arcade_reddit.client import RedditClient -from arcade_reddit.enums import ( - RedditTimeFilter, - SubredditListingType, -) -from arcade_reddit.utils import ( - create_fullname_for_multiple_posts, - create_path_for_post, - normalize_subreddit_name, - parse_get_content_of_multiple_posts_response, - parse_get_content_of_post_response, - parse_get_posts_in_subreddit_response, - parse_get_top_level_comments_response, - parse_subreddit_rules_response, - parse_user_posts_response, - remove_none_values, - resolve_subreddit_access, -) - - -@tool(requires_auth=Reddit(scopes=["read"])) -async def get_posts_in_subreddit( - context: ToolContext, - subreddit: Annotated[str, "The name of the subreddit to fetch posts from"], - listing: Annotated[ - SubredditListingType, - ( - "The type of listing to fetch. For simple listings such as 'hot', 'new', or 'rising', " - "the 'time_range' parameter is ignored. For time-based listings such as " - "'top' or 'controversial', the 'time_range' parameter is required." - ), - ] = SubredditListingType.HOT, - limit: Annotated[int, "The maximum number of posts to fetch. Default is 10, max is 100."] = 10, - cursor: Annotated[str | None, "The pagination token from a previous call"] = None, - time_range: Annotated[ - RedditTimeFilter, - "The time range for filtering posts. Must be provided if the listing type is " - f"{SubredditListingType.TOP.value} or {SubredditListingType.CONTROVERSIAL.value}. " - f"Otherwise, it is ignored. Defaults to {RedditTimeFilter.TODAY.value}.", - ] = RedditTimeFilter.TODAY, -) -> Annotated[dict, "A dictionary with a cursor for the next page and a list of posts"]: - """Gets posts titles, links, and other metadata in the specified subreddit - - The time_range is required if the listing type is 'top' or 'controversial'. - """ - client = RedditClient(context.get_auth_token_or_empty()) - - params = {"limit": limit, "after": cursor} - if listing.is_time_based(): - params["t"] = time_range.to_api_value() - - params = remove_none_values(params) - subreddit = normalize_subreddit_name(subreddit) - data = await client.get(f"r/{subreddit}/{listing.value}", params=params) - result = parse_get_posts_in_subreddit_response(data) - - return result - - -@tool(requires_auth=Reddit(scopes=["read"])) -async def get_content_of_post( - context: ToolContext, - post_identifier: Annotated[ - str, - "The identifier of the Reddit post. " - "The identifier may be a reddit URL to the post, a permalink to the post, " - "a fullname for the post, or a post id.", - ], -) -> Annotated[dict, "The content (body) of the Reddit post"]: - """Get the content (body) of a Reddit post by its identifier.""" - client = RedditClient(context.get_auth_token_or_empty()) - - path = create_path_for_post(post_identifier) - data = await client.get(f"{path}.json") - result = parse_get_content_of_post_response(data) - - return result - - -@tool(requires_auth=Reddit(scopes=["read"])) -async def get_content_of_multiple_posts( - context: ToolContext, - post_identifiers: Annotated[ - list[str], - "A list of Reddit post identifiers. " - "The identifiers may be reddit URLs to the posts, permalinks to the posts, " - "fullnames for the posts, or post ids. Must be less than or equal to 100 identifiers.", - ], -) -> Annotated[dict, "A dictionary containing the content of multiple Reddit posts"]: - """Get the content (body) of multiple Reddit posts by their identifiers. - - Efficiently retrieve the content of multiple posts in a single request. - Always use this tool to retrieve more than one post's content. - """ - client = RedditClient(context.get_auth_token_or_empty()) - - fullnames, warnings = create_fullname_for_multiple_posts(post_identifiers) - data = await client.get("api/info.json", params={"id": ",".join(fullnames)}) - posts = parse_get_content_of_multiple_posts_response(data) - - return {"posts": posts, "warnings": warnings} - - -@tool(requires_auth=Reddit(scopes=["read"])) -async def get_top_level_comments( - context: ToolContext, - post_identifier: Annotated[ - str, - "The identifier of the Reddit post to fetch comments from. " - "The identifier may be a reddit URL, a permalink, a fullname, or a post id.", - ], -) -> Annotated[dict, "A dictionary with a list of top level comments"]: - """Get the first page of top-level comments of a Reddit post.""" - client = RedditClient(context.get_auth_token_or_empty()) - - path = create_path_for_post(post_identifier) - data = await client.get(f"{path}.json") - result = parse_get_top_level_comments_response(data) - - return result - - -@tool(requires_auth=Reddit(scopes=["read"])) -async def check_subreddit_access( - context: ToolContext, - subreddit: Annotated[str, "The name of the subreddit to check access for"], -) -> Annotated[ - dict, - "A dict indicating whether the subreddit exists and is accessible to the authenticated user", -]: - """ - Checks whether the specified subreddit exists and also if it is accessible - to the authenticated user. - - Returns: - {"exists": True, "accessible": True} if the subreddit exists and is accessible. - {"exists": True, "accessible": False} if the subreddit exists but is private or restricted. - {"exists": False, "accessible": False} if the subreddit does not exist. - """ - client = RedditClient(context.get_auth_token_or_empty()) - - return await resolve_subreddit_access(client, subreddit) - - -@tool(requires_auth=Reddit(scopes=["read"])) -async def get_subreddit_rules( - context: ToolContext, - subreddit: Annotated[str, "The name of the subreddit for which to fetch rules"], -) -> Annotated[dict, "A dictionary containing the subreddit rules"]: - """Gets the rules of the specified subreddit""" - client = RedditClient(context.get_auth_token_or_empty()) - - normalized_subreddit = normalize_subreddit_name(subreddit) - data = await client.get(f"r/{normalized_subreddit}/about/rules") - - return parse_subreddit_rules_response(data) - - -@tool(requires_auth=Reddit(scopes=["identity"])) -async def get_my_username(context: ToolContext) -> str: - """Get the Reddit username of the authenticated user""" - client = RedditClient(context.get_auth_token_or_empty()) - user_info = await client.get("api/v1/me") - username: str = user_info.get("name", "") - - if not username: - raise ToolExecutionError(message="Failed to retrieve the authenticated user's name") - - return username - - -@tool(requires_auth=Reddit(scopes=["identity", "history", "read"])) -async def get_my_posts( - context: ToolContext, - limit: Annotated[ - int, "The maximum number of posts to fetch. Default is 10. Maximum is 100" - ] = 10, - include_body: Annotated[ - bool, "Whether to include the body (content) of the posts. Defaults to True." - ] = True, - cursor: Annotated[str | None, "The pagination token from a previous call"] = None, -) -> Annotated[ - dict, - "A dictionary with a cursor for the next page and " - "a list of posts created by the authenticated user", -]: - """Get posts that were created by the authenticated user sorted by newest first""" - client = RedditClient(context.get_auth_token_or_empty()) - - username = await get_my_username(context=context) - params = {"limit": limit, "after": cursor} - params = remove_none_values(params) - - posts_data = await client.get(f"user/{username}/submitted", params=params) - - return await parse_user_posts_response(context, posts_data, include_body) diff --git a/toolkits/reddit/arcade_reddit/tools/submit.py b/toolkits/reddit/arcade_reddit/tools/submit.py deleted file mode 100644 index 998d2105..00000000 --- a/toolkits/reddit/arcade_reddit/tools/submit.py +++ /dev/null @@ -1,113 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Reddit - -from arcade_reddit.client import RedditClient -from arcade_reddit.utils import ( - create_fullname_for_comment, - create_fullname_for_post, - normalize_subreddit_name, - parse_api_comment_response, - remove_none_values, -) - - -@tool(requires_auth=Reddit(scopes=["submit"])) -async def submit_text_post( - context: ToolContext, - subreddit: Annotated[str, "The name of the subreddit to which the post will be submitted"], - title: Annotated[str, "The title of the submission"], - body: Annotated[ - str | None, - "The body of the post in markdown format. Should never be the same as the title", - ] = None, - nsfw: Annotated[ - bool | None, - "Indicates if the submission has content that is 'Not Safe For Work' (NSFW). " - "Default is False", - ] = False, - spoiler: Annotated[ - bool | None, - "Indicates if the post is marked as a spoiler. Default is False", - ] = False, - send_replies: Annotated[ - bool | None, "If true, sends replies to the user's inbox. Default is True" - ] = True, -) -> Annotated[dict, "Response from Reddit after submission"]: - """Submit a text-based post to a subreddit""" - - client = RedditClient(context.get_auth_token_or_empty()) - - subreddit = normalize_subreddit_name(subreddit) - - params = { - "api_type": "json", - "sr": subreddit, - "title": title, - "kind": "self", - "nsfw": nsfw, - "spoiler": spoiler, - "sendreplies": send_replies, - "text": body, - } - params = remove_none_values(params) - - data = await client.post("api/submit", data=params) - return {"data": data["json"].get("data", {}), "errors": data["json"].get("errors", [])} - - -@tool(requires_auth=Reddit(scopes=["submit"])) -async def comment_on_post( - context: ToolContext, - post_identifier: Annotated[ - str, - "The identifier of the Reddit post. " - "The identifier may be a reddit URL, a permalink, a fullname, or a post id.", - ], - text: Annotated[str, "The body of the comment in markdown format"], -) -> Annotated[dict, "Response from Reddit after submission"]: - """Comment on a Reddit post""" - - client = RedditClient(context.get_auth_token_or_empty()) - - fullname = create_fullname_for_post(post_identifier) - - params = { - "api_type": "json", - "thing_id": fullname, - "text": text, - "return_rtjson": True, - } - - data = await client.post("api/comment", data=params) - - return parse_api_comment_response(data) - - -@tool(requires_auth=Reddit(scopes=["submit"])) -async def reply_to_comment( - context: ToolContext, - comment_identifier: Annotated[ - str, - "The identifier of the Reddit comment to reply to. " - "The identifier may be a comment ID, a reddit URL to the comment, " - "a permalink to the comment, or the fullname of the comment.", - ], - text: Annotated[str, "The body of the reply in markdown format"], -) -> Annotated[dict, "Response from Reddit after submission"]: - """Reply to a Reddit comment""" - - client = RedditClient(context.get_auth_token_or_empty()) - - fullname = create_fullname_for_comment(comment_identifier) - - params = { - "api_type": "json", - "thing_id": fullname, - "text": text, - "return_rtjson": True, - } - - data = await client.post("api/comment", data=params) - return parse_api_comment_response(data) diff --git a/toolkits/reddit/arcade_reddit/utils.py b/toolkits/reddit/arcade_reddit/utils.py deleted file mode 100644 index ad7c5f99..00000000 --- a/toolkits/reddit/arcade_reddit/utils.py +++ /dev/null @@ -1,463 +0,0 @@ -import re -from urllib.parse import urlparse - -import httpx -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError - -from arcade_reddit.client import RedditClient -from arcade_reddit.enums import RedditThingType - - -def remove_none_values(data: dict) -> dict: - """Remove all keys with None values from a dictionary""" - return {k: v for k, v in data.items() if v is not None} - - -def normalize_subreddit_name(subreddit: str) -> str: - """Normalize a subreddit name""" - return subreddit.lower().replace("r/", "").replace(" ", "") - - -def _simplify_post_data(post_data: dict, include_body: bool = False) -> dict: - simplified_data = { - "id": post_data.get("id"), - "name": post_data.get("name"), - "title": post_data.get("title"), - "author": post_data.get("author"), - "subreddit": post_data.get("subreddit"), - "created_utc": post_data.get("created_utc"), - "num_comments": post_data.get("num_comments"), - "score": post_data.get("score"), - "upvote_ratio": post_data.get("upvote_ratio"), - "upvotes": post_data.get("ups"), - "permalink": post_data.get("permalink"), - "url": post_data.get("url"), - "is_video": post_data.get("is_video"), - } - if include_body: - simplified_data["body"] = post_data.get("selftext") - return simplified_data - - -def parse_get_posts_in_subreddit_response(data: dict) -> dict: - """Parse the response from the Reddit API for getting posts in a subreddit - - Associated Reddit API endpoints: - https://www.reddit.com/dev/api/#GET_hot - https://www.reddit.com/dev/api/#GET_new - https://www.reddit.com/dev/api/#GET_rising - https://www.reddit.com/dev/api/#GET_{sort} - - Args: - data: The response from the Reddit API deserialized as a dictionary. - NOTE: The response doesn't contain the body of the posts. - - Returns: - A dictionary with a cursor for the next page and a list of posts - """ - posts = [] - for child in data.get("data", {}).get("children", []): - post_data = child.get("data", {}) - post = _simplify_post_data(post_data) - posts.append(post) - result = {"cursor": data.get("data", {}).get("after"), "posts": posts} - return result - - -def parse_get_content_of_post_response(data: list) -> dict: - """Parse the json representation of a Reddit post to get the content of a post - - Args: - data: The json representation of a Reddit post - (retrieved by appending .json to the permalink) - - Returns: - A dictionary with the content of the post - """ - if not data or not isinstance(data, list) or len(data) == 0: - return {} - - try: - post_data = data[0].get("data", {}).get("children", [{}])[0].get("data", {}) - return _simplify_post_data(post_data, include_body=True) - except (IndexError, AttributeError, KeyError): - return {} - - -def parse_get_content_of_multiple_posts_response(data: dict) -> list[dict]: - """Parse the json representation of multiple Reddit posts to get the content of each post - - Args: - data: The json representation of multiple Reddit posts - (retrieved from the /api/info.json endpoint) - - Returns: - A dictionary with the simplified content of each post - """ - if not data or not isinstance(data, dict) or len(data) == 0: - return [] - - result = [] - for post in data.get("data", {}).get("children", []): - post_data = post.get("data", {}) - result.append(_simplify_post_data(post_data, include_body=True)) - - return result - - -def parse_get_top_level_comments_response(data: list) -> dict: - """Parse the json representation of a Reddit post to get the top-level comments - - Args: - data: The json representation of a Reddit post - - Returns: - A dictionary with a list of top-level comments - """ - try: - comments_listing = data[1]["data"]["children"] - except (IndexError, KeyError): - return {"comments": [], "num_comments": 0} - - comments = [] - for comment in comments_listing: - if comment.get("kind") != RedditThingType.COMMENT.value: - continue - comment_data = comment.get("data", {}) - comments.append({ - "id": comment_data.get("id"), - "author": comment_data.get("author"), - "body": comment_data.get("body"), - "score": comment_data.get("score"), - "created_utc": comment_data.get("created_utc"), - }) - - return {"comments": comments, "num_comments": len(comments)} - - -def parse_api_comment_response(data: dict) -> dict: - """Parse the response from the Reddit API's /api/comment endpoint - - Args: - data: The response from the Reddit API deserialized as a dictionary - - Returns: - A dictionary with the comment data - """ - result = { - "created_utc": data.get("created_utc"), - "name": data.get("name"), - "parent_id": data.get("parent_id"), - "permalink": data.get("permalink"), - "subreddit": data.get("subreddit"), - "subreddit_id": data.get("subreddit_id"), - "subreddit_name_prefixed": data.get("subreddit_name_prefixed"), - } - - return result - - -def _extract_id_from_url(identifier: str, regex: str, error_msg: str) -> str: - """ - Extract an ID from a Reddit URL using the provided regular expression. - - Args: - identifier: The URL string from which to extract the ID. - regex: The regular expression pattern containing a capturing group for the ID. - error_msg: The error message to use if no ID can be extracted. - - Returns: - The extracted ID as a string. - - Raises: - ToolExecutionError: If the URL is not a Reddit URL or the pattern does not match. - """ - parsed = urlparse(identifier) - if not parsed.netloc.endswith("reddit.com"): - raise ToolExecutionError( - message=f"Expected a reddit URL, but got: {identifier}", - developer_message="The identifier should be a valid Reddit URL.", - ) - match = re.search(regex, parsed.path) - if not match: - raise ToolExecutionError( - message=f"Could not extract id from URL: {identifier}", - developer_message=error_msg, - ) - return match.group(1) - - -def _extract_id_from_permalink(identifier: str, regex: str, error_msg: str) -> str: - """ - Extract an ID from a Reddit permalink using the provided regular expression. - - Args: - identifier: The permalink string from which to extract the ID. - regex: The regular expression pattern containing a capturing group for the ID. - error_msg: The error message to use if no ID can be extracted. - - Returns: - The extracted ID as a string. - - Raises: - ToolExecutionError: If the pattern does not match the permalink. - """ - match = re.search(regex, identifier) - if not match: - raise ToolExecutionError( - message=f"Could not extract id from permalink: {identifier}", - developer_message=error_msg, - ) - return match.group(1) - - -def _get_post_id(identifier: str) -> str: - """ - Retrieve the post ID from various types of Reddit post identifiers. - - The identifier can be a Reddit URL to the post, a permalink for the post, - a fullname for the post (starting with 't3_'), or a raw post ID. - - Args: - identifier: The Reddit post identifier. - - Returns: - The post ID as a string. - - Raises: - ToolExecutionError: If the identifier does not contain a valid post ID. - """ - if identifier.startswith("http://") or identifier.startswith("https://"): - return _extract_id_from_url( - identifier, - r"/comments/([A-Za-z0-9]+)", - "The reddit URL does not contain a valid post id.", - ) - elif identifier.startswith("/r/"): - return _extract_id_from_permalink( - identifier, - r"/comments/([A-Za-z0-9]+)", - "The permalink does not contain a valid post id.", - ) - else: - pattern = re.compile(r"^(t3_)?([A-Za-z0-9]+)$") - match = pattern.match(identifier) - if match: - return match.group(2) - raise ToolExecutionError( - message=f"Invalid identifier: {identifier}", - developer_message=( - "The identifier should be a valid Reddit URL, permalink, fullname, or post id." - ), - ) - - -def _get_comment_id(identifier: str) -> str: - """ - Retrieve the comment ID from various types of Reddit comment identifiers. - - The identifier can be a Reddit URL to the comment, a permalink for the comment, - a fullname for the comment (starting with 't1_'), or a raw comment ID. - - Args: - identifier: The Reddit comment identifier. - - Returns: - The comment ID as a string. - - Raises: - ToolExecutionError: If the identifier does not contain a valid comment ID. - """ - if identifier.startswith("http://") or identifier.startswith("https://"): - return _extract_id_from_url( - identifier, - r"/comment/([A-Za-z0-9]+)", - "The reddit URL does not contain a valid comment id.", - ) - elif identifier.startswith("/r/"): - return _extract_id_from_permalink( - identifier, - r"/comment/([A-Za-z0-9]+)", - "The permalink does not contain a valid comment id.", - ) - else: - if identifier.startswith("t1_"): - return identifier[3:] - if re.fullmatch(r"[A-Za-z0-9]+", identifier): - return identifier - raise ToolExecutionError( - message=f"Invalid identifier: {identifier}", - developer_message=( - "The identifier should be a valid Reddit URL, permalink, fullname, or comment id." - ), - ) - - -def create_path_for_post(identifier: str) -> str: - """ - Create a path for a Reddit post. - - Args: - identifier: The identifier of the post. The identifier may be a reddit URL, - a permalink for the post, a fullname for the post, or a post id. - - Returns: - The path for the post. - """ - if identifier.startswith("http://") or identifier.startswith("https://"): - parsed = urlparse(identifier) - if not parsed.netloc.endswith("reddit.com"): - raise ToolExecutionError( - message=f"Expected a reddit URL, but got: {identifier}", - developer_message="The identifier should be a valid Reddit URL.", - ) - return parsed.path - if identifier.startswith("/r/"): - return identifier - post_id = _get_post_id(identifier) - return f"/comments/{post_id}" - - -def create_fullname_for_post(identifier: str) -> str: - """ - Create a fullname for a Reddit post. - - Args: - identifier: The identifier of the post. The identifier may be a reddit URL, - a permalink for the post, a fullname for the post, or a post id. - - Returns: - The fullname for the post. - """ - if identifier.startswith("t3_"): - return identifier - post_id = _get_post_id(identifier) - return f"t3_{post_id}" - - -def create_fullname_for_multiple_posts(post_identifiers: list[str]) -> tuple[list[str], list[dict]]: - """ - Create fullnames for multiple Reddit posts. - - Args: - post_identifiers: A list of Reddit post identifiers. The identifiers may be - reddit URLs, permalinks, fullnames, or post ids. - - Returns: - (fullnames, warnings): A tuple of a list of fullnames for the posts and - a list of warnings if any of the identifiers are invalid. - """ - fullnames = [] - warnings = [] - for identifier in post_identifiers: - try: - fullnames.append(create_fullname_for_post(identifier)) - except ToolExecutionError: - message = f"'{identifier}' is not a valid Reddit post identifier." - warnings.append({"message": message, "identifier": identifier}) - - return fullnames, warnings - - -def create_fullname_for_comment(identifier: str) -> str: - """ - Create a fullname for a Reddit comment. - - Args: - identifier: The identifier of the comment. The identifier may be a - reddit URL to the comment, a permalink for the comment, a fullname for - the comment, or a comment id. - - Returns: - The fullname for the comment. - """ - if identifier.startswith("t1_"): - return identifier - comment_id = _get_comment_id(identifier) - return f"t1_{comment_id}" - - -async def resolve_subreddit_access(client: RedditClient, subreddit: str) -> dict: - """Checks whether the specified subreddit exists and is accessible. - Helps abstract the logic of checking subreddit access. - - Args: - client: The Reddit client - subreddit: The subreddit to check - - Returns: - A dictionary that specifies whether the subreddit exists and - whether it is accessible to the user. - """ - normalized_name = normalize_subreddit_name(subreddit) - try: - await client.get(f"r/{normalized_name}/about.json") - except httpx.HTTPStatusError as e: - if e.response.status_code in (404, 302): - return {"exists": False, "accessible": False} - elif e.response.status_code == 403: - return {"exists": True, "accessible": False} - raise - return {"exists": True, "accessible": True} - - -def parse_subreddit_rules_response(data: dict) -> dict: - """ - Parse the response data from the Reddit API for subreddit rules. - - Args: - data (dict): The raw API response containing subreddit rules. - - Returns: - dict: A dictionary with a 'rules' key containing a list of parsed rules. - """ - rules = [] - for rule in data.get("rules", []): - rules.append({ - "priority": rule.get("priority"), - "title": rule.get("short_name"), - "body": rule.get("description"), - }) - return {"rules": rules} - - -async def parse_user_posts_response( - context: ToolContext, posts_data: dict, include_body: bool -) -> dict: - """Parse the response from the Reddit API for user posts - - Args: - context: The tool context - posts_data: The response from the Reddit API for getting the authenticated user's posts - include_body: Whether to include the body of the posts in the parsed response - - Returns: - A dictionary with a cursor for the next page (if there is one) and a list of posts - """ - next_cursor = posts_data.get("data", {}).get("after") - parsed_response = {"cursor": next_cursor} if next_cursor else {} - if not include_body: - posts = [] - for child in posts_data.get("data", {}).get("children", []): - post_data = child.get("data", {}) - simplified = _simplify_post_data(post_data, include_body=False) - posts.append(simplified) - parsed_response["posts"] = posts - else: - post_ids = [] - for child in posts_data.get("data", {}).get("children", []): - post_data = child.get("data", {}) - identifier = post_data.get("name") or post_data.get("id") - if identifier: - post_ids.append(identifier) - # Dynamically import get_content_of_multiple_posts to avoid circular dependency - from arcade_reddit.tools.read import get_content_of_multiple_posts - - content_response = await get_content_of_multiple_posts( - context=context, post_identifiers=post_ids - ) - posts_with_body = content_response.get("posts", []) - parsed_response["posts"] = posts_with_body - - return parsed_response diff --git a/toolkits/reddit/evals/additional_messages.py b/toolkits/reddit/evals/additional_messages.py deleted file mode 100644 index b8f6339c..00000000 --- a/toolkits/reddit/evals/additional_messages.py +++ /dev/null @@ -1,30 +0,0 @@ -get_post_in_subreddit_messages = [ - { - "role": "user", - "content": "get 1 post from AskReddit that are contentious at this moment", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_ZjRic1hS43o10EjutTFgotBh", - "type": "function", - "function": { - "name": "Reddit_GetPostsInSubreddit", - "arguments": '{"subreddit":"AskReddit","listing":"controversial","time_range":"NOW","limit":1}', # noqa: E501 - }, - } - ], - }, - { - "role": "tool", - "content": '{"cursor":"t3_1abcdef","posts":[{"author":"StewieGriffin","created_utc":1743457013,"id":"1abcdef","is_video":false,"name":"t3_1abcdef","num_comments":55,"permalink":"/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/","score":2,"subreddit":"AskReddit","title":"Why does my dog have four legs?","upvote_ratio":0.55,"upvotes":2,"url":"https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/"}]}', # noqa: E501 - "tool_call_id": "call_ZjRic1hS43o10EjutTFgotBh", - "name": "Reddit_GetPostsInSubreddit", - }, - { - "role": "assistant", - "content": "Here's a contentious post from AskReddit that is generating some discussion:\n\n**Title:** Why does my dog have four legs?\n\n- **Author:**StewieGriffin\n- **Score:** 2\n- **Upvote Ratio:** 55%\n- **Number of Comments:** 55\n- **Posted:**[Link](https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/)", # noqa: E501 - }, -] diff --git a/toolkits/reddit/evals/critics.py b/toolkits/reddit/evals/critics.py deleted file mode 100644 index b398cea7..00000000 --- a/toolkits/reddit/evals/critics.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from arcade_evals.critic import Critic - - -@dataclass -class AnyOfCritic(Critic): - """ - A critic that checks if the actual value matches any of the expected values. - In other words, it checks if the actual value is in the expected list. - """ - - def evaluate(self, expected: list[Any], actual: Any) -> dict[str, float | bool]: - match = actual in expected - return {"match": match, "score": self.weight if match else 0.0} - - -@dataclass -class ListCritic(Critic): - """ - A critic for comparing two lists. - """ - - def __init__( - self, - critic_field: str, - weight: float = 1.0, - order_matters: bool = True, - duplicates_matter: bool = True, - ): - self.critic_field = critic_field - self.weight = weight - self.order_matters = order_matters - self.duplicates_matter = duplicates_matter - - def evaluate(self, expected: list[Any], actual: list[Any]) -> dict[str, float | bool]: - match = actual == expected if self.order_matters else set(actual) == set(expected) - if self.duplicates_matter: - match = match and len(actual) == len(expected) - - return {"match": match, "score": self.weight if match else 0.0} diff --git a/toolkits/reddit/evals/eval_reddit_read.py b/toolkits/reddit/evals/eval_reddit_read.py deleted file mode 100644 index 7e49fc83..00000000 --- a/toolkits/reddit/evals/eval_reddit_read.py +++ /dev/null @@ -1,461 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_reddit -from arcade_reddit.enums import RedditTimeFilter, SubredditListingType -from arcade_reddit.tools import ( - get_content_of_post, - get_posts_in_subreddit, - get_top_level_comments, -) -from arcade_reddit.tools.read import ( - check_subreddit_access, - get_content_of_multiple_posts, - get_my_posts, - get_subreddit_rules, -) -from evals.additional_messages import get_post_in_subreddit_messages -from evals.critics import AnyOfCritic, ListCritic - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_reddit) - - -@tool_eval() -def reddit_get_posts_in_subreddit_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_get_posts_in_subreddit_1", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_get_posts_in_subreddit_1", - user_message="Get 30 posts from AskReddit that are contentious at this moment.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_posts_in_subreddit, - args={ - "subreddit": "AskReddit", - "listing": SubredditListingType.CONTROVERSIAL.value, - "limit": 30, - "cursor": None, - "time_range": RedditTimeFilter.NOW.value, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=0.3), - BinaryCritic(critic_field="listing", weight=0.2), - BinaryCritic(critic_field="limit", weight=0.2), - BinaryCritic(critic_field="cursor", weight=0.1), - BinaryCritic(critic_field="time_range", weight=0.2), - ], - ) - - suite.add_case( - name="reddit_get_posts_in_subreddit_2", - user_message="Get the next 5 posts from AskReddit that are contentious at this moment.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_posts_in_subreddit, - args={ - "subreddit": "AskReddit", - "listing": SubredditListingType.CONTROVERSIAL.value, - "limit": 5, - "cursor": "t3_1abcdef", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=0.2), - BinaryCritic(critic_field="listing", weight=0.2), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="cursor", weight=0.3), - BinaryCritic(critic_field="time_range", weight=0.2), - ], - additional_messages=get_post_in_subreddit_messages, - ) - - suite.add_case( # time-based listing, but don't provide a specific time range - name="reddit_get_posts_in_subreddit_3", - user_message="Get 5 top posts from AskReddit", - expected_tool_calls=[ - ExpectedToolCall( - func=get_posts_in_subreddit, - args={ - "subreddit": "AskReddit", - "listing": SubredditListingType.TOP.value, - "limit": 5, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=0.3), - BinaryCritic(critic_field="listing", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.3), - ], - ) - - suite.add_case( - name="reddit_get_posts_in_subreddit_4", - user_message="Get posts from AskReddit that are gaining traction as we speak", - expected_tool_calls=[ - ExpectedToolCall( - func=get_posts_in_subreddit, - args={ - "subreddit": "AskReddit", - "listing": SubredditListingType.RISING.value, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=0.3), - BinaryCritic(critic_field="listing", weight=0.7), - ], - ) - - return suite - - -@tool_eval() -def reddit_get_content_of_post_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_get_content_of_post", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_get_content_of_post_1", - user_message="Get the content of the post with the id t3_1abcdef", - expected_tool_calls=[ - ExpectedToolCall( - func=get_content_of_post, - args={ - "post_identifier": [ # post_identifier can be any of the following - "1abcdef", - "t3_1abcdef", - "https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/", - "/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/", - ], - }, - ), - ], - rubric=rubric, - critics=[ - AnyOfCritic( - critic_field="post_identifier", - weight=1.0, - ), - ], - additional_messages=get_post_in_subreddit_messages, - ) - - return suite - - -@tool_eval() -def reddit_get_content_of_multiple_posts_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_get_content_of_multiple_posts", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_get_content_of_multiple_posts_1", - user_message=( - "Get the content of the posts t3_1abcdef, " - "https://www.reddit.com/r/AskReddit/comments/1jdfgk1vn/why_is_water_wet/, " - "t3_3abcdef, t3_4abcdef, and t3_5abcdef, t3_6abcdef, and t3_7abcdef, 4jfnsklf, " - "/r/AskReddit/comments/2dsghr/, and /r/AskReddit/comments/1jdfg35dvn/" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_content_of_multiple_posts, - args={ - "post_identifiers": [ - "t3_1abcdef", - "https://www.reddit.com/r/AskReddit/comments/1jdfgk1vn/why_is_water_wet/", - "t3_3abcdef", - "t3_4abcdef", - "t3_5abcdef", - "t3_6abcdef", - "t3_7abcdef", - "4jfnsklf", - "/r/AskReddit/comments/2dsghr/", - "/r/AskReddit/comments/1jdfg35dvn/", - ], - }, - ), - ], - rubric=rubric, - critics=[ - ListCritic( - critic_field="post_identifiers", - weight=1.0, - order_matters=False, - duplicates_matter=True, - ), - ], - ) - - return suite - - -@tool_eval() -def reddit_get_top_level_comments_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_get_top_level_comments", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_get_top_level_comments_1", - user_message="What are people saying in response?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_top_level_comments, - args={ - "post_identifier": [ # post_identifier can be any of the following - "1abcdef", - "t3_1abcdef", - "https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/", - "/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/", - ], - }, - ), - ], - rubric=rubric, - critics=[ - AnyOfCritic( - critic_field="post_identifier", - weight=1.0, - ), - ], - additional_messages=get_post_in_subreddit_messages, - ) - - return suite - - -@tool_eval() -def reddit_check_subreddit_access_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_check_subreddit_access", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_check_subreddit_access_1", - user_message="does r/WaterBottleCollecting exist?", - expected_tool_calls=[ - ExpectedToolCall( - func=check_subreddit_access, - args={ - "subreddit": "WaterBottleCollecting", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=1.0), - ], - ) - - suite.add_case( - name="reddit_check_subreddit_access_2", - user_message=( - "so my friend is a part of the WaterBottleCollecting subreddit, " - "but i cant find it. Why?" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=check_subreddit_access, - args={ - "subreddit": "WaterBottleCollecting", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def reddit_get_subreddit_rules_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_get_subreddit_rules", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_get_subreddit_rules_1", - user_message=( - "I'm going to be posting some stuff on WaterBottleCollecting, " - "but I'm scared that I might go against their terms & conditions " - "and get my post removed." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_subreddit_rules, - args={"subreddit": "WaterBottleCollecting"}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=1.0), - ], - ) - - suite.add_case( - name="reddit_get_subreddit_rules_2", - user_message=( - "What are WaterBottleCollecting's bannable offenses? I don't want to get banned!" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_subreddit_rules, - args={"subreddit": "WaterBottleCollecting"}, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def reddit_get_my_posts_eval_suite() -> EvalSuite: - get_my_posts_response = [ - {"role": "user", "content": "get 1 of my posts"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_hPacHNSvuKKamoKKoPWBQosv", - "type": "function", - "function": {"name": "Reddit_GetMyPosts", "arguments": '{"limit":1}'}, - } - ], - }, - { - "role": "tool", - "content": '{"cursor":"t3_1jt9jz4","posts":[{"author":"RedditUser123","body":"i just wanted to say that i love thisapp\\n\\n","created_utc":1743988489,"id":"1jt9jz4","is_video":false,"name":"t3_1jt9jz4","num_comments":1,"permalink":"/r/SparkingWater/comments/1jt9jz4/this_is_fun/","score":1,"subreddit":"SparklingWater","title":"this isfun","upvote_ratio":1,"upvotes":1,"url":"https://www.reddit.com/r/SparklingWater/comments/1jt9jz4/this_is_fun/"}]}', # noqa: E501 - "tool_call_id": "call_hPacHNSvuKKamoKKoPWBQosv", - "name": "Reddit_GetMyPosts", - }, - { - "role": "assistant", - "content": "Here is one of your posts on Reddit:\n\n**Title:** [this isfun](https://www.reddit.com/r/SparklingWater/comments/1jt9jz4/this_is_fun/)\n\n**Subreddit:**r/SparklingWater\n\n**Content:** \n```\ni just wanted to say that i love this app\n```\n\n**Upvotes:** 1 \n**Comments:** 1", # noqa: E501 - }, - ] - - suite = EvalSuite( - name="reddit_get_my_posts", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_get_my_posts_1", - user_message=( - "I want to train an AI on the voice I use for my reddit posts. " - "Help me out here & get my last 100" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_my_posts, - args={ - "limit": 100, - "include_body": True, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="include_body", weight=0.5), - ], - ) - - suite.add_case( - name="reddit_get_my_posts_2", - user_message=("get 25 more but w/o their content"), - expected_tool_calls=[ - ExpectedToolCall( - func=get_my_posts, - args={ - "limit": 25, - "include_body": False, - "cursor": "t3_1jt9jz4", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=0.3), - BinaryCritic(critic_field="include_body", weight=0.3), - BinaryCritic(critic_field="cursor", weight=0.4), - ], - additional_messages=get_my_posts_response, - ) - - return suite diff --git a/toolkits/reddit/evals/eval_reddit_submit.py b/toolkits/reddit/evals/eval_reddit_submit.py deleted file mode 100644 index 3633cfae..00000000 --- a/toolkits/reddit/evals/eval_reddit_submit.py +++ /dev/null @@ -1,107 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_reddit -from arcade_reddit.tools import ( - submit_text_post, -) -from arcade_reddit.tools.submit import comment_on_post -from evals.additional_messages import get_post_in_subreddit_messages -from evals.critics import AnyOfCritic - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_reddit) - - -@tool_eval() -def reddit_submit_text_post_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_submit_text_post_1", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="reddit_submit_text_post_1", - user_message=( - "Post this in AskReddit - 'Why is the sky blue?'. I dont want replies sent to my dms" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=submit_text_post, - args={ - "subreddit": "AskReddit", - "title": "Why is the sky blue?", - "send_replies": False, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="subreddit", weight=0.3), - BinaryCritic(critic_field="title", weight=0.3), - BinaryCritic(critic_field="body", weight=0.3), - BinaryCritic(critic_field="send_replies", weight=0.1), - ], - ) - - return suite - - -@tool_eval() -def reddit_comment_on_post_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="reddit_comment_on_post_1", - system_message=( - "You are an AI assistant with access to reddit tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - comment = "Your dog's four-legged structure is a manifestation of tetrapodal evolution, where natural selection has optimized limb development for biomechanical stability and efficient terrestrial locomotion. Duh!" # noqa: E501 - suite.add_case( - name="reddit_comment_on_post_1", - user_message=(f"here's my comment - {comment}"), - expected_tool_calls=[ - ExpectedToolCall( - func=comment_on_post, - args={ - "post_identifier": [ # post_identifier can be any of the following - "1abcdef", - "t3_1abcdef", - "https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/", - "/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/", - ], - "text": comment, - }, - ), - ], - rubric=rubric, - critics=[ - AnyOfCritic(critic_field="post_identifier", weight=0.5), - SimilarityCritic(critic_field="text", weight=0.5), - ], - additional_messages=get_post_in_subreddit_messages, - ) - - return suite diff --git a/toolkits/reddit/pyproject.toml b/toolkits/reddit/pyproject.toml deleted file mode 100644 index 0980837a..00000000 --- a/toolkits/reddit/pyproject.toml +++ /dev/null @@ -1,53 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_reddit" -version = "0.1.3" -description = "Arcade.dev LLM tools Reddit" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "httpx>=0.27.2,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_reddit/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_reddit",] diff --git a/toolkits/reddit/tests/__init__.py b/toolkits/reddit/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/reddit/tests/test_utils.py b/toolkits/reddit/tests/test_utils.py deleted file mode 100644 index f6736951..00000000 --- a/toolkits/reddit/tests/test_utils.py +++ /dev/null @@ -1,323 +0,0 @@ -import httpx -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_reddit.client import RedditClient -from arcade_reddit.utils import ( - create_fullname_for_comment, - create_fullname_for_multiple_posts, - create_fullname_for_post, - create_path_for_post, - parse_get_content_of_multiple_posts_response, - parse_get_content_of_post_response, - parse_get_posts_in_subreddit_response, - parse_get_top_level_comments_response, - parse_subreddit_rules_response, - parse_user_posts_response, - resolve_subreddit_access, -) - - -class DummyRedditClientResponse: - def __init__(self, status_code): - self.status_code = status_code - - -@pytest.mark.parametrize( - "identifier, expected", - [ - ("abcdef", "t1_abcdef"), - ("https://www.reddit.com/r/test/comments/1234567890/comment/1abcdef/", "t1_1abcdef"), - ("t1_abcdef", "t1_abcdef"), - ("/r/test/comments/1234567890/comment/1abcdef/", "t1_1abcdef"), - ("not-an-id", pytest.raises(ToolExecutionError)), - # Fullname: Invalid (not a Reddit comment id, but a Reddit post id) - ("t2_abcdef", pytest.raises(ToolExecutionError)), - # URL: Invalid (not a Reddit url to a comment, but to a post) - ("https://www.reddit.com/r/test/comments/1234567890", pytest.raises(ToolExecutionError)), - # Permalink: Invalid (not a Reddit permalink to a comment, but to a post) - ("/r/test/comments/1234567890", pytest.raises(ToolExecutionError)), - ], -) -def test_create_fullname_for_comment(identifier, expected) -> None: - if isinstance(expected, str): - assert create_fullname_for_comment(identifier) == expected - else: - with expected: - create_fullname_for_comment(identifier) - - -@pytest.mark.parametrize( - "identifier, expected", - [ - ("https://www.reddit.com/r/test/comments/1234abc/", "/r/test/comments/1234abc/"), - ("1234abc", "/comments/1234abc"), - ("/r/test/comments/1234abc/", "/r/test/comments/1234abc/"), - ("t3_1234abc", "/comments/1234abc"), - # URL: invalid (non-reddit domain) - ("https://www.example.com/r/test/comments/1234abc/", pytest.raises(ToolExecutionError)), - # Post ID: invalid (non-alphanumeric character) - ("12!abc", pytest.raises(ToolExecutionError)), - # Permalink: invalid (missing the leading "/" so not recognized as a proper permalink) - ("r/test/comments/1234abc/", pytest.raises(ToolExecutionError)), - # Fullname: invalid (contains an illegal character) - ("t3_1234*abc", pytest.raises(ToolExecutionError)), - ], -) -def test_create_path_for_post(identifier, expected): - if isinstance(expected, str): - result = create_path_for_post(identifier) - assert result == expected - else: - with expected: - create_path_for_post(identifier) - - -@pytest.mark.parametrize( - "identifier, expected", - [ - ("https://www.reddit.com/r/test/comments/1234abc/", "t3_1234abc"), - ("1234abc", "t3_1234abc"), - ("/r/test/comments/1234abc/", "t3_1234abc"), - ("t3_1234abc", "t3_1234abc"), - # URL: invalid (missing "/comments/" segment) - ("https://www.reddit.com/r/test/1234abc/", pytest.raises(ToolExecutionError)), - # Post ID: invalid (non-alphanumeric) - ("12!abc", pytest.raises(ToolExecutionError)), - # Permalink: invalid (missing "/comments/" segment) - ("/r/test/1234abc/", pytest.raises(ToolExecutionError)), - # Fullname: invalid (type prefix is for a message, not a post - ("t4_1234abc", pytest.raises(ToolExecutionError)), - ], -) -def test_create_fullname_for_post(identifier, expected): - if isinstance(expected, str): - result = create_fullname_for_post(identifier) - assert result == expected - else: - with expected: - create_fullname_for_post(identifier) - - -@pytest.mark.parametrize( - "identifiers, expected_fullnames, expected_warnings", - [ - ([], [], []), - ( - ["t3_1234abc", "https://www.reddit.com/r/test/comments/1234abc/"], - ["t3_1234abc", "t3_1234abc"], - [], - ), - ( - ["t3_1234abc", "not-a-valid-identifier"], - ["t3_1234abc"], - [ - { - "message": "'not-a-valid-identifier' is not a valid Reddit post identifier.", - "identifier": "not-a-valid-identifier", - } - ], - ), - ( - ["inv@lid", "not-a-valid-identifier"], - [], - [ - { - "message": "'inv@lid' is not a valid Reddit post identifier.", - "identifier": "inv@lid", - }, - { - "message": "'not-a-valid-identifier' is not a valid Reddit post identifier.", - "identifier": "not-a-valid-identifier", - }, - ], - ), - ], -) -def test_create_fullname_for_multiple_posts(identifiers, expected_fullnames, expected_warnings): - actual_fullnames, actual_warnings = create_fullname_for_multiple_posts(identifiers) - assert actual_fullnames == expected_fullnames - assert actual_warnings == expected_warnings - - -def test_parse_get_posts_in_subreddit_response_empty(): - data = {} - expected = {"cursor": None, "posts": []} - result = parse_get_posts_in_subreddit_response(data) - assert result == expected - - -def test_parse_get_content_of_post_response_empty_and_malformed(): - data = [] - result = parse_get_content_of_post_response(data) - assert result == {} - - data = None - result = parse_get_content_of_post_response(data) - assert result == {} - - # missing expected keys - data = [{}] - expected = { - "id": None, - "name": None, - "title": None, - "author": None, - "subreddit": None, - "created_utc": None, - "num_comments": None, - "score": None, - "upvote_ratio": None, - "upvotes": None, - "permalink": None, - "url": None, - "is_video": None, - "body": None, - } - result = parse_get_content_of_post_response(data) - assert result == expected - - -def test_parse_get_content_of_multiple_posts_response_empty_and_malformed(): - expected = [] - - data = {} - result = parse_get_content_of_multiple_posts_response(data) - assert result == expected - - data = None - result = parse_get_content_of_multiple_posts_response(data) - assert result == expected - - data = [{}] - result = parse_get_content_of_multiple_posts_response(data) - assert result == expected - - -def test_parse_get_top_level_comments_response_missing_data(): - data = [{}] - expected = {"comments": [], "num_comments": 0} - result = parse_get_top_level_comments_response(data) - assert result == expected - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status_code, expected_result, expected_exception", - [ - (None, {"exists": True, "accessible": True}, None), - (404, {"exists": False, "accessible": False}, None), - (302, {"exists": False, "accessible": False}, None), - (403, {"exists": True, "accessible": False}, None), - (500, None, httpx.HTTPStatusError), - ], -) -async def test_resolve_subreddit_access(status_code, expected_result, expected_exception): - class DummyRedditClient(RedditClient): - async def get(self, path: str, **kwargs): - if status_code is not None: - raise httpx.HTTPStatusError( - "Error", request=None, response=DummyRedditClientResponse(status_code) - ) - return "dummy_success" - - client = DummyRedditClient(token="dummy") # noqa: S106 - if expected_exception: - with pytest.raises(expected_exception): - await resolve_subreddit_access(client, "testsub") - else: - result = await resolve_subreddit_access(client, "testsub") - assert result == expected_result - - -@pytest.mark.parametrize( - "data, expected", - [ - ( - { - "rules": [ - {"priority": 1, "short_name": "Rule1", "description": "Desc1"}, - {"priority": 2, "short_name": "Rule2", "description": "Desc2"}, - ] - }, - { - "rules": [ - {"priority": 1, "title": "Rule1", "body": "Desc1"}, - {"priority": 2, "title": "Rule2", "body": "Desc2"}, - ] - }, - ), - ({}, {"rules": []}), - ], -) -def test_parse_subreddit_rules_response(data, expected): - result = parse_subreddit_rules_response(data) - assert result == expected - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "posts_data, expected_cursor, expected_posts", - [ - ( - { - "data": { - "after": "cursor123", - "children": [ - { - "data": { - "id": "1", - "name": "t3_1", - "title": "Post 1", - "author": "user1", - "subreddit": "subreddit1", - "created_utc": 1712345678, - "num_comments": 10, - "score": 100, - "upvote_ratio": 0.5, - "ups": 50, - "permalink": "permalink1", - "url": "url1", - "is_video": False, - "selftext": "This is the body of post 1", - "all_awardings": [], - "allow_live_comments": False, - "approved": False, - "approved_at_utc": None, - } - }, - ], - } - }, - "cursor123", - [ - { - "id": "1", - "name": "t3_1", - "title": "Post 1", - "author": "user1", - "subreddit": "subreddit1", - "created_utc": 1712345678, - "num_comments": 10, - "score": 100, - "upvote_ratio": 0.5, - "upvotes": 50, - "permalink": "permalink1", - "url": "url1", - "is_video": False, - }, - ], - ), - ({"data": {"after": None, "children": []}}, None, []), - ], -) -async def test_parse_user_posts_response_without_body(posts_data, expected_cursor, expected_posts): - dummy_context = object() - result = await parse_user_posts_response(dummy_context, posts_data, include_body=False) - - if expected_cursor: - expected = {"cursor": expected_cursor, "posts": expected_posts} - else: - expected = {"posts": expected_posts} - - assert result == expected diff --git a/toolkits/salesforce/.pre-commit-config.yaml b/toolkits/salesforce/.pre-commit-config.yaml deleted file mode 100644 index a5a99967..00000000 --- a/toolkits/salesforce/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/salesforce/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/salesforce/.ruff.toml b/toolkits/salesforce/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/salesforce/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/salesforce/LICENSE b/toolkits/salesforce/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/salesforce/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/salesforce/Makefile b/toolkits/salesforce/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/salesforce/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/salesforce/arcade_salesforce/__init__.py b/toolkits/salesforce/arcade_salesforce/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/salesforce/arcade_salesforce/constants.py b/toolkits/salesforce/arcade_salesforce/constants.py deleted file mode 100644 index 416db3e7..00000000 --- a/toolkits/salesforce/arcade_salesforce/constants.py +++ /dev/null @@ -1,33 +0,0 @@ -import os - -SALESFORCE_API_VERSION = "v63.0" - -DEFAULT_MAX_CONCURRENT_REQUESTS = 3 -try: - MAX_CONCURRENT_REQUESTS = int( - os.getenv("ARCADE_SALESFORCE_MAX_CONCURRENT_REQUESTS", DEFAULT_MAX_CONCURRENT_REQUESTS) - ) -except ValueError: - MAX_CONCURRENT_REQUESTS = DEFAULT_MAX_CONCURRENT_REQUESTS - -ASSOCIATION_REFERENCE_FIELDS = [ - "AccountId", - "OwnerId", - "AssociatedToWhom", - "ContactId", -] - -GLOBALLY_IGNORED_FIELDS = [ - "attributes", - "CleanStatus", - "CreatedById", - "CreatedDate", - "IsDeleted", - "LastModifiedById", - "LastModifiedDate", - "LastReferencedDate", - "LastViewedDate", - "PhotoUrl", - "SystemModstamp", - "WhatId", -] diff --git a/toolkits/salesforce/arcade_salesforce/enums.py b/toolkits/salesforce/arcade_salesforce/enums.py deleted file mode 100644 index fca01f16..00000000 --- a/toolkits/salesforce/arcade_salesforce/enums.py +++ /dev/null @@ -1,21 +0,0 @@ -from enum import Enum -from typing import cast - - -class SalesforceObject(Enum): - ACCOUNT = "Account" - CALL = "Call" - CONTACT = "Contact" - EMAIL = "Email" - EVENT = "Event" - LEAD = "Lead" - NOTE = "Note" - OPPORTUNITY = "Opportunity" - TASK = "Task" - USER = "User" - - @property - def plural(self) -> str: - if self == SalesforceObject.OPPORTUNITY: - return "Opportunities" - return cast(str, self.value) + "s" diff --git a/toolkits/salesforce/arcade_salesforce/exceptions.py b/toolkits/salesforce/arcade_salesforce/exceptions.py deleted file mode 100644 index ffb3f5d0..00000000 --- a/toolkits/salesforce/arcade_salesforce/exceptions.py +++ /dev/null @@ -1,22 +0,0 @@ -from arcade_tdk.errors import ToolExecutionError - - -class SalesforceToolExecutionError(ToolExecutionError): - def __init__(self, errors: list[str], message: str = "Failed to execute Salesforce tool"): - self.message = message - self.errors = errors - exc_message = f"{message}. Errors: {errors}" - super().__init__( - message=exc_message, - developer_message=exc_message, - ) - - -class ResourceNotFoundError(SalesforceToolExecutionError): - def __init__(self, errors: list[str]): - super().__init__(message="Resource not found", errors=errors) - - -class BadRequestError(SalesforceToolExecutionError): - def __init__(self, errors: list[str]): - super().__init__(message="Bad request", errors=errors) diff --git a/toolkits/salesforce/arcade_salesforce/models.py b/toolkits/salesforce/arcade_salesforce/models.py deleted file mode 100644 index 061186d0..00000000 --- a/toolkits/salesforce/arcade_salesforce/models.py +++ /dev/null @@ -1,495 +0,0 @@ -import asyncio -import os -from dataclasses import dataclass -from typing import Any, cast - -import httpx - -from arcade_salesforce.constants import MAX_CONCURRENT_REQUESTS, SALESFORCE_API_VERSION -from arcade_salesforce.enums import SalesforceObject -from arcade_salesforce.exceptions import ( - BadRequestError, - ResourceNotFoundError, - SalesforceToolExecutionError, -) -from arcade_salesforce.utils import ( - build_soql_query, - clean_contact_data, - clean_lead_data, - clean_note_data, - clean_object_data, - clean_opportunity_data, - clean_task_data, - expand_associations, - get_ids_referenced, - get_object_type, - remove_none_values, -) - - -@dataclass -class SalesforceClient: - auth_token: str - org_subdomain: str | None = None - api_version: str = SALESFORCE_API_VERSION - max_concurrent_requests: int = MAX_CONCURRENT_REQUESTS - - # Internal state properties - _state_object_fields: dict[SalesforceObject, list[str]] | None = None - _state_is_person_account_enabled: bool | None = None - _semaphore: asyncio.Semaphore | None = None - - def __post_init__(self) -> None: - if self.org_subdomain is None: - self.org_subdomain = os.getenv("SALESFORCE_ORG_SUBDOMAIN") - if self.org_subdomain is None: - raise ValueError( - "Either `org_subdomain` argument or `SALESFORCE_ORG_SUBDOMAIN` env var must be set" - ) - - if self._state_object_fields is None: - self._state_object_fields = {} - - if self._semaphore is None: - self._semaphore = asyncio.Semaphore(self.max_concurrent_requests) - - @property - def _base_url(self) -> str: - return f"https://{self.org_subdomain}.my.salesforce.com/services/data/{self.api_version}" - - @property - def object_fields(self) -> dict[SalesforceObject, list[str]]: - return cast(dict, self._state_object_fields) - - def _endpoint_url(self, endpoint: str) -> str: - return f"{self._base_url}/{endpoint.lstrip('/')}" - - def _build_headers(self, headers: dict | None = None) -> dict: - headers = headers or {} - headers["Authorization"] = f"Bearer {self.auth_token}" - return headers - - def _raise_salesforce_error(self, response: httpx.Response) -> None: - """Raise a ToolExecutionError based on the Salesforce API response status code.""" - errors = [error.get("message") for error in response.json()] - if response.status_code == 404: - raise ResourceNotFoundError(errors) - elif response.status_code == 400: - raise BadRequestError(errors) - raise SalesforceToolExecutionError(errors) - - async def get( - self, - endpoint: str, - params: dict | None = None, - headers: dict | None = None, - ) -> dict: - """Make a GET request to the Salesforce API. - - Args: - endpoint: The Salesforce API endpoint to call. - params: The query parameters to include in the request. - headers: The headers to include in the request. - - Returns: - The JSON-loaded representation of the response from the Salesforce API. - """ - kwargs: dict[str, Any] = { - "url": self._endpoint_url(endpoint), - "headers": self._build_headers(headers), - } - if params: - kwargs["params"] = params - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.get(**kwargs) - - if response.status_code >= 300: - self._raise_salesforce_error(response) - - return cast(dict, response.json()) - - async def post( - self, - endpoint: str, - data: dict | None = None, - json_data: dict | None = None, - headers: dict | None = None, - ) -> dict: - """Make a POST request to the Salesforce API. - - Args: - endpoint: The Salesforce API endpoint to call. - data: The data for the request. (provide data or json_data, not both) - json_data: The JSON data for the request. (provide data or json_data, not both) - headers: The headers to include in the request. - - Returns: - The JSON-loaded representation of the response from the Salesforce API. - """ - kwargs: dict[str, Any] = { - "url": self._endpoint_url(endpoint), - "headers": self._build_headers(headers), - } - if data: - kwargs["data"] = data - if json_data: - kwargs["json"] = json_data - - async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] - response = await client.post(**kwargs) - - if response.status_code >= 300: - self._raise_salesforce_error(response) - - return cast(dict, response.json()) - - async def get_object_fields(self, object_type: SalesforceObject) -> list[str]: - """Get the fields available for a Salesforce object. - - Args: - object_type: The Salesforce object to get the fields for. - - Returns: - The list of fields available for a Salesforce object. - """ - if object_type not in self.object_fields: - response = await self._describe_object(object_type) - self.object_fields[object_type] = [field["name"] for field in response["fields"]] - - return self.object_fields[object_type] - - async def _describe_object(self, object_type: SalesforceObject) -> dict: - return await self.get(f"sobjects/{object_type.value}/describe/") - - async def _get_related_objects( - self, - child_object_type: SalesforceObject, - parent_object_type: SalesforceObject, - parent_object_id: str, - limit: int | None = 10, - ) -> list[dict]: - """Get the objects that are associated with another Salesforce object. - - Args: - child_object_type: The type of child object to retrieve. - parent_object_type: The type of parent object. - parent_object_id: The ID of the parent object. - limit: The maximum number of related objects to retrieve. - - Returns: - The list of related objects. - """ - try: - response = await self.get( - f"sobjects/{parent_object_type.value}/{parent_object_id}/{child_object_type.plural.lower()}", - params={"limit": limit}, - ) - return cast(list, response["records"]) - except ResourceNotFoundError: - return [] - - async def get_object_by_id(self, object_id: str) -> dict | None: - """Get a Salesforce object by its ID. - - Args: - object_id: The ID of the Salesforce object to retrieve. - - Returns: - The Salesforce object. - """ - try: - response = await self.get(f"sobjects/{object_id}") - return clean_object_data(response) - except ResourceNotFoundError: - if "User" not in object_id: - return await self.get_object_by_id(f"User/{object_id}") - return None - - async def get_account(self, account_id: str) -> dict[str, Any] | None: - """Get an account by its ID. - - Args: - account_id: The ID of the account to retrieve. - - Returns: - The account. - """ - try: - return cast(dict, await self.get(f"sobjects/Account/{account_id}")) - except ResourceNotFoundError: - return None - - async def get_account_contacts(self, account_id: str, limit: int | None = 10) -> list[dict]: - """Get the contacts associated with an account. - - Args: - account_id: The ID of the account to retrieve the contacts for. - limit: The maximum number of contacts to retrieve. - - Returns: - The list of contacts with cleaned and standardized dictionaries. - """ - contacts = await self._get_related_objects( - SalesforceObject.CONTACT, SalesforceObject.ACCOUNT, account_id, limit - ) - - return [ - clean_contact_data(contact) - for contact in await asyncio.gather(*[ - self.enrich_contact_with_notes(contact, limit) for contact in contacts - ]) - ] - - async def enrich_contact_with_notes(self, contact: dict, limit: int | None = 10) -> dict: - """Get the notes associated with a contact and add to the contact dictionary. - - Args: - contact: The contact to retrieve the notes for. - limit: The maximum number of notes to retrieve. - - Returns: - The contact with the notes added to the dictionary in the key `Notes`. - """ - notes = await self.get_notes(contact["Id"], limit) - if notes: - contact["Notes"] = notes - return contact - - async def get_account_leads(self, account_id: str, limit: int | None = 10) -> list[dict]: - """Get the leads associated with an account. - - Args: - account_id: The ID of the account to retrieve the leads for. - limit: The maximum number of leads to retrieve. - - Returns: - The list of leads with cleaned and standardized dictionaries. - """ - leads = await self._get_related_objects( - SalesforceObject.LEAD, SalesforceObject.ACCOUNT, account_id, limit - ) - return [clean_lead_data(lead) for lead in leads] - - async def get_notes(self, parent_id: str, limit: int | None = 10) -> list[dict]: - """Get the notes associated with a Salesforce object. - - Args: - parent_id: The ID of the Salesforce object to retrieve the notes for. - limit: The maximum number of notes to retrieve. - - Returns: - The list of notes with cleaned and standardized dictionaries. - """ - query = build_soql_query( - "SELECT Id, Title, Body, OwnerId, CreatedById, CreatedDate " - "FROM Note " - "WHERE ParentId = '{parent_id}' " - "LIMIT {limit}", - parent_id=parent_id, - limit=limit, - ) - response = await self.get("query", params={"q": query}) - notes = response["records"] - return [clean_note_data(note) for note in notes] - - # TODO: Add support for retrieving Currency, when enabled in the org account. - # If not enabled and we try to retrieve it, we get a 400 error. - # More inf: https://developer.salesforce.com/docs/atlas.en-us.254.0.object_reference.meta/object_reference/sforce_api_objects_opportunity.htm#i1455437 - async def get_account_opportunities( - self, - account_id: str, - limit: int | None = 10, - ) -> list[dict]: - """Get the opportunities associated with an account. - - Args: - account_id: The ID of the account to retrieve the opportunities for. - limit: The maximum number of opportunities to retrieve. - - Returns: - The list of opportunities with cleaned and standardized dictionaries. - """ - query = build_soql_query( - "SELECT Id, Name, Type, StageName, OwnerId, CreatedById, LastModifiedById, " - "Description, Amount, Probability, ExpectedRevenue, CloseDate, ContactId " - "FROM Opportunity " - "WHERE AccountId = '{account_id}' " - "LIMIT {limit}", - account_id=account_id, - limit=limit, - ) - response = await self.get("query", params={"q": query}) - opportunities = response["records"] - return [clean_opportunity_data(opportunity) for opportunity in opportunities] - - async def get_account_tasks( - self, - account_id: str, - limit: int | None = 10, - ) -> list[dict]: - """Get the tasks associated with an account. - - Args: - account_id: The ID of the account to retrieve the tasks for. - limit: The maximum number of tasks to retrieve. - - Returns: - The list of tasks with cleaned and standardized dictionaries. - """ - tasks = await self._get_related_objects( - SalesforceObject.TASK, SalesforceObject.ACCOUNT, account_id, limit - ) - return [clean_task_data(task) for task in tasks] - - async def enrich_account( - self, - account_id: str | None = None, - account_data: dict[str, Any] | None = None, - limit_per_association: int | None = 10, - ) -> dict: - """Enrich account dictionary with contacts, leads, opportunities, and tasks. - - Provide `account_id` or `account_data`, but not both. - - Args: - account_id: The ID of the account to retrieve the associations for. - account_data: The account data to enrich. - limit_per_association: The maximum number of associations to retrieve. - - Returns: - The account with the associations added to the dictionary. - """ - if (account_id and account_data) or (not account_id and not account_data): - raise ValueError("Must provide exactly one of `account_id` or `account_data`") - - if account_data is None: - account_data = await self.get_account(cast(str, account_id)) - - if not account_data: - raise ResourceNotFoundError([f"Account not found with ID: {account_id}"]) - - if not account_id: - account_id = cast(str, account_data["Id"]) - - associations = await asyncio.gather( - self.get_account_contacts(account_id, limit=limit_per_association), - self.get_account_leads(account_id, limit=limit_per_association), - self.get_account_opportunities(account_id, limit=limit_per_association), - self.get_account_tasks(account_id, limit=limit_per_association), - ) - - for association in associations: - for item in association: - try: - obj_type = SalesforceObject(get_object_type(item)).plural - except ValueError: - obj_type = get_object_type(item) + "s" - - if obj_type not in account_data: - account_data[obj_type] = [] - account_data[obj_type].append(item) - - return await self.expand_account_associations(account_data) - - async def expand_account_associations(self, account: dict) -> dict: - """Expand with metadata about objects referenced by ID in an account dictionary. - - This method will, for example, expand an `OwnerId` or `ContactId` referenced in an account - dictionary with the owner or contact name, for example. - - Args: - account: The account dictionary to expand. - - Returns: - The account with the associations expanded. - """ - objects_by_id = { - obj["Id"]: obj - for obj_type in SalesforceObject - for obj in account.get(obj_type.plural, []) - } - objects_by_id[account["Id"]] = account - - referenced_ids = get_ids_referenced( - account, - *[account.get(obj_type.plural, []) for obj_type in SalesforceObject], - ) - - missing_referenced_ids = [ref for ref in referenced_ids if ref not in objects_by_id] - - if missing_referenced_ids: - missing_objects = await asyncio.gather(*[ - self.get_object_by_id(missing_id) for missing_id in missing_referenced_ids - ]) - objects_by_id.update({obj["Id"]: obj for obj in missing_objects if obj is not None}) - - account = expand_associations(account, objects_by_id) - - for object_type in SalesforceObject: - if object_type.plural not in account: - continue - - expanded_items = [] - - for item in account[object_type.plural]: - if "AccountId" in item: - del item["AccountId"] - - expanded_items.append(expand_associations(item, objects_by_id)) - - if object_type == SalesforceObject.CONTACT: - for contact in expanded_items: - if "Notes" in contact: - contact["Notes"] = [ - expand_associations(note, objects_by_id) for note in contact["Notes"] - ] - - account[object_type.plural] = expanded_items - - return account - - async def create_contact( - self, - account_id: str, - last_name: str, - first_name: str | None = None, - email: str | None = None, - phone: str | None = None, - mobile_phone: str | None = None, - title: str | None = None, - department: str | None = None, - description: str | None = None, - ) -> dict: - """Create a contact in Salesforce. - - Args: - account_id: The ID of the account to associate the contact with. - last_name: The last name of the contact. - first_name: The first name of the contact. - email: The email of the contact. - phone: The phone number of the contact. - mobile_phone: The mobile phone number of the contact. - title: The title of the contact. - department: The department of the contact. - description: The description of the contact. - - Returns: - The created contact. - """ - data = { - "AccountId": account_id, - "FirstName": first_name, - "LastName": last_name, - "Email": email, - "Phone": phone, - "MobilePhone": mobile_phone, - "Title": title, - "Department": department, - "Description": description, - } - - return await self.post( - f"sobjects/{SalesforceObject.CONTACT.value}", - json_data=remove_none_values(data), - ) diff --git a/toolkits/salesforce/arcade_salesforce/tools/__init__.py b/toolkits/salesforce/arcade_salesforce/tools/__init__.py deleted file mode 100644 index dff20713..00000000 --- a/toolkits/salesforce/arcade_salesforce/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from arcade_salesforce.tools.crm.account import ( - get_account_data_by_id, - get_account_data_by_keywords, -) -from arcade_salesforce.tools.crm.contact import create_contact - -__all__ = [ - "create_contact", - "get_account_data_by_id", - "get_account_data_by_keywords", -] diff --git a/toolkits/salesforce/arcade_salesforce/tools/crm/account.py b/toolkits/salesforce/arcade_salesforce/tools/crm/account.py deleted file mode 100644 index 3bbbefa7..00000000 --- a/toolkits/salesforce/arcade_salesforce/tools/crm/account.py +++ /dev/null @@ -1,126 +0,0 @@ -import asyncio -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import OAuth2 -from arcade_tdk.errors import ToolExecutionError - -from arcade_salesforce.enums import SalesforceObject -from arcade_salesforce.models import SalesforceClient -from arcade_salesforce.utils import clean_account_data - - -# TODO: We only return up to 10 items of each related object (e.g. contacts). Need to implement -# separate tools for each related object, so that we can return more items, when needed. -@tool( - requires_auth=OAuth2( - id="salesforce", - scopes=[ - "read_account", - "read_contact", - "read_lead", - "read_note", - "read_opportunity", - "read_task", - ], - ) -) -async def get_account_data_by_keywords( - context: ToolContext, - query: Annotated[ - str, - "The query to search for accounts. MUST be longer than one character. It will match the " - "keywords against all account fields, such as name, website, phone, address, etc. " - "E.g. 'Acme'", - ], - # Note: Salesforce supports up to 200 results, but since we're enriching each account with - # related objects, we limit to 10, so that the response is not too lengthy for LLMs. - limit: Annotated[ - int, - "The maximum number of accounts to return. Defaults to 10. Maximum allowed is 10.", - ] = 10, - page: Annotated[int, "The page number to return. Defaults to 1 (first page of results)."] = 1, -) -> Annotated[ - dict, - "The accounts matching the query with related info: contacts, leads, notes, calls, " - "opportunities, tasks, emails, and events (up to 10 items of each type)", -]: - """Searches for accounts in Salesforce and returns them with related info: contacts, leads, - notes, calls, opportunities, tasks, emails, and events (up to 10 items of each type). - - An account is an organization (such as a customer, supplier, or partner, though more commonly - a customer). In some Salesforce account setups, an account can also represent a person. - """ - if len(query) < 2: - raise ToolExecutionError("The `query` argument must have two or more characters.") - - limit = min(limit, 10) - - client = SalesforceClient(context.get_auth_token_or_empty()) - - params = { - "q": query, - "sobjects": [ - { - "name": "Account", - "fields": await client.get_object_fields(SalesforceObject.ACCOUNT), - } - ], - "in": "ALL", - "overallLimit": limit, - "offset": (page - 1) * limit, - } - response = await client.post("parameterizedSearch", json_data=params) - search_results = response["searchRecords"] - - accounts = await asyncio.gather(*[ - client.enrich_account( - account_data=account, - limit_per_association=10, - ) - for account in search_results - ]) - return {"accounts": [clean_account_data(account) for account in accounts]} - - -@tool( - requires_auth=OAuth2( - id="salesforce", - scopes=[ - "read_account", - "read_contact", - "read_lead", - "read_note", - "read_opportunity", - "read_task", - ], - ) -) -async def get_account_data_by_id( - context: ToolContext, - account_id: Annotated[ - str, - "The ID of the account to get data for.", - ], -) -> Annotated[ - dict, - "The account with related info: contacts, leads, notes, calls, opportunities, tasks, emails, " - "and events (up to 10 items of each type)", -]: - """Gets the account with related info: contacts, leads, notes, calls, opportunities, tasks, - emails, and events (up to 10 items of each type). - - An account is an organization (such as a customer, supplier, or partner, though more commonly - a customer). In some Salesforce account setups, an account can also represent a person. - """ - client = SalesforceClient(context.get_auth_token_or_empty()) - - account = await client.get_account(account_id) - - if not account: - return {"account": None, "error": f"No account found with id '{account_id}'"} - - account = await client.enrich_account(account_data=account) - account = clean_account_data(account) - - return {"account": account} diff --git a/toolkits/salesforce/arcade_salesforce/tools/crm/contact.py b/toolkits/salesforce/arcade_salesforce/tools/crm/contact.py deleted file mode 100644 index ca4f4abb..00000000 --- a/toolkits/salesforce/arcade_salesforce/tools/crm/contact.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import OAuth2 -from arcade_tdk.errors import ToolExecutionError - -from arcade_salesforce.exceptions import SalesforceToolExecutionError -from arcade_salesforce.models import SalesforceClient - - -# TODO: Add support for referencing an account by keywords (name, website, etc). We can use the -# get_account_data_by_keywords tool to search for accounts. If more than one is returned, we can -# raise a RetryableToolError providing the matches for the user to choose. -@tool( - requires_auth=OAuth2( - id="salesforce", - scopes=["write_contact"], - ) -) -async def create_contact( - context: ToolContext, - account_id: Annotated[str, "The ID of the account to create the contact for."], - last_name: Annotated[str, "The last name of the contact."], - first_name: Annotated[str | None, "The first name of the contact."] = None, - email: Annotated[str | None, "The email of the contact."] = None, - phone: Annotated[str | None, "The phone number of the contact."] = None, - mobile_phone: Annotated[str | None, "The mobile phone number of the contact."] = None, - title: Annotated[ - str | None, - "The title of the contact. E.g. 'CEO', 'Sales Director', 'CTO', etc.", - ] = None, - department: Annotated[ - str | None, - "The department of the contact. E.g. 'Marketing', 'Sales', 'IT', etc.", - ] = None, - description: Annotated[str | None, "The description of the contact."] = None, -) -> Annotated[ - dict, - "The created contact.", -]: - """Creates a contact in Salesforce.""" - if not last_name: - raise ToolExecutionError("Last name is required by Salesforce to create a contact.") - - client = SalesforceClient(context.get_auth_token_or_empty()) - contact = await client.create_contact( - account_id=account_id, - first_name=first_name, - last_name=last_name, - email=email, - phone=phone, - mobile_phone=mobile_phone, - title=title, - department=department, - description=description, - ) - - if not contact.get("success"): - raise SalesforceToolExecutionError( - message="Failed to create contact", - errors=contact.get("errors", []), - ) - - return {"success": True, "contactId": contact.get("id")} diff --git a/toolkits/salesforce/arcade_salesforce/utils.py b/toolkits/salesforce/arcade_salesforce/utils.py deleted file mode 100644 index 3ae200c4..00000000 --- a/toolkits/salesforce/arcade_salesforce/utils.py +++ /dev/null @@ -1,279 +0,0 @@ -import string -from collections.abc import Callable -from typing import Any, cast - -from arcade_salesforce.constants import ASSOCIATION_REFERENCE_FIELDS, GLOBALLY_IGNORED_FIELDS -from arcade_salesforce.enums import SalesforceObject - - -def remove_fields_globally_ignored(clean_func: Callable[[dict], dict]) -> Callable[[dict], dict]: - """Remove fields that are irrelevant for our tools' responses. - - The main purpose of cleaning and removing unnecessary fields from data is to make our - responses more concise and save on LLM tokens/context window. - """ - - def global_cleaner(data: dict) -> dict: - cleaned_data = {} - for key, value in data.items(): - if key in GLOBALLY_IGNORED_FIELDS or value is None: - continue - - if isinstance(value, dict): - cleaned_data[key] = global_cleaner(value) - - elif isinstance(value, list | tuple | set): - cleaned_items = [] - for item in value: - if isinstance(item, dict): - cleaned_items.append(global_cleaner(item)) - else: - cleaned_items.append(item) - cleaned_data[key] = cleaned_items # type: ignore[assignment] - else: - cleaned_data[key] = value - return cleaned_data - - def wrapper(data: dict) -> dict: - return global_cleaner(clean_func(data)) - - return wrapper - - -def clean_object_data(data: dict) -> dict: - """Clean Salesforce object dictionary based on the object type.""" - - obj_type = data["attributes"]["type"] - if obj_type == SalesforceObject.ACCOUNT.value: - return clean_account_data(data) - elif obj_type == SalesforceObject.CONTACT.value: - return clean_contact_data(data) - elif obj_type == SalesforceObject.LEAD.value: - return clean_lead_data(data) - elif obj_type == SalesforceObject.NOTE.value: - return clean_note_data(data) - elif obj_type == SalesforceObject.TASK.value: - return clean_task_data(data) - elif obj_type == SalesforceObject.USER.value: - return clean_user_data(data) - raise ValueError(f"Unknown object type: '{obj_type}' in object: {data}") - - -@remove_fields_globally_ignored -def clean_account_data(data: dict) -> dict: - data["AccountType"] = data["Type"] - del data["Type"] - data["ObjectType"] = SalesforceObject.ACCOUNT.value - ignore_fields = [ - "BillingCity", - "BillingCountry", - "BillingCountryCode", - "BillingPostalCode", - "BillingState", - "BillingStateCode", - "BillingStreet", - "LastActivityDate", - "ShippingCity", - "ShippingCountry", - "ShippingCountryCode", - "ShippingPostalCode", - "ShippingState", - "ShippingStateCode", - "ShippingStreet", - ] - return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} - - -@remove_fields_globally_ignored -def clean_contact_data(data: dict) -> dict: - data["ObjectType"] = SalesforceObject.CONTACT.value - ignore_fields = ["IsEmailBounced", "IsPriorityRecord"] - return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} - - -@remove_fields_globally_ignored -def clean_lead_data(data: dict) -> dict: - data["ObjectType"] = SalesforceObject.LEAD.value - return data - - -@remove_fields_globally_ignored -def clean_opportunity_data(data: dict) -> dict: - data["ObjectType"] = SalesforceObject.OPPORTUNITY.value - data["Amount"] = { - "Value": data["Amount"], - "ClosingProbability": data["Probability"] - if not isinstance(data["Probability"], int | float) - else data["Probability"] / 100, - "ExpectedRevenue": data["ExpectedRevenue"], - } - del data["Probability"] - del data["ExpectedRevenue"] - return data - - -@remove_fields_globally_ignored -def clean_note_data(data: dict) -> dict: - data["ObjectType"] = SalesforceObject.NOTE.value - return data - - -@remove_fields_globally_ignored -def clean_task_data(data: dict) -> dict: - data["ObjectType"] = data["TaskSubtype"] - data["AssociatedToWhom"] = data["WhoId"] - ignore_fields = [ - "IsArchived", - "IsClosed", - "IsDeleted", - "IsHighPriority", - "IsRecurrence", - "IsReminderSet", - "TaskSubtype", - "WhoId", - ] - - if data["ObjectType"] == SalesforceObject.EMAIL.value: - data["Email"] = format_email(data["Description"]) - del data["ActivityDate"] - del data["CompletedDateTime"] - del data["Description"] - del data["Subject"] - del data["OwnerId"] - del data["Priority"] - del data["Status"] - - return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} - - -@remove_fields_globally_ignored -def clean_user_data(data: dict) -> dict: - data["ObjectType"] = SalesforceObject.USER.value - ignore_fields = [ - "attributes", - "CleanStatus", - "LastReferencedDate", - "LastViewedDate", - "SystemModstamp", - ] - return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} - - -def get_ids_referenced(*data_objects: dict) -> set[str]: - """Build a set of IDs referenced in a list of dictionaries. - - Args: - data_objects: The list of dictionaries to search for referenced IDs. - - Returns: - The set of IDs referenced in the dictionaries. - """ - referenced_ids = set() - for data in data_objects: - for field in ASSOCIATION_REFERENCE_FIELDS: - if field in data: - referenced_ids.add(data[field]) - return referenced_ids - - -def expand_associations(data: dict, objects_by_id: dict) -> dict: - """Expand a Salesforce object referenced by ID with metadata about the object. - - Args: - data: The dictionary to expand. - objects_by_id: The dictionary of objects metadata by ID. - - Returns: - The expanded dictionary. - """ - for field in ASSOCIATION_REFERENCE_FIELDS: - if field not in data: - continue - - associated_object = objects_by_id.get(data[field]) - - if isinstance(associated_object, dict): - del data[field] - data[field.rstrip("Id")] = simplified_object_data(associated_object) - - return data - - -def simplified_object_data(data: dict) -> dict: - return { - "Id": data["Id"], - "Name": data["Name"], - "ObjectType": get_object_type(data), - } - - -def get_object_type(data: dict) -> str: - obj_type = data.get("ObjectType") or data["attributes"]["type"] - return cast(str, obj_type) - - -def build_soql_query(query: str, **kwargs: Any) -> str: - """Build a SOQL query with sanitized arguments. - - Args: - query: The SOQL query to build. - **kwargs: The arguments to sanitize and format into the query. - - Returns: - The SOQL query with the arguments formatted. - """ - return query.format(**{key: sanitize_soql_argument(value) for key, value in kwargs.items()}) - - -def sanitize_soql_argument(value: Any) -> str: - """Sanitize an argument for a SOQL query. - - The goal is to prevent SQL injection, since Salesforce does not support parameterized queries. - This function will only accept ASCII letters and digits in the value. - - Args: - value: The value to sanitize. - - Returns: - The sanitized value. - """ - allowed_chars = string.ascii_letters + string.digits - if not isinstance(value, str): - value = str(value) - return "".join([char for char in value if char in allowed_chars]) - - -def format_email(description: str) -> dict: - """Turns a Salesforce email description string into a more readable dictionary. - - Args: - description: The email description to format. - - Returns: - The formatted email as a dictionary. - """ - email = { - "To": description.split("To:")[1].split("\n")[0].strip(), - "CC": description.split("CC:")[1].split("\n")[0].strip(), - "BCC": description.split("BCC:")[1].split("\n")[0].strip(), - "Attachment": description.split("Attachment:")[1].split("\n")[0].strip(), - "Subject": description.split("Subject:")[1].split("\n")[0].strip(), - "Body": description.split("Body:\n")[1].strip(), - } - - if email["Attachment"] == "--none--": - del email["Attachment"] - - return email - - -def remove_none_values(data: dict) -> dict: - """Remove None values from a dictionary. - - Args: - data: The dictionary to clean. - - Returns: - The cleaned dictionary. - """ - return {k: v for k, v in data.items() if v is not None} diff --git a/toolkits/salesforce/conftest.py b/toolkits/salesforce/conftest.py deleted file mode 100644 index 39be1063..00000000 --- a/toolkits/salesforce/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_httpx_client(): - with patch("arcade_salesforce.models.httpx") as mock_httpx: - yield mock_httpx.AsyncClient().__aenter__.return_value diff --git a/toolkits/salesforce/evals/eval_create_contact.py b/toolkits/salesforce/evals/eval_create_contact.py deleted file mode 100644 index 80989c31..00000000 --- a/toolkits/salesforce/evals/eval_create_contact.py +++ /dev/null @@ -1,102 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_salesforce -from arcade_salesforce.tools import create_contact - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_salesforce) - - -@tool_eval() -def create_contact_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="create contact eval suite", - system_message=( - "You are an AI assistant with access to Salesforce tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create contact", - user_message="Create a contact for the account with ID 001gK000003DIn0QAG with name Jenifer Bear and email jenifer@acme.net.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "account_id": "001gK000003DIn0QAG", - "last_name": "Bear", - "first_name": "Jenifer", - "email": "jenifer@acme.net", - "phone": None, - "mobile_phone": None, - "title": None, - "department": None, - "description": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="account_id", weight=0.2), - BinaryCritic(critic_field="last_name", weight=0.1), - BinaryCritic(critic_field="first_name", weight=0.1), - BinaryCritic(critic_field="email", weight=0.1), - BinaryCritic(critic_field="phone", weight=0.1), - BinaryCritic(critic_field="mobile_phone", weight=0.1), - BinaryCritic(critic_field="title", weight=0.1), - BinaryCritic(critic_field="department", weight=0.1), - BinaryCritic(critic_field="description", weight=0.1), - ], - ) - - suite.add_case( - name="Create contact with only last name", - user_message="Create a contact for the account with ID 001gK000003DIn0QAG with name Doe and email doe@acme.net.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_contact, - args={ - "account_id": "001gK000003DIn0QAG", - "last_name": "Doe", - "first_name": None, - "email": "doe@acme.net", - "phone": None, - "mobile_phone": None, - "title": None, - "department": None, - "description": None, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="account_id", weight=0.2), - BinaryCritic(critic_field="last_name", weight=0.1), - BinaryCritic(critic_field="first_name", weight=0.1), - BinaryCritic(critic_field="email", weight=0.1), - BinaryCritic(critic_field="phone", weight=0.1), - BinaryCritic(critic_field="mobile_phone", weight=0.1), - BinaryCritic(critic_field="title", weight=0.1), - BinaryCritic(critic_field="department", weight=0.1), - BinaryCritic(critic_field="description", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/salesforce/evals/eval_get_account_data.py b/toolkits/salesforce/evals/eval_get_account_data.py deleted file mode 100644 index 24fae04a..00000000 --- a/toolkits/salesforce/evals/eval_get_account_data.py +++ /dev/null @@ -1,222 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_salesforce -from arcade_salesforce.tools import get_account_data_by_id, get_account_data_by_keywords - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_salesforce) - - -@tool_eval() -def get_account_data_by_keywords_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get_account_data_by_keywords", - system_message=( - "You are an AI assistant with access to Salesforce tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get account data by keywords", - user_message="Get information about the Acme account.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - suite.add_case( - name="Get account data by keywords with limit", - user_message="Get information of up to 3 accounts with the word 'Acme' in their name.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "limit": 3, - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=1 / 3), - BinaryCritic(critic_field="limit", weight=1 / 3), - BinaryCritic(critic_field="page", weight=1 / 3), - ], - ) - - suite.add_case( - name="Get summary of account activity", - user_message="Get a summary of the latest activities in the Acme account.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - suite.add_case( - name="Interactions with contacts of an account", - user_message="Get me the interactions with the contacts of the Acme account.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - suite.add_case( - name="Emails or calls with contacts of an account", - user_message="Were there any emails or calls with contacts of the Acme account this week?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - suite.add_case( - name="Get account status", - user_message="What's the status of the Acme account?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - suite.add_case( - name="Get overdue tasks", - user_message="Are there any tasks overdue for the Acme account?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - suite.add_case( - name="Get account closing likelihood", - user_message="What's the likelihood of the Acme account closing a deal?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_keywords, - args={ - "query": "Acme", - "page": 1, - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="page", weight=0.5), - ], - ) - - return suite - - -@tool_eval() -def get_account_data_by_id_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="get_account_data_by_id", - system_message=( - "You are an AI assistant with access to Salesforce tools. " - "Use them to help the user with their tasks." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get account data by ID", - user_message="Get information about the account with ID 001gK000003DIn0QAG.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_account_data_by_id, - args={ - "account_id": "001gK000003DIn0QAG", - }, - ), - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="account_id", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/salesforce/pyproject.toml b/toolkits/salesforce/pyproject.toml deleted file mode 100644 index 8bd82389..00000000 --- a/toolkits/salesforce/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_salesforce" -version = "0.1.3" -description = "Arcade tools designed for LLMs to interact with Salesforce" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "httpx>=0.27.2,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_salesforce/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_salesforce",] diff --git a/toolkits/salesforce/tests/test_create_contact.py b/toolkits/salesforce/tests/test_create_contact.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/salesforce/tests/test_get_account_data.py b/toolkits/salesforce/tests/test_get_account_data.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/salesforce/tests/test_salesforce_client.py b/toolkits/salesforce/tests/test_salesforce_client.py deleted file mode 100644 index f9796a88..00000000 --- a/toolkits/salesforce/tests/test_salesforce_client.py +++ /dev/null @@ -1,270 +0,0 @@ -from unittest.mock import MagicMock, patch - -import httpx -import pytest - -from arcade_salesforce.constants import SALESFORCE_API_VERSION -from arcade_salesforce.enums import SalesforceObject -from arcade_salesforce.exceptions import ResourceNotFoundError, SalesforceToolExecutionError -from arcade_salesforce.models import SalesforceClient - - -@pytest.mark.asyncio -async def test_get_account_success(mock_context, mock_httpx_client): - account_id = "001gK000003DIn0QAG" - account = { - "attributes": { - "type": "Account", - }, - "Id": account_id, - "Name": "Acme, Inc.", - } - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = account - mock_httpx_client.get.return_value = mock_response - - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - response = await client.get_account(account_id) - - assert response == account - - mock_httpx_client.get.assert_called_once_with( - url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/Account/{account_id}", - headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, - ) - - -@pytest.mark.asyncio -async def test_get_account_not_found_error(mock_context, mock_httpx_client): - account_id = "001gK000003DIn0QAG" - response_data = [{"message": "Account not found"}] - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 404 - mock_response.json.return_value = response_data - mock_httpx_client.get.return_value = mock_response - - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - response = await client.get_account(account_id) - assert response is None - - -@pytest.mark.asyncio -async def test_get_account_bad_request_error(mock_context, mock_httpx_client): - account_id = "001gK000003DIn0QAG" - response_data = [{"message": "Bad request"}] - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 400 - mock_response.json.return_value = response_data - mock_httpx_client.get.return_value = mock_response - - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - with pytest.raises(SalesforceToolExecutionError) as exc_info: - await client.get_account(account_id) - assert exc_info.value.errors == ["Bad request"] - - -@pytest.mark.asyncio -async def test_get_account_internal_server_error(mock_context, mock_httpx_client): - account_id = "001gK000003DIn0QAG" - response_data = [{"message": "Internal server error"}] - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 500 - mock_response.json.return_value = response_data - mock_httpx_client.get.return_value = mock_response - - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - with pytest.raises(SalesforceToolExecutionError) as exc_info: - await client.get_account(account_id) - assert exc_info.value.errors == ["Internal server error"] - - -@pytest.mark.asyncio -async def test_create_contact(mock_context, mock_httpx_client): - account_id = "001gK000003DIn0QAG" - - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_httpx_client.post.return_value = mock_response - - response = await client.create_contact( - account_id=account_id, - last_name="Doe", - first_name="John", - email="john.doe@acme.net", - ) - - assert response == mock_response.json.return_value - - mock_httpx_client.post.assert_called_once_with( - url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/Contact", - headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, - json={ - "AccountId": account_id, - "FirstName": "John", - "LastName": "Doe", - "Email": "john.doe@acme.net", - }, - ) - - -@pytest.mark.asyncio -async def test_get_related_objects_success(mock_context, mock_httpx_client): - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - - parent_object_id = "001gK000003DIn0QAG" - parent_object = SalesforceObject.ACCOUNT - child_object = SalesforceObject.CONTACT - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = {"records": []} - mock_httpx_client.get.return_value = mock_response - - response = await client._get_related_objects( - child_object_type=child_object, - parent_object_type=parent_object, - parent_object_id=parent_object_id, - limit=10, - ) - - assert response == [] - - mock_httpx_client.get.assert_called_once_with( - url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/{parent_object.value}/{parent_object_id}/{child_object.plural.lower()}", - headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, - params={"limit": 10}, - ) - - -@pytest.mark.asyncio -async def test_get_related_objects_not_found_error(mock_context, mock_httpx_client): - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 404 - mock_response.json.return_value = [{"message": "Not found"}] - mock_httpx_client.get.return_value = mock_response - - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - - parent_object_id = "001gK000003DIn0QAG" - parent_object = SalesforceObject.ACCOUNT - child_object = SalesforceObject.CONTACT - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = {"records": []} - mock_httpx_client.get.return_value = mock_response - - response = await client._get_related_objects( - child_object_type=child_object, - parent_object_type=parent_object, - parent_object_id=parent_object_id, - limit=10, - ) - - assert response == [] - - mock_httpx_client.get.assert_called_once_with( - url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/{parent_object.value}/{parent_object_id}/{child_object.plural.lower()}", - headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, - params={"limit": 10}, - ) - - -@pytest.mark.asyncio -@patch("arcade_salesforce.models.build_soql_query") -async def test_get_notes_success(mock_build_soql_query, mock_context, mock_httpx_client): - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - - note_data = { - "Id": "003gK000003DIn0QAG", - "Title": "Note 1", - "Body": "Note 1 body", - "OwnerId": "005gK000003DIn0QAG", - "CreatedById": "005gK000003DIn0QAG", - "CreatedDate": "2025-01-01T00:00:00Z", - "attributes": { - "type": "Note", - }, - } - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 200 - mock_response.json.return_value = {"records": [note_data]} - mock_httpx_client.get.return_value = mock_response - - parent_id = "003gK000003DIn0QAG" - response = await client.get_notes(parent_id=parent_id, limit=10) - assert response == [ - { - "Id": note_data["Id"], - "Title": note_data["Title"], - "Body": note_data["Body"], - "OwnerId": note_data["OwnerId"], - "ObjectType": SalesforceObject.NOTE.value, - } - ] - - mock_httpx_client.get.assert_called_once_with( - url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/query", - headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, - params={"q": mock_build_soql_query.return_value}, - ) - - mock_build_soql_query.assert_called_once_with( - "SELECT Id, Title, Body, OwnerId, CreatedById, CreatedDate " - "FROM Note " - "WHERE ParentId = '{parent_id}' " - "LIMIT {limit}", - parent_id=parent_id, - limit=10, - ) - - -@pytest.mark.asyncio -@patch("arcade_salesforce.models.build_soql_query") -async def test_get_notes_not_found_error(mock_build_soql_query, mock_context, mock_httpx_client): - client = SalesforceClient( - auth_token=mock_context.authorization.token, - org_subdomain="org_domain", - ) - - mock_response = MagicMock(spec=httpx.Response) - mock_response.status_code = 404 - mock_response.json.return_value = [{"message": "Not found"}] - mock_httpx_client.get.return_value = mock_response - - parent_id = "003gK000003DIn0QAG" - with pytest.raises(ResourceNotFoundError) as exc_info: - await client.get_notes(parent_id=parent_id, limit=10) - assert exc_info.value.errors == ["Not found"] diff --git a/toolkits/search/.pre-commit-config.yaml b/toolkits/search/.pre-commit-config.yaml deleted file mode 100644 index 2a7af098..00000000 --- a/toolkits/search/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/search/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/search/.ruff.toml b/toolkits/search/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/search/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/search/LICENSE b/toolkits/search/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/search/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/search/Makefile b/toolkits/search/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/search/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/search/arcade_search/__init__.py b/toolkits/search/arcade_search/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/search/arcade_search/constants.py b/toolkits/search/arcade_search/constants.py deleted file mode 100644 index cca971fb..00000000 --- a/toolkits/search/arcade_search/constants.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - -from arcade_search.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode - -# ------------------------------------------------------------------------------------------------ -# Google default constants -# ------------------------------------------------------------------------------------------------ -DEFAULT_GOOGLE_LANGUAGE = os.getenv("ARCADE_GOOGLE_LANGUAGE", "en") -DEFAULT_GOOGLE_COUNTRY = os.getenv("ARCADE_GOOGLE_COUNTRY") - -# ------------------------------------------------------------------------------------------------ -# Google News default constants -# ------------------------------------------------------------------------------------------------ -DEFAULT_GOOGLE_NEWS_LANGUAGE = os.getenv("ARCADE_GOOGLE_NEWS_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE) -DEFAULT_GOOGLE_NEWS_COUNTRY = os.getenv("ARCADE_GOOGLE_NEWS_COUNTRY") - -# ------------------------------------------------------------------------------------------------ -# Google Jobs default constants -# ------------------------------------------------------------------------------------------------ -DEFAULT_GOOGLE_JOBS_LANGUAGE = os.getenv("ARCADE_GOOGLE_JOBS_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE) - -# ------------------------------------------------------------------------------------------------ -# Google Maps default constants -# ------------------------------------------------------------------------------------------------ -DEFAULT_GOOGLE_MAPS_LANGUAGE = os.getenv("ARCADE_GOOGLE_MAPS_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE) -DEFAULT_GOOGLE_MAPS_COUNTRY = os.getenv("ARCADE_GOOGLE_MAPS_COUNTRY") -DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT = GoogleMapsDistanceUnit( - os.getenv("ARCADE_GOOGLE_MAPS_DISTANCE_UNIT", GoogleMapsDistanceUnit.KM.value) -) -DEFAULT_GOOGLE_MAPS_TRAVEL_MODE = GoogleMapsTravelMode( - os.getenv("ARCADE_GOOGLE_MAPS_TRAVEL_MODE", GoogleMapsTravelMode.BEST.value) -) - -# ------------------------------------------------------------------------------------------------ -# YouTube default constants -# ------------------------------------------------------------------------------------------------ -YOUTUBE_MAX_DESCRIPTION_LENGTH = 500 -DEFAULT_YOUTUBE_SEARCH_LANGUAGE = os.getenv("ARCADE_YOUTUBE_SEARCH_LANGUAGE") -DEFAULT_YOUTUBE_SEARCH_COUNTRY = os.getenv("ARCADE_YOUTUBE_SEARCH_COUNTRY") - -# ------------------------------------------------------------------------------------------------ -# Google Shopping default constants -# ------------------------------------------------------------------------------------------------ -DEFAULT_GOOGLE_SHOPPING_LANGUAGE = os.getenv( - "ARCADE_GOOGLE_SHOPPING_LANGUAGE", DEFAULT_GOOGLE_LANGUAGE -) -DEFAULT_GOOGLE_SHOPPING_COUNTRY = os.getenv( - "ARCADE_GOOGLE_SHOPPING_COUNTRY", DEFAULT_GOOGLE_COUNTRY -) diff --git a/toolkits/search/arcade_search/enums.py b/toolkits/search/arcade_search/enums.py deleted file mode 100644 index b1e31d8d..00000000 --- a/toolkits/search/arcade_search/enums.py +++ /dev/null @@ -1,149 +0,0 @@ -from enum import Enum - - -# ------------------------------------------------------------------------------------------------ -# Google Finance enumerations -# ------------------------------------------------------------------------------------------------ -class GoogleFinanceWindow(Enum): - ONE_DAY = "1D" - FIVE_DAYS = "5D" - ONE_MONTH = "1M" - SIX_MONTHS = "6M" - YEAR_TO_DATE = "YTD" - ONE_YEAR = "1Y" - FIVE_YEARS = "5Y" - MAX = "MAX" - - -# ------------------------------------------------------------------------------------------------ -# Google Flights enumerations -# ------------------------------------------------------------------------------------------------ -class GoogleFlightsTravelClass(Enum): - ECONOMY = "ECONOMY" - PREMIUM_ECONOMY = "PREMIUM_ECONOMY" - BUSINESS = "BUSINESS" - FIRST = "FIRST" - - def to_api_value(self) -> int: - _map = { - "ECONOMY": 1, - "PREMIUM_ECONOMY": 2, - "BUSINESS": 3, - "FIRST": 4, - } - return _map[self.value] - - -class GoogleFlightsMaxStops(Enum): - ANY = "ANY" - NONSTOP = "NONSTOP" - ONE = "ONE" - TWO = "TWO" - - def to_api_value(self) -> int: - _map = { - "ANY": 0, - "NONSTOP": 1, - "ONE": 2, - "TWO": 3, - } - return _map[self.value] - - -class GoogleFlightsSortBy(Enum): - TOP_FLIGHTS = "TOP_FLIGHTS" - PRICE = "PRICE" - DEPARTURE_TIME = "DEPARTURE_TIME" - ARRIVAL_TIME = "ARRIVAL_TIME" - DURATION = "DURATION" - EMISSIONS = "EMISSIONS" - - def to_api_value(self) -> int: - _map = { - "TOP_FLIGHTS": 1, - "PRICE": 2, - "DEPARTURE_TIME": 3, - "ARRIVAL_TIME": 4, - "DURATION": 5, - "EMISSIONS": 6, - } - return _map[self.value] - - -# ------------------------------------------------------------------------------------------------ -# Google Hotels enumerations -# ------------------------------------------------------------------------------------------------ -class GoogleHotelsSortBy(Enum): - RELEVANCE = "RELEVANCE" - LOWEST_PRICE = "LOWEST_PRICE" - HIGHEST_RATING = "HIGHEST_RATING" - MOST_REVIEWED = "MOST_REVIEWED" - - def to_api_value(self) -> int | None: - _map = { - "RELEVANCE": None, - "LOWEST_PRICE": 3, - "HIGHEST_RATING": 8, - "MOST_REVIEWED": 13, - } - return _map[self.value] - - -# ------------------------------------------------------------------------------------------------ -# Google Maps enumerations -# ------------------------------------------------------------------------------------------------ -class GoogleMapsTravelMode(Enum): - BEST = "best" - DRIVING = "driving" - MOTORCYCLE = "motorcycle" - PUBLIC_TRANSPORTATION = "public_transportation" - WALKING = "walking" - BICYCLE = "bicycle" - FLIGHT = "flight" - - def to_api_value(self) -> int: - _map = { - str(self.BEST): 6, - str(self.DRIVING): 0, - str(self.MOTORCYCLE): 9, - str(self.PUBLIC_TRANSPORTATION): 3, - str(self.WALKING): 2, - str(self.BICYCLE): 1, - str(self.FLIGHT): 4, - } - return _map[str(self)] - - -class GoogleMapsDistanceUnit(Enum): - KM = "km" - MILES = "mi" - - def to_api_value(self) -> int: - _map = { - str(self.KM): 0, - str(self.MILES): 1, - } - return _map[str(self)] - - -# ------------------------------------------------------------------------------------------------ -# Walmart enumerations -# ------------------------------------------------------------------------------------------------ -class WalmartSortBy(Enum): - RELEVANCE = "relevance_according_to_keywords_searched" - PRICE_LOW_TO_HIGH = "lowest_price_first" - PRICE_HIGH_TO_LOW = "highest_price_first" - BEST_SELLING = "best_selling_products_first" - RATING_HIGH = "highest_rating_first" - NEW_ARRIVALS = "new_arrivals_first" - - def to_api_value(self: "WalmartSortBy") -> str | None: - _map = { - str(self.RELEVANCE): None, - str(self.PRICE_LOW_TO_HIGH): "price_low", - str(self.PRICE_HIGH_TO_LOW): "price_high", - str(self.BEST_SELLING): "best_seller", - str(self.RATING_HIGH): "rating_high", - str(self.NEW_ARRIVALS): "new", - } - return _map[str(self)] diff --git a/toolkits/search/arcade_search/exceptions.py b/toolkits/search/arcade_search/exceptions.py deleted file mode 100644 index 7b1526a9..00000000 --- a/toolkits/search/arcade_search/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -from arcade_tdk.errors import RetryableToolError - -from arcade_search.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -class GoogleRetryableError(RetryableToolError): - pass - - -class CountryNotFoundError(GoogleRetryableError): - def __init__(self, country: str | None) -> None: - valid_countries = json.dumps(COUNTRY_CODES, default=str) - message = f"Country not found: '{country}'." - additional_message = f"Valid countries are: {valid_countries}" - super().__init__(message, additional_prompt_content=additional_message) - - -class LanguageNotFoundError(GoogleRetryableError): - def __init__(self, language: str | None) -> None: - valid_languages = json.dumps(LANGUAGE_CODES, default=str) - message = f"Language not found: '{language}'." - additional_message = f"Valid languages are: {valid_languages}" - super().__init__(message, additional_prompt_content=additional_message) diff --git a/toolkits/search/arcade_search/google_data.py b/toolkits/search/arcade_search/google_data.py deleted file mode 100644 index 0c44fc11..00000000 --- a/toolkits/search/arcade_search/google_data.py +++ /dev/null @@ -1,469 +0,0 @@ -COUNTRY_CODES = { - "af": "Afghanistan", - "al": "Albania", - "dz": "Algeria", - "as": "American Samoa", - "ad": "Andorra", - "ao": "Angola", - "ai": "Anguilla", - "aq": "Antarctica", - "ag": "Antigua and Barbuda", - "ar": "Argentina", - "am": "Armenia", - "aw": "Aruba", - "au": "Australia", - "at": "Austria", - "az": "Azerbaijan", - "bs": "Bahamas", - "bh": "Bahrain", - "bd": "Bangladesh", - "bb": "Barbados", - "by": "Belarus", - "be": "Belgium", - "bz": "Belize", - "bj": "Benin", - "bm": "Bermuda", - "bt": "Bhutan", - "bo": "Bolivia", - "ba": "Bosnia and Herzegovina", - "bw": "Botswana", - "bv": "Bouvet Island", - "br": "Brazil", - "io": "British Indian Ocean Territory", - "bn": "Brunei Darussalam", - "bg": "Bulgaria", - "bf": "Burkina Faso", - "bi": "Burundi", - "kh": "Cambodia", - "cm": "Cameroon", - "ca": "Canada", - "cv": "Cape Verde", - "ky": "Cayman Islands", - "cf": "Central African Republic", - "td": "Chad", - "cl": "Chile", - "cn": "China", - "cx": "Christmas Island", - "cc": "Cocos (Keeling) Islands", - "co": "Colombia", - "km": "Comoros", - "cg": "Congo", - "cd": "Congo, the Democratic Republic of the", - "ck": "Cook Islands", - "cr": "Costa Rica", - "ci": "Cote D'ivoire", - "hr": "Croatia", - "cu": "Cuba", - "cy": "Cyprus", - "cz": "Czech Republic", - "dk": "Denmark", - "dj": "Djibouti", - "dm": "Dominica", - "do": "Dominican Republic", - "ec": "Ecuador", - "eg": "Egypt", - "sv": "El Salvador", - "gq": "Equatorial Guinea", - "er": "Eritrea", - "ee": "Estonia", - "et": "Ethiopia", - "fk": "Falkland Islands (Malvinas)", - "fo": "Faroe Islands", - "fj": "Fiji", - "fi": "Finland", - "fr": "France", - "gf": "French Guiana", - "pf": "French Polynesia", - "tf": "French Southern Territories", - "ga": "Gabon", - "gm": "Gambia", - "ge": "Georgia", - "de": "Germany", - "gh": "Ghana", - "gi": "Gibraltar", - "gr": "Greece", - "gl": "Greenland", - "gd": "Grenada", - "gp": "Guadeloupe", - "gu": "Guam", - "gt": "Guatemala", - "gg": "Guernsey", - "gn": "Guinea", - "gw": "Guinea-Bissau", - "gy": "Guyana", - "ht": "Haiti", - "hm": "Heard Island and Mcdonald Islands", - "va": "Holy See (Vatican City State)", - "hn": "Honduras", - "hk": "Hong Kong", - "hu": "Hungary", - "is": "Iceland", - "in": "India", - "id": "Indonesia", - "ir": "Iran, Islamic Republic of", - "iq": "Iraq", - "ie": "Ireland", - "im": "Isle of Man", - "il": "Israel", - "it": "Italy", - "je": "Jersey", - "jm": "Jamaica", - "jp": "Japan", - "jo": "Jordan", - "kz": "Kazakhstan", - "ke": "Kenya", - "ki": "Kiribati", - "kp": "Korea, Democratic People's Republic of", - "kr": "Korea, Republic of", - "kw": "Kuwait", - "kg": "Kyrgyzstan", - "la": "Lao People's Democratic Republic", - "lv": "Latvia", - "lb": "Lebanon", - "ls": "Lesotho", - "lr": "Liberia", - "ly": "Libyan Arab Jamahiriya", - "li": "Liechtenstein", - "lt": "Lithuania", - "lu": "Luxembourg", - "mo": "Macao", - "mk": "Macedonia, the Former Yugosalv Republic of", - "mg": "Madagascar", - "mw": "Malawi", - "my": "Malaysia", - "mv": "Maldives", - "ml": "Mali", - "mt": "Malta", - "mh": "Marshall Islands", - "mq": "Martinique", - "mr": "Mauritania", - "mu": "Mauritius", - "yt": "Mayotte", - "mx": "Mexico", - "fm": "Micronesia, Federated States of", - "md": "Moldova, Republic of", - "mc": "Monaco", - "mn": "Mongolia", - "me": "Montenegro", - "ms": "Montserrat", - "ma": "Morocco", - "mz": "Mozambique", - "mm": "Myanmar", - "na": "Namibia", - "nr": "Nauru", - "np": "Nepal", - "nl": "Netherlands", - "an": "Netherlands Antilles", - "nc": "New Caledonia", - "nz": "New Zealand", - "ni": "Nicaragua", - "ne": "Niger", - "ng": "Nigeria", - "nu": "Niue", - "nf": "Norfolk Island", - "mp": "Northern Mariana Islands", - "no": "Norway", - "om": "Oman", - "pk": "Pakistan", - "pw": "Palau", - "ps": "Palestinian Territory, Occupied", - "pa": "Panama", - "pg": "Papua New Guinea", - "py": "Paraguay", - "pe": "Peru", - "ph": "Philippines", - "pn": "Pitcairn", - "pl": "Poland", - "pt": "Portugal", - "pr": "Puerto Rico", - "qa": "Qatar", - "re": "Reunion", - "ro": "Romania", - "ru": "Russian Federation", - "rw": "Rwanda", - "sh": "Saint Helena", - "kn": "Saint Kitts and Nevis", - "lc": "Saint Lucia", - "pm": "Saint Pierre and Miquelon", - "vc": "Saint Vincent and the Grenadines", - "ws": "Samoa", - "sm": "San Marino", - "st": "Sao Tome and Principe", - "sa": "Saudi Arabia", - "sn": "Senegal", - "rs": "Serbia", - "sc": "Seychelles", - "sl": "Sierra Leone", - "sg": "Singapore", - "sk": "Slovakia", - "si": "Slovenia", - "sb": "Solomon Islands", - "so": "Somalia", - "za": "South Africa", - "gs": "South Georgia and the South Sandwich Islands", - "es": "Spain", - "lk": "Sri Lanka", - "sd": "Sudan", - "sr": "Suriname", - "sj": "Svalbard and Jan Mayen", - "sz": "Swaziland", - "se": "Sweden", - "ch": "Switzerland", - "sy": "Syrian Arab Republic", - "tw": "Taiwan, Province of China", - "tj": "Tajikistan", - "tz": "Tanzania, United Republic of", - "th": "Thailand", - "tl": "Timor-Leste", - "tg": "Togo", - "tk": "Tokelau", - "to": "Tonga", - "tt": "Trinidad and Tobago", - "tn": "Tunisia", - "tr": "Turkiye", - "tm": "Turkmenistan", - "tc": "Turks and Caicos Islands", - "tv": "Tuvalu", - "ug": "Uganda", - "ua": "Ukraine", - "ae": "United Arab Emirates", - "uk": "United Kingdom", - "gb": "United Kingdom", - "us": "United States", - "um": "United States Minor Outlying Islands", - "uy": "Uruguay", - "uz": "Uzbekistan", - "vu": "Vanuatu", - "ve": "Venezuela", - "vn": "Viet Nam", - "vg": "Virgin Islands, British", - "vi": "Virgin Islands, U.S.", - "wf": "Wallis and Futuna", - "eh": "Western Sahara", - "ye": "Yemen", - "zm": "Zambia", - "zw": "Zimbabwe", -} - - -LANGUAGE_CODES = { - "ar": "Arabic", - "bn": "Bengali", - "da": "Danish", - "de": "German", - "el": "Greek", - "en": "English", - "es": "Spanish", - "fi": "Finnish", - "fr": "French", - "hi": "Hindi", - "hu": "Hungarian", - "id": "Indonesian", - "it": "Italian", - "ja": "Japanese", - "ko": "Korean", - "nl": "Dutch", - "ms": "Malay", - "no": "Norwegian", - "pcm": "Nigerian Pidgin", - "pl": "Polish", - "pt": "Portuguese", - "pt-br": "Portuguese (Brazil)", - "pt-pt": "Portuguese (Portugal)", - "ru": "Russian", - "sv": "Swedish", - "tl": "Filipino", - "tr": "Turkish", - "uk": "Ukrainian", - "zh": "Chinese", - "zh-cn": "Chinese (Simplified)", - "zh-tw": "Chinese (Traditional)", -} - - -GOOGLE_DOMAIN_BY_COUNTRY_CODE = { - "ad": "google.ad", - "ae": "google.ae", - "al": "google.al", - "am": "google.am", - "as": "google.as", - "at": "google.at", - "az": "google.az", - "ba": "google.ba", - "be": "google.be", - "bf": "google.bf", - "bg": "google.bg", - "bi": "google.bi", - "bj": "google.bj", - "bs": "google.bs", - "bt": "google.bt", - "by": "google.by", - "ca": "google.ca", - "cg": "google.cg", - "cf": "google.cf", - "ch": "google.ch", - "ci": "google.ci", - "cl": "google.cl", - "cm": "google.cm", - "ao": "google.co.ao", - "bw": "google.co.bw", - "ck": "google.co.ck", - "cr": "google.co.cr", - "id": "google.co.id", - "il": "google.co.il", - "in": "google.co.in", - "jp": "google.co.jp", - "ke": "google.co.ke", - "kr": "google.co.kr", - "ls": "google.co.ls", - "ma": "google.co.ma", - "mz": "google.co.mz", - "nz": "google.co.nz", - "th": "google.co.th", - "tz": "google.co.tz", - "ug": "google.co.ug", - "uk": "google.co.uk", - "uz": "google.co.uz", - "ve": "google.co.ve", - "vi": "google.co.vi", - "za": "google.co.za", - "zm": "google.co.zm", - "zw": "google.co.zw", - "us": "google.com", - "af": "google.com.af", - "ag": "google.com.ag", - "ai": "google.com.ai", - "ar": "google.com.ar", - "au": "google.com.au", - "bd": "google.com.bd", - "bh": "google.com.bh", - "bn": "google.com.bn", - "bo": "google.com.bo", - "br": "google.com.br", - "bz": "google.com.bz", - "co": "google.com.co", - "cu": "google.com.cu", - "cy": "google.com.cy", - "do": "google.com.do", - "ec": "google.com.ec", - "eg": "google.com.eg", - "et": "google.com.et", - "fj": "google.com.fj", - "gh": "google.com.gh", - "gi": "google.com.gi", - "gt": "google.com.gt", - "hk": "google.com.hk", - "jm": "google.com.jm", - "kh": "google.com.kh", - "kw": "google.com.kw", - "lb": "google.com.lb", - "ly": "google.com.ly", - "mm": "google.com.mm", - "mt": "google.com.mt", - "mx": "google.com.mx", - "my": "google.com.my", - "na": "google.com.na", - "ng": "google.com.ng", - "ni": "google.com.ni", - "np": "google.com.np", - "om": "google.com.om", - "pa": "google.com.pa", - "pe": "google.com.pe", - "pg": "google.com.pg", - "ph": "google.com.ph", - "pk": "google.com.pk", - "pr": "google.com.pr", - "py": "google.com.py", - "qa": "google.com.qa", - "sa": "google.com.sa", - "sb": "google.com.sb", - "sg": "google.com.sg", - "sl": "google.com.sl", - "sv": "google.com.sv", - "tj": "google.com.tj", - "tr": "google.com.tr", - "tw": "google.com.tw", - "ua": "google.com.ua", - "uy": "google.com.uy", - "vc": "google.com.vc", - "vn": "google.com.vn", - "cv": "google.cv", - "cz": "google.cz", - "de": "google.de", - "dj": "google.dj", - "dk": "google.dk", - "dm": "google.dm", - "dz": "google.dz", - "ee": "google.ee", - "es": "google.es", - "fi": "google.fi", - "fm": "google.fm", - "fr": "google.fr", - "ga": "google.ga", - "ge": "google.ge", - "gl": "google.gl", - "gm": "google.gm", - "gp": "google.gp", - "gr": "google.gr", - "gy": "google.gy", - "hn": "google.hn", - "hr": "google.hr", - "ht": "google.ht", - "hu": "google.hu", - "ie": "google.ie", - "iq": "google.iq", - "is": "google.is", - "it": "google.it", - "je": "google.je", - "jo": "google.jo", - "kg": "google.kg", - "ki": "google.ki", - "kz": "google.kz", - "la": "google.la", - "li": "google.li", - "lk": "google.lk", - "lt": "google.lt", - "lu": "google.lu", - "lv": "google.lv", - "md": "google.md", - "mg": "google.mg", - "mk": "google.mk", - "ml": "google.ml", - "mn": "google.mn", - "ms": "google.ms", - "mu": "google.mu", - "mv": "google.mv", - "mw": "google.mw", - "ne": "google.ne", - "nl": "google.nl", - "no": "google.no", - "nr": "google.nr", - "nu": "google.nu", - "pl": "google.pl", - "ps": "google.ps", - "pt": "google.pt", - "ro": "google.ro", - "rs": "google.rs", - "ru": "google.ru", - "rw": "google.rw", - "sc": "google.sc", - "se": "google.se", - "sh": "google.sh", - "si": "google.si", - "sk": "google.sk", - "sm": "google.sm", - "sn": "google.sn", - "so": "google.so", - "sr": "google.sr", - "td": "google.td", - "tg": "google.tg", - "tk": "google.tk", - "tl": "google.tl", - "tm": "google.tm", - "tn": "google.tn", - "to": "google.to", - "tt": "google.tt", - "vg": "google.vg", - "vu": "google.vu", - "ws": "google.ws", -} diff --git a/toolkits/search/arcade_search/tools/__init__.py b/toolkits/search/arcade_search/tools/__init__.py deleted file mode 100644 index 3d5d54de..00000000 --- a/toolkits/search/arcade_search/tools/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from arcade_search.tools.google_finance import get_stock_historical_data, get_stock_summary -from arcade_search.tools.google_flights import search_one_way_flights, search_roundtrip_flights -from arcade_search.tools.google_hotels import search_hotels -from arcade_search.tools.google_jobs import search_jobs -from arcade_search.tools.google_maps import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) -from arcade_search.tools.google_search import search_google - -__all__ = [ - "search_google", # Google Search - "get_stock_summary", # Google Finance - "get_stock_historical_data", # Google Finance - "search_one_way_flights", # Google Flights - "search_roundtrip_flights", # Google Flights - "search_hotels", # Google Hotels - "get_directions_between_addresses", # Google Maps - "get_directions_between_coordinates", # Google Maps - "search_jobs", # Google Jobs -] diff --git a/toolkits/search/arcade_search/tools/google_finance.py b/toolkits/search/arcade_search/tools/google_finance.py deleted file mode 100644 index e5538b5a..00000000 --- a/toolkits/search/arcade_search/tools/google_finance.py +++ /dev/null @@ -1,86 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool - -from arcade_search.enums import GoogleFinanceWindow -from arcade_search.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_stock_summary( - context: ToolContext, - ticker_symbol: Annotated[ - str, - "The stock ticker to get summary for. For example, 'GOOG' is the ticker symbol for Google", - ], - exchange_identifier: Annotated[ - str, - "The exchange identifier. This part indicates the market where the " - "stock is traded. For example, 'NASDAQ', 'NYSE', 'TSE', 'LSE', etc.", - ], -) -> Annotated[dict[str, Any], "Summary of the stock's recent performance"]: - """Retrieve the summary information for a given stock ticker using the Google Finance API. - - Gets the stock's current price as well as price movement from the most recent trading day. - """ - # Prepare the request - query = ( - f"{ticker_symbol.upper()}:{exchange_identifier.upper()}" - if exchange_identifier - else ticker_symbol.upper() - ) - params = prepare_params("google_finance", q=query) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - summary: dict = results.get("summary", {}) - - return summary - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_stock_historical_data( - context: ToolContext, - ticker_symbol: Annotated[ - str, - "The stock ticker to get summary for. For example, 'GOOG' is the ticker symbol for Google", - ], - exchange_identifier: Annotated[ - str, - "The exchange identifier. This part indicates the market where the " - "stock is traded. For example, 'NASDAQ', 'NYSE', 'TSE', 'LSE', etc.", - ], - window: Annotated[ - GoogleFinanceWindow, "Time window for the graph data. Defaults to 1 month" - ] = GoogleFinanceWindow.ONE_MONTH, -) -> Annotated[ - dict[str, Any], - "A stock's price and volume data at a specific time interval over a specified time window", -]: - """Fetch historical stock price data over a specified time window - - Returns a stock's price and volume data over a specified time window - """ - # Prepare the request - query = ( - f"{ticker_symbol.upper()}:{exchange_identifier.upper()}" - if exchange_identifier - else ticker_symbol.upper() - ) - params = prepare_params("google_finance", q=query, window=window.value) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - data = { - "summary": results.get("summary", {}), - "graph": results.get("graph", []), - } - key_events = results.get("key_events") - if key_events: - data["key_events"] = key_events - - return data diff --git a/toolkits/search/arcade_search/tools/google_flights.py b/toolkits/search/arcade_search/tools/google_flights.py deleted file mode 100644 index a0c5000e..00000000 --- a/toolkits/search/arcade_search/tools/google_flights.py +++ /dev/null @@ -1,110 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool - -from arcade_search.enums import GoogleFlightsMaxStops, GoogleFlightsSortBy, GoogleFlightsTravelClass -from arcade_search.utils import call_serpapi, parse_flight_results, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_roundtrip_flights( - context: ToolContext, - departure_airport_code: Annotated[ - str, "The departure airport code. An uppercase 3-letter code" - ], - arrival_airport_code: Annotated[str, "The arrival airport code. An uppercase 3-letter code"], - outbound_date: Annotated[str, "Flight outbound date in YYYY-MM-DD format"], - return_date: Annotated[str | None, "Flight return date in YYYY-MM-DD format"], - currency_code: Annotated[ - str | None, "Currency of the returned prices. Defaults to 'USD'" - ] = "USD", - travel_class: Annotated[ - GoogleFlightsTravelClass, - "Travel class of the flight. Defaults to 'ECONOMY'", - ] = GoogleFlightsTravelClass.ECONOMY, - num_adults: Annotated[int | None, "Number of adult passengers. Defaults to 1"] = 1, - num_children: Annotated[int | None, "Number of child passengers. Defaults to 0"] = 0, - max_stops: Annotated[ - GoogleFlightsMaxStops, - "Maximum number of stops (layovers) for the flight. Defaults to any number of stops", - ] = GoogleFlightsMaxStops.ANY, - sort_by: Annotated[ - GoogleFlightsSortBy, - "The sorting order of the results. Defaults to TOP_FLIGHTS.", - ] = GoogleFlightsSortBy.TOP_FLIGHTS, -) -> Annotated[dict[str, Any], "Flight search results from the Google Flights API"]: - """Retrieve flight search results using Google Flights""" - # Prepare the request - params = prepare_params( - "google_flights", - departure_id=departure_airport_code, - arrival_id=arrival_airport_code, - outbound_date=outbound_date, - return_date=return_date, - currency=currency_code, - travel_class=travel_class.to_api_value(), - adults=num_adults, - children=num_children, - stops=max_stops.to_api_value(), - sort_by=sort_by.to_api_value(), - deep_search=True, # Same search depth of the Google Flights page in the browser - ) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - flights = parse_flight_results(results) - - return flights - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_one_way_flights( - context: ToolContext, - departure_airport_code: Annotated[ - str, "The departure airport code. An uppercase 3-letter code" - ], - arrival_airport_code: Annotated[str, "The arrival airport code. An uppercase 3-letter code"], - outbound_date: Annotated[str, "Flight departure date in YYYY-MM-DD format"], - currency_code: Annotated[ - str | None, "Currency of the returned prices. Defaults to 'USD'" - ] = "USD", - travel_class: Annotated[ - GoogleFlightsTravelClass, - "Travel class of the flight. Defaults to 'ECONOMY'", - ] = GoogleFlightsTravelClass.ECONOMY, - num_adults: Annotated[int | None, "Number of adult passengers. Defaults to 1"] = 1, - num_children: Annotated[int | None, "Number of child passengers. Defaults to 0"] = 0, - max_stops: Annotated[ - GoogleFlightsMaxStops, - "Maximum number of stops (layovers) for the flight. Defaults to any number of stops", - ] = GoogleFlightsMaxStops.ANY, - sort_by: Annotated[ - GoogleFlightsSortBy, - "The sorting order of the results. Defaults to TOP_FLIGHTS.", - ] = GoogleFlightsSortBy.TOP_FLIGHTS, -) -> Annotated[dict[str, Any], "Flight search results from the Google Flights API"]: - """Retrieve flight search results for a one-way flight using Google Flights""" - params = prepare_params( - "google_flights", - departure_id=departure_airport_code, - arrival_id=arrival_airport_code, - outbound_date=outbound_date, - currency=currency_code, - travel_class=travel_class.to_api_value(), - adults=num_adults, - children=num_children, - stops=max_stops.to_api_value(), - sort_by=sort_by.to_api_value(), - type=2, # indicates one-way - deep_search=True, # Same search depth as the Google Flights page in the browser - ) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - flights = parse_flight_results(results) - - return flights diff --git a/toolkits/search/arcade_search/tools/google_hotels.py b/toolkits/search/arcade_search/tools/google_hotels.py deleted file mode 100644 index 904baa3a..00000000 --- a/toolkits/search/arcade_search/tools/google_hotels.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool - -from arcade_search.enums import GoogleHotelsSortBy -from arcade_search.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_hotels( - context: ToolContext, - location: Annotated[str, "Location to search for hotels, e.g., a city name, a state, etc."], - check_in_date: Annotated[str, "Check-in date in YYYY-MM-DD format"], - check_out_date: Annotated[str, "Check-out date in YYYY-MM-DD format"], - query: Annotated[ - str | None, "Anything that would be used in a regular Google Hotels search" - ] = None, - currency: Annotated[str | None, "Currency code for prices. Defaults to 'USD'"] = "USD", - min_price: Annotated[int | None, "Minimum price per night. Defaults to no minimum"] = None, - max_price: Annotated[int | None, "Maximum price per night. Defaults to no maximum"] = None, - num_adults: Annotated[int | None, "Number of adults per room. Defaults to 2"] = 2, - num_children: Annotated[int | None, "Number of children per room. Defaults to 0"] = 0, - sort_by: Annotated[ - GoogleHotelsSortBy, "The sorting order of the results. Defaults to RELEVANCE" - ] = GoogleHotelsSortBy.RELEVANCE, - num_results: Annotated[ - int | None, "Maximum number of results to return. Defaults to 5. Max 20" - ] = 5, -) -> Annotated[dict[str, Any], "Hotel search results from the Google Hotels API"]: - """Retrieve hotel search results using the Google Hotels API.""" - # Prepare the request - params = prepare_params( - "google_hotels", - q=f"{query}, {location}" if query else location, - check_in_date=check_in_date, - check_out_date=check_out_date, - currency=currency, - min_price=min_price, - max_price=max_price, - adults=num_adults, - children=num_children, - sort_by=sort_by.to_api_value(), - ) - - # Execute the request - results = call_serpapi(context, params) - - # Parse the results - properties = results.get("properties", [])[:num_results] - - # Remove unwanted fields from each property - for hotel in properties: - hotel.pop("images", None) - hotel.pop("extracted_hotel_class", None) - hotel.pop("reviews_breakdown", None) - hotel.pop("serpapi_property_details_link", None) - - return {"properties": properties} diff --git a/toolkits/search/arcade_search/tools/google_jobs.py b/toolkits/search/arcade_search/tools/google_jobs.py deleted file mode 100644 index 0076664e..00000000 --- a/toolkits/search/arcade_search/tools/google_jobs.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool - -from arcade_search.constants import DEFAULT_GOOGLE_JOBS_LANGUAGE -from arcade_search.exceptions import LanguageNotFoundError -from arcade_search.google_data import LANGUAGE_CODES -from arcade_search.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_jobs( - context: ToolContext, - query: Annotated[ - str, - "Search query. Provide a job title, company name, and/or any keywords in general " - "representing what kind of jobs the user is looking for. E.g. 'software engineer' " - "or 'data analyst at Apple'.", - ], - location: Annotated[ - str | None, - "Location to search for jobs. E.g. 'United States' or 'New York, NY'. Defaults to None.", - ] = None, - language: Annotated[ - str, - "2-character language code to use in the Google Jobs search. " - f"E.g. 'en' for English. Defaults to '{DEFAULT_GOOGLE_JOBS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_JOBS_LANGUAGE, - limit: Annotated[ - int, - "Maximum number of results to retrieve. Defaults to 10 (max supported by the API).", - ] = 10, - next_page_token: Annotated[ - str | None, - "Next page token to paginate results. Defaults to None (start from the first page).", - ] = None, -) -> Annotated[dict, "Google Jobs results"]: - """Search Google Jobs using SerpAPI.""" - if language not in LANGUAGE_CODES: - raise LanguageNotFoundError(language) - - params = prepare_params( - engine="google_jobs", - q=query, - hl=language, - ) - - if location: - params["location"] = location - - if next_page_token: - params["next_page_token"] = next_page_token - - results = call_serpapi(context, params) - jobs_results = results.get("jobs_results", []) - - try: - next_page_token = results["serpapi_pagination"]["next_page_token"] - except KeyError: - next_page_token = None - - return { - "jobs": jobs_results[:limit], - "next_page_token": next_page_token, - } diff --git a/toolkits/search/arcade_search/tools/google_maps.py b/toolkits/search/arcade_search/tools/google_maps.py deleted file mode 100644 index a45ca539..00000000 --- a/toolkits/search/arcade_search/tools/google_maps.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool - -from arcade_search.constants import ( - DEFAULT_GOOGLE_MAPS_COUNTRY, - DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - DEFAULT_GOOGLE_MAPS_LANGUAGE, - DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -from arcade_search.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode -from arcade_search.utils import get_google_maps_directions - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_directions_between_addresses( - context: ToolContext, - origin_address: Annotated[ - str, "The origin address. Example: '123 Main St, New York, NY 10001'" - ], - destination_address: Annotated[ - str, "The destination address. Example: '456 Main St, New York, NY 10001'" - ], - language: Annotated[ - str, - "2-character language code to use in the Google Maps search. " - f"Defaults to '{DEFAULT_GOOGLE_MAPS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_MAPS_LANGUAGE, - country: Annotated[ - str | None, - "2-character country code to use in the Google Maps search. " - f"Defaults to '{DEFAULT_GOOGLE_MAPS_COUNTRY}'.", - ] = DEFAULT_GOOGLE_MAPS_COUNTRY, - distance_unit: Annotated[ - GoogleMapsDistanceUnit, - f"Distance unit to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT}'.", - ] = DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - travel_mode: Annotated[ - GoogleMapsTravelMode, - f"Travel mode to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_TRAVEL_MODE}'.", - ] = DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -> Annotated[dict, "The directions from Google Maps"]: - """Get directions from Google Maps.""" - return { - "directions": get_google_maps_directions( - context=context, - origin_address=origin_address, - destination_address=destination_address, - language=language, - country=country, - distance_unit=distance_unit, - travel_mode=travel_mode, - ), - } - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_directions_between_coordinates( - context: ToolContext, - origin_latitude: Annotated[str, "The origin latitude. E.g. '40.7128'"], - origin_longitude: Annotated[str, "The origin longitude. E.g. '-74.0060'"], - destination_latitude: Annotated[str, "The destination latitude. E.g. '40.7128'"], - destination_longitude: Annotated[str, "The destination longitude. E.g. '-74.0060'"], - language: Annotated[ - str, - "2-letter language code to use in the Google Maps search. " - f"Defaults to '{DEFAULT_GOOGLE_MAPS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_MAPS_LANGUAGE, - country: Annotated[ - str | None, - f"2-letter country code to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_COUNTRY}'.", - ] = DEFAULT_GOOGLE_MAPS_COUNTRY, - distance_unit: Annotated[ - GoogleMapsDistanceUnit, - f"Distance unit to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT}'.", - ] = DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - travel_mode: Annotated[ - GoogleMapsTravelMode, - f"Travel mode to use in the Google Maps search. Defaults to " - f"'{DEFAULT_GOOGLE_MAPS_TRAVEL_MODE}'.", - ] = DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -> Annotated[dict, "The directions from Google Maps"]: - """Get directions from Google Maps.""" - return { - "directions": get_google_maps_directions( - context=context, - origin_latitude=origin_latitude, - origin_longitude=origin_longitude, - destination_latitude=destination_latitude, - destination_longitude=destination_longitude, - language=language, - country=country, - distance_unit=distance_unit, - travel_mode=travel_mode, - ), - } diff --git a/toolkits/search/arcade_search/tools/google_news.py b/toolkits/search/arcade_search/tools/google_news.py deleted file mode 100644 index 012ee31d..00000000 --- a/toolkits/search/arcade_search/tools/google_news.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import ToolExecutionError - -from arcade_search.constants import DEFAULT_GOOGLE_NEWS_COUNTRY, DEFAULT_GOOGLE_NEWS_LANGUAGE -from arcade_search.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_search.google_data import COUNTRY_CODES, LANGUAGE_CODES -from arcade_search.utils import call_serpapi, extract_news_results, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_news_stories( - context: ToolContext, - keywords: Annotated[ - str, - "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.", - ], - country_code: Annotated[ - str | None, - "2-character country code to search for news articles. E.g. 'us' (United States). " - f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.", - ] = None, - language_code: Annotated[ - str, - "2-character language code to search for news articles. E.g. 'en' (English). " - f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.", - ] = DEFAULT_GOOGLE_NEWS_LANGUAGE, - limit: Annotated[ - int | None, - "Maximum number of news articles to return. Defaults to None " - "(returns all results found by the API).", - ] = None, -) -> Annotated[dict[str, list[dict[str, Any]]], "News results."]: - """Search for news articles related to a given query.""" - if not keywords: - raise ToolExecutionError("Keywords are required to search for news articles.") - - if country_code and country_code not in COUNTRY_CODES: - raise CountryNotFoundError(country_code) - - if language_code not in LANGUAGE_CODES: - raise LanguageNotFoundError(language_code) - - params = prepare_params("google_news", q=keywords, gl=country_code, hl=language_code) - results = call_serpapi(context, params) - return {"news_results": extract_news_results(results, limit=limit)} diff --git a/toolkits/search/arcade_search/tools/google_search.py b/toolkits/search/arcade_search/tools/google_search.py deleted file mode 100644 index dc7faf3e..00000000 --- a/toolkits/search/arcade_search/tools/google_search.py +++ /dev/null @@ -1,21 +0,0 @@ -import json -from typing import Annotated - -from arcade_tdk import ToolContext, tool - -from arcade_search.utils import call_serpapi, prepare_params - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_google( - context: ToolContext, - query: Annotated[str, "Search query"], - n_results: Annotated[int, "Number of results to retrieve"] = 5, -) -> str: - """Search Google using SerpAPI and return organic search results.""" - - params = prepare_params("google", q=query) - results = call_serpapi(context, params) - organic_results = results.get("organic_results", []) - - return json.dumps(organic_results[:n_results]) diff --git a/toolkits/search/arcade_search/tools/google_shopping.py b/toolkits/search/arcade_search/tools/google_shopping.py deleted file mode 100644 index 222c0830..00000000 --- a/toolkits/search/arcade_search/tools/google_shopping.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import ToolExecutionError - -from arcade_search.constants import ( - DEFAULT_GOOGLE_SHOPPING_COUNTRY, - DEFAULT_GOOGLE_SHOPPING_LANGUAGE, -) -from arcade_search.google_data import GOOGLE_DOMAIN_BY_COUNTRY_CODE -from arcade_search.utils import ( - call_serpapi, - extract_shopping_results, - prepare_params, - resolve_country_code, - resolve_language_code, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_shopping_products( - context: ToolContext, - keywords: Annotated[ - str, - "Keywords to search for products in Google Shopping. E.g. 'Apple iPhone'.", - ], - country_code: Annotated[ - str | None, - "2-character country code to search for products in Google Shopping. " - f"E.g. 'us' (United States). Defaults to '{DEFAULT_GOOGLE_SHOPPING_COUNTRY or 'us'}'.", - ] = DEFAULT_GOOGLE_SHOPPING_COUNTRY, - language_code: Annotated[ - str | None, - "2-character language code to search for products on Google Shopping. E.g. 'en' (English). " - f"Defaults to '{DEFAULT_GOOGLE_SHOPPING_LANGUAGE or 'en'}'.", - ] = DEFAULT_GOOGLE_SHOPPING_LANGUAGE, -) -> Annotated[dict[str, list[dict[str, Any]]], "Products on Google Shopping."]: - """Search for products on Google Shopping related to a given query.""" - country_code = resolve_country_code(country_code, DEFAULT_GOOGLE_SHOPPING_COUNTRY) - language_code = resolve_language_code(language_code, DEFAULT_GOOGLE_SHOPPING_LANGUAGE) - - if not isinstance(country_code, str): - country_code = "us" - - if not isinstance(language_code, str): - language_code = "en" - - google_domain = GOOGLE_DOMAIN_BY_COUNTRY_CODE.get(country_code, "google.com") - - params = prepare_params( - "google_shopping", - q=keywords, - gl=country_code, - hl=language_code, - google_domain=google_domain, - ) - - response = call_serpapi(context, params) - - if response.get("error"): - error_msg = response.get("error") or "Unknown Google Shopping Error" - raise ToolExecutionError(error_msg) - - return { - "products": extract_shopping_results(response.get("shopping_results", [])), - } diff --git a/toolkits/search/arcade_search/tools/walmart.py b/toolkits/search/arcade_search/tools/walmart.py deleted file mode 100644 index 707df6e4..00000000 --- a/toolkits/search/arcade_search/tools/walmart.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from arcade_tdk.tool import tool - -from arcade_search.enums import WalmartSortBy -from arcade_search.utils import ( - call_serpapi, - extract_walmart_product_details, - extract_walmart_results, - get_walmart_last_page_integer, - prepare_params, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_walmart_products( - context: ToolContext, - keywords: Annotated[str, "Keywords to search for. E.g. 'apple iphone' or 'samsung galaxy'"], - sort_by: Annotated[ - WalmartSortBy, - "Sort the results by the specified criteria. " - f"Defaults to '{WalmartSortBy.RELEVANCE.value}'.", - ] = WalmartSortBy.RELEVANCE, - min_price: Annotated[ - float | None, - "Minimum price to filter the results by. E.g. 100.00", - ] = None, - max_price: Annotated[ - float | None, - "Maximum price to filter the results by. E.g. 100.00", - ] = None, - next_day_delivery: Annotated[ - bool, - "Filters products that are eligible for next day delivery. " - "Defaults to False (returns all products, regardless of delivery status).", - ] = False, - page: Annotated[ - int, - "Page number to fetch. Defaults to 1 (first page of results). " - "The maximum page value is 100.", - ] = 1, -) -> Annotated[dict[str, Any], "List of Walmart products matching the search query."]: - """Search Walmart products using SerpAPI.""" - if page > 100: - raise ToolExecutionError(f"The maximum page value for Walmart search is 100, got {page}.") - - sort_by_value = sort_by.to_api_value() - - params = prepare_params( - "walmart", - query=keywords, - sort=sort_by_value, - # When the user selects a sorting option, we have to disable the relevance sorting - # using the soft_sort parameter. - soft_sort=not sort_by_value, - min_price=min_price, - max_price=max_price, - nd_en=next_day_delivery, - page=page, - include_filters=False, - ) - - response = call_serpapi(context, params) - - return { - "products": extract_walmart_results(response.get("organic_results", [])), - "current_page": page, - "last_available_page": get_walmart_last_page_integer(response), - } - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_walmart_product_details( - context: ToolContext, - item_id: Annotated[ - str, - "Item ID. E.g. '414600577'. This can be retrieved from the search results of the " - f"{search_walmart_products.__tool_name__} tool.", - ], -) -> Annotated[dict[str, Any], "Product details"]: - """Get product details from Walmart.""" - params = prepare_params("walmart_product", product_id=item_id) - response = call_serpapi(context, params) - - product_result = response.get("product_result") - - if not product_result: - return { - "product_details": None, - "message": f"No product details found for item ID '{item_id}'.", - } - - return {"product_details": extract_walmart_product_details(product_result)} diff --git a/toolkits/search/arcade_search/tools/youtube.py b/toolkits/search/arcade_search/tools/youtube.py deleted file mode 100644 index 9144b47b..00000000 --- a/toolkits/search/arcade_search/tools/youtube.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import ToolExecutionError - -from arcade_search.constants import DEFAULT_YOUTUBE_SEARCH_COUNTRY, DEFAULT_YOUTUBE_SEARCH_LANGUAGE -from arcade_search.utils import ( - call_serpapi, - default_country_code, - default_language_code, - extract_video_details, - extract_video_results, - prepare_params, - resolve_country_code, - resolve_language_code, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_youtube_videos( - context: ToolContext, - keywords: Annotated[ - str, - "The keywords to search for. E.g. 'Python tutorial'.", - ], - language_code: Annotated[ - str | None, - "2-character language code to search for. E.g. 'en' for English. " - f"Defaults to '{default_language_code(DEFAULT_YOUTUBE_SEARCH_LANGUAGE)}'.", - ] = None, - country_code: Annotated[ - str | None, - "2-character country code to search for. E.g. 'us' for United States. " - f"Defaults to '{default_country_code(DEFAULT_YOUTUBE_SEARCH_COUNTRY)}'.", - ] = None, - next_page_token: Annotated[ - str | None, - "The next page token to use for pagination. " - "Defaults to `None` (start from the first page).", - ] = None, -) -> Annotated[dict[str, Any], "List of YouTube videos related to the query."]: - """Search for YouTube videos related to the query.""" - language_code = resolve_language_code(language_code, DEFAULT_YOUTUBE_SEARCH_LANGUAGE) - country_code = resolve_country_code(country_code, DEFAULT_YOUTUBE_SEARCH_COUNTRY) - - params = prepare_params( - "youtube", - search_query=keywords, - hl=language_code, - gl=country_code, - sp=next_page_token, - ) - results = call_serpapi(context, params) - - if results.get("error"): - error_msg = cast(str, results.get("error")) - raise ToolExecutionError(error_msg) - - return { - "videos": extract_video_results(results), - "next_page_token": results.get("serpapi_pagination", {}).get("next_page_token"), - } - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_youtube_video_details( - context: ToolContext, - video_id: Annotated[ - str, - "The ID of the YouTube video to get details about. E.g. 'dQw4w9WgXcQ'.", - ], - language_code: Annotated[ - str | None, - "2-character language code to search for. E.g. 'en' for English. " - f"Defaults to '{default_language_code(DEFAULT_YOUTUBE_SEARCH_LANGUAGE)}'.", - ] = None, - country_code: Annotated[ - str | None, - "2-character country code to search for. E.g. 'us' for United States. " - f"Defaults to '{default_country_code(DEFAULT_YOUTUBE_SEARCH_COUNTRY)}'.", - ] = None, -) -> Annotated[dict[str, Any], "Details about a YouTube video."]: - """Get details about a YouTube video.""" - language_code = resolve_language_code(language_code, DEFAULT_YOUTUBE_SEARCH_LANGUAGE) - country_code = resolve_country_code(country_code, DEFAULT_YOUTUBE_SEARCH_COUNTRY) - - params = prepare_params( - "youtube_video", - v=video_id, - hl=language_code, - gl=country_code, - ) - results = call_serpapi(context, params) - - if results.get("error"): - error_msg = cast(str, results.get("error")) - raise ToolExecutionError(error_msg) - - return { - "video": extract_video_details(results), - } diff --git a/toolkits/search/arcade_search/utils.py b/toolkits/search/arcade_search/utils.py deleted file mode 100644 index 92b474bc..00000000 --- a/toolkits/search/arcade_search/utils.py +++ /dev/null @@ -1,441 +0,0 @@ -import contextlib -import re -from datetime import datetime -from typing import Any, cast -from urllib.parse import parse_qs, urlparse -from zoneinfo import ZoneInfo - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - -from arcade_search.constants import ( - DEFAULT_GOOGLE_COUNTRY, - DEFAULT_GOOGLE_LANGUAGE, - DEFAULT_GOOGLE_MAPS_COUNTRY, - DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - DEFAULT_GOOGLE_MAPS_LANGUAGE, - DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - YOUTUBE_MAX_DESCRIPTION_LENGTH, -) -from arcade_search.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode -from arcade_search.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_search.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -# ------------------------------------------------------------------------------------------------ -# General SerpAPI utils -# ------------------------------------------------------------------------------------------------ -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -# ------------------------------------------------------------------------------------------------ -# Google general utils -# ------------------------------------------------------------------------------------------------ -def default_language_code(default_service_language_code: str | None = None) -> str | None: - if isinstance(default_service_language_code, str): - return default_service_language_code.lower() - elif isinstance(DEFAULT_GOOGLE_LANGUAGE, str): - return DEFAULT_GOOGLE_LANGUAGE.lower() - return None - - -def default_country_code(default_service_country_code: str | None = None) -> str | None: - if isinstance(default_service_country_code, str): - return default_service_country_code.lower() - elif isinstance(DEFAULT_GOOGLE_COUNTRY, str): - return DEFAULT_GOOGLE_COUNTRY.lower() - return None - - -def resolve_language_code( - language_code: str | None = None, - default_service_language_code: str | None = None, -) -> str | None: - language_code = language_code or default_language_code(default_service_language_code) - - if isinstance(language_code, str): - language_code = language_code.lower() - if language_code not in LANGUAGE_CODES: - raise LanguageNotFoundError(language_code) - - return language_code - - -def resolve_country_code( - country_code: str | None = None, - default_service_country_code: str | None = None, -) -> str | None: - country_code = country_code or default_country_code(default_service_country_code) - - if isinstance(country_code, str): - country_code = country_code.lower() - if country_code not in COUNTRY_CODES: - raise CountryNotFoundError(country_code) - - return country_code - - -# ------------------------------------------------------------------------------------------------ -# Google Maps utils -# ------------------------------------------------------------------------------------------------ -def get_google_maps_directions( - context: ToolContext, - origin_address: str | None = None, - destination_address: str | None = None, - origin_latitude: str | None = None, - origin_longitude: str | None = None, - destination_latitude: str | None = None, - destination_longitude: str | None = None, - language: str | None = DEFAULT_GOOGLE_MAPS_LANGUAGE, - country: str | None = DEFAULT_GOOGLE_MAPS_COUNTRY, - distance_unit: GoogleMapsDistanceUnit = DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - travel_mode: GoogleMapsTravelMode = DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -> list[dict[str, Any]]: - """Get directions from Google Maps. - - Provide either all(origin_address, destination_address) or - all(origin_latitude, origin_longitude, destination_latitude, destination_longitude). - - Args: - context: Tool context containing required Serp API Key secret. - origin_address: Origin address. - destination_address: Destination address. - origin_latitude: Origin latitude. - origin_longitude: Origin longitude. - destination_latitude: Destination latitude. - destination_longitude: Destination longitude. - language: Language to use in the Google Maps search. Defaults to 'en' (English). - country: 2-letter country code to use in the Google Maps search. Defaults to None - (no country is specified). - distance_unit: Distance unit to use in the Google Maps search. Defaults to 'km' - (kilometers). - travel_mode: Travel mode to use in the Google Maps search. Defaults to 'best' - (best mode). - - Returns: - The directions from Google Maps. - """ - if isinstance(language, str): - language = language.lower() - - if language not in LANGUAGE_CODES: - raise LanguageNotFoundError(language) - - params = prepare_params( - engine="google_maps_directions", - hl=language, - distance_unit=distance_unit.to_api_value(), - travel_mode=travel_mode.to_api_value(), - ) - - if any([ - origin_latitude, - origin_longitude, - destination_latitude, - destination_longitude, - ]) and any([origin_address, destination_address]): - raise ValueError("Either coordinates or addresses must be provided, not both") - - elif all([origin_latitude, origin_longitude, destination_latitude, destination_longitude]): - params["start_coords"] = f"{origin_latitude},{origin_longitude}" - params["end_coords"] = f"{destination_latitude},{destination_longitude}" - - elif all([origin_address, destination_address]): - params["start_addr"] = str(origin_address) - params["end_addr"] = str(destination_address) - - else: - raise ValueError("Either coordinates or addresses must be provided") - - if country: - country = country.lower() - if country not in COUNTRY_CODES: - raise CountryNotFoundError(country) - params["gl"] = country - - results = call_serpapi(context, params) - - directions = cast(list[dict[str, Any]], results.get("directions", [])) - - for direction in directions: - clean_google_maps_direction(direction) - - if "arrive_around" in direction: - direction["arrive_around"] = enrich_google_maps_arrive_around( - direction["arrive_around"] - ) - - return directions - - -def clean_google_maps_direction(direction: dict[str, Any]) -> None: - for trip in direction.get("trips", []): - with contextlib.suppress(KeyError): - del trip["start_stop"]["data_id"] - del trip["end_stop"]["data_id"] - - for detail in trip.get("details", []): - with contextlib.suppress(KeyError): - del detail["geo_photo"] - del detail["gps_coordinates"] - - for stop in trip.get("stops", []): - with contextlib.suppress(KeyError): - del stop["data_id"] - - -def enrich_google_maps_arrive_around(timestamp: int | None) -> dict[str, Any]: - if not timestamp: - return {} - - dt = datetime.fromtimestamp(timestamp, tz=ZoneInfo("UTC")).isoformat() - return {"datetime": dt, "timestamp": timestamp} - - -# ------------------------------------------------------------------------------------------------ -# Google Flights utils -# ------------------------------------------------------------------------------------------------ -def parse_flight_results(results: dict[str, Any]) -> dict[str, Any]: - """Parse the flight results from the Google Flights API - - Note: Best flights is not always returned from the API. - """ - flight_data = {} - flights = [] - - if "best_flights" in results: - flights.extend(results["best_flights"]) - if "other_flights" in results: - flights.extend(results["other_flights"]) - if "price_insights" in results: - flight_data["price_insights"] = results["price_insights"] - - flight_data["flights"] = flights - - return flight_data - - -# ------------------------------------------------------------------------------------------------ -# Google News utils -# ------------------------------------------------------------------------------------------------ -def extract_news_results(results: dict[str, Any], limit: int | None = None) -> list[dict[str, Any]]: - news_results = [] - for result in results.get("news_results", []): - news_results.append({ - "title": result.get("title"), - "snippet": result.get("snippet"), - "link": result.get("link"), - "date": result.get("date"), - "source": result.get("source", {}).get("name"), - }) - - if limit: - return news_results[:limit] - return news_results - - -# ------------------------------------------------------------------------------------------------ -# Google Shopping utils -# ------------------------------------------------------------------------------------------------ -def extract_shopping_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - { - "title": result.get("title"), - "direct_link": result.get("link"), - "google_link": result.get("product_link"), - "source": result.get("source"), - "price": result.get("price"), - "product_rating": result.get("rating"), - "product_reviews": result.get("reviews"), - "store_rating": result.get("store_rating"), - "store_reviews": result.get("store_reviews"), - "delivery": result.get("delivery"), - } - for result in results - ] - - -# ------------------------------------------------------------------------------------------------ -# Walmart utils -# ------------------------------------------------------------------------------------------------ -def extract_walmart_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - { - "item_id": result.get("us_item_id"), - "title": result.get("title"), - "description": result.get("description"), - "rating": result.get("rating"), - "reviews_count": result.get("reviews"), - "seller": { - "id": result.get("seller_id"), - "name": result.get("seller_name"), - }, - "price": { - "value": result.get("primary_offer", {}).get("offer_price"), - "currency": result.get("primary_offer", {}).get("offer_currency"), - }, - "link": result.get("product_page_url"), - } - for result in results - ] - - -def get_walmart_last_page_integer(results: dict[str, Any]) -> int: - try: - return int(list(results["pagination"]["other_pages"].keys())[-1]) - except (KeyError, IndexError, ValueError): - return 1 - - -def extract_walmart_product_details(product: dict[str, Any]) -> dict[str, Any]: - return { - "item_id": product.get("us_item_id"), - "product_type": product.get("product_type"), - "title": product.get("title"), - "description_html": product.get("short_description_html"), - "rating": product.get("rating"), - "reviews_count": product.get("reviews"), - "seller": { - "id": product.get("seller_id"), - "name": product.get("seller_name"), - }, - "manufacturer_name": product.get("manufacturer"), - "price": { - "value": product.get("price_map", {}).get("price"), - "currency": product.get("price_map", {}).get("currency"), - "previous_price": product.get("price_map", {}).get("was_price", {}).get("price"), - }, - "link": product.get("product_page_url"), - "variant_options": extract_walmart_variant_options(product.get("variant_swatches", [])), - } - - -def extract_walmart_variant_options(variant_swatches: list[dict[str, Any]]) -> list[dict[str, Any]]: - variants = [] - - for variant_swatch in variant_swatches: - variant_name = variant_swatch.get("name") - if not variant_name: - continue - - options = [] - - for selection in variant_swatch.get("available_selections", []): - selection_name = selection.get("name") - if selection_name and selection_name not in options: - options.append(selection_name) - - variants.append({variant_name: options}) - - return variants - - -# ------------------------------------------------------------------------------------------------ -# YouTube utils -# ------------------------------------------------------------------------------------------------ -def extract_video_id_from_link(link: str | None) -> str | None: - if not isinstance(link, str): - return None - - parsed_url = urlparse(link) - query_params = parse_qs(parsed_url.query) - return query_params.get("v", [""])[0] - - -def extract_video_description( - video: dict[str, Any], - max_description_length: int = YOUTUBE_MAX_DESCRIPTION_LENGTH, -) -> str | None: - description = video.get("description", "") - - if isinstance(description, dict): - description = description.get("content", "") - - if isinstance(description, str): - too_long = len(description) > max_description_length - if too_long: - description = description[:max_description_length] + " [truncated]" - - if description is not None: - description = str(description).strip() - - return cast(str | None, description) - - -def extract_video_results( - results: dict[str, Any], - max_description_length: int = YOUTUBE_MAX_DESCRIPTION_LENGTH, -) -> list[dict[str, Any]]: - videos = [] - - for video in results.get("video_results", []): - videos.append({ - "id": extract_video_id_from_link(video.get("link")), - "title": video.get("title"), - "description": extract_video_description(video, max_description_length), - "link": video.get("link"), - "published_date": video.get("published_date"), - "duration": video.get("duration"), - "channel": { - "name": video.get("channel", {}).get("name"), - "link": video.get("channel", {}).get("link"), - }, - }) - - return videos - - -def extract_video_details(video: dict[str, Any]) -> dict[str, Any]: - return { - "id": extract_video_id_from_link(video.get("link")), - "title": video.get("title"), - "description": extract_video_description(video, YOUTUBE_MAX_DESCRIPTION_LENGTH), - "published_date": video.get("published_date"), - "channel": { - "name": video.get("channel", {}).get("name"), - "link": video.get("channel", {}).get("link"), - }, - "like_count": video.get("extracted_likes"), - "view_count": video.get("extracted_views"), - "live": video.get("live", False), - } diff --git a/toolkits/search/conftest.py b/toolkits/search/conftest.py deleted file mode 100644 index 2903cc30..00000000 --- a/toolkits/search/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - - -class DummyContext: - def get_secret(self, key: str) -> str | None: - if key.lower() == "serp_api_key": - return "dummy_key" - return None - - -@pytest.fixture -def dummy_context(): - return DummyContext() diff --git a/toolkits/search/evals/eval_google_jobs.py b/toolkits/search/evals/eval_google_jobs.py deleted file mode 100644 index 2be23fca..00000000 --- a/toolkits/search/evals/eval_google_jobs.py +++ /dev/null @@ -1,157 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NoneCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_search -from arcade_search.constants import DEFAULT_GOOGLE_JOBS_LANGUAGE -from arcade_search.tools.google_jobs import search_jobs - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the Google Jobs tool -catalog.add_module(arcade_search) - - -@tool_eval() -def google_jobs_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Jobs tool.""" - suite = EvalSuite( - name="Google Jobs Tool Evaluation", - system_message="You are an AI assistant that can perform job searches using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search for 'backend engineer' jobs", - user_message="Search for 'backend engineer' jobs", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "backend engineer", - "location": None, - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.5), - NoneCritic(critic_field="location", weight=0.1), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - NoneCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Search for 'senior backend engineer' jobs that are part-time", - user_message="Search for senior backend engineer jobs that are part-time", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "part-time senior backend engineer", - "location": None, - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.5), - NoneCritic(critic_field="location", weight=0.1), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - NoneCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Search for 'backend engineer' jobs in San Francisco", - user_message="Search for 'backend engineer' jobs in San Francisco", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "backend engineer", - "location": "San Francisco", - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.35), - SimilarityCritic(critic_field="location", weight=0.35), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - NoneCritic(critic_field="next_page_token", weight=0.1), - ], - ) - - suite.add_case( - name="Get the first 3 jobs for 'backend engineer' in San Francisco", - user_message="Get the first 3 jobs for 'backend engineer' in San Francisco", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "backend engineer", - "location": "San Francisco", - "language": DEFAULT_GOOGLE_JOBS_LANGUAGE, - "limit": 3, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.25), - SimilarityCritic(critic_field="location", weight=0.25), - BinaryCritic(critic_field="language", weight=0.125), - BinaryCritic(critic_field="limit", weight=0.25), - NoneCritic(critic_field="next_page_token", weight=0.125), - ], - ) - - suite.add_case( - name="Search for 'engenheiro de software' jobs in Brazil, return results in Portuguese", - user_message="Search for 'engenheiro de software' jobs in Brazil, return results in Portuguese", - expected_tool_calls=[ - ExpectedToolCall( - func=search_jobs, - args={ - "query": "engenheiro de software", - "location": "Brazil", - "language": "pt", - "limit": 10, - "next_page_token": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.25), - SimilarityCritic(critic_field="location", weight=0.125), - BinaryCritic(critic_field="language", weight=0.25), - BinaryCritic(critic_field="limit", weight=0.125), - NoneCritic(critic_field="next_page_token", weight=0.125), - ], - ) - - return suite diff --git a/toolkits/search/evals/eval_google_maps_directions.py b/toolkits/search/evals/eval_google_maps_directions.py deleted file mode 100644 index c36db5ed..00000000 --- a/toolkits/search/evals/eval_google_maps_directions.py +++ /dev/null @@ -1,226 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_search -from arcade_search.constants import ( - DEFAULT_GOOGLE_MAPS_COUNTRY, - DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - DEFAULT_GOOGLE_MAPS_LANGUAGE, - DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, -) -from arcade_search.enums import GoogleMapsDistanceUnit, GoogleMapsTravelMode -from arcade_search.tools.google_maps import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the Google Search tool -catalog.add_module(arcade_search) - - -@tool_eval() -def google_maps_directions_by_addresses_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Maps Directions tools.""" - suite = EvalSuite( - name="Google Maps Directions Tool Evaluation", - system_message="You are an AI assistant that can get directions from Google Maps using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get directions between two addresses", - user_message="Get directions from Google Maps between the following addresses: 1600 Amphitheatre Parkway, Mountain View, CA 94043 and 1 Infinite Loop, Cupertino, CA 95014.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_addresses, - args={ - "origin_address": "1600 Amphitheatre Parkway, Mountain View, CA 94043", - "destination_address": "1 Infinite Loop, Cupertino, CA 95014", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_address", weight=0.3), - SimilarityCritic(critic_field="destination_address", weight=0.3), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two addresses with custom distance unit and travel mode", - user_message="Get walking directions from Google Maps between the following addresses in miles: 1600 Amphitheatre Parkway, Mountain View, CA 94043 and 1 Infinite Loop, Cupertino, CA 95014.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_addresses, - args={ - "origin_address": "1600 Amphitheatre Parkway, Mountain View, CA 94043", - "destination_address": "1 Infinite Loop, Cupertino, CA 95014", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": GoogleMapsDistanceUnit.MILES.value, - "travel_mode": GoogleMapsTravelMode.WALKING.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_address", weight=0.3), - SimilarityCritic(critic_field="destination_address", weight=0.3), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two addresses in a given country and language", - user_message="Get directions from Google Maps in Portuguese between the following addresses in Brazil: Rua do Amendoim, 1, Belo Horizonte, MG and Av. do Descobrimento, 515, Porto Seguro, BA.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_addresses, - args={ - "origin_address": "Rua do Amendoim, 1, Belo Horizonte, MG", - "destination_address": "Av. do Descobrimento, 515, Porto Seguro, BA", - "language": "pt", - "country": "br", - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_address", weight=0.3), - SimilarityCritic(critic_field="destination_address", weight=0.3), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - return suite - - -@tool_eval() -def google_maps_directions_by_coordinates_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Maps Directions tools.""" - suite = EvalSuite( - name="Google Maps Directions Tool Evaluation", - system_message="You are an AI assistant that can get directions from Google Maps using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get directions between two coordinates", - user_message="Get directions from Google Maps between the following coordinates: 37.422740,-122.084961 and 37.331820,-122.031180.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_coordinates, - args={ - "origin_latitude": "37.422740", - "origin_longitude": "-122.084961", - "destination_latitude": "37.331820", - "destination_longitude": "-122.031180", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_latitude", weight=0.15), - SimilarityCritic(critic_field="origin_longitude", weight=0.15), - SimilarityCritic(critic_field="destination_latitude", weight=0.15), - SimilarityCritic(critic_field="destination_longitude", weight=0.15), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two coordinates with custom distance unit and travel mode", - user_message="Get walking directions from Google Maps between the following coordinates in miles: 37.422740,-122.084961 and 37.331820,-122.031180.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_coordinates, - args={ - "origin_latitude": "37.422740", - "origin_longitude": "-122.084961", - "destination_latitude": "37.331820", - "destination_longitude": "-122.031180", - "language": DEFAULT_GOOGLE_MAPS_LANGUAGE, - "country": DEFAULT_GOOGLE_MAPS_COUNTRY, - "distance_unit": GoogleMapsDistanceUnit.MILES.value, - "travel_mode": GoogleMapsTravelMode.WALKING.value, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_latitude", weight=0.15), - SimilarityCritic(critic_field="origin_longitude", weight=0.15), - SimilarityCritic(critic_field="destination_latitude", weight=0.15), - SimilarityCritic(critic_field="destination_longitude", weight=0.15), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - suite.add_case( - name="Get directions between two coordinates in a given country and language", - user_message="Get directions from Google Maps in Portuguese between the following coordinates in Brazil: 37.422740,-122.084961 and 37.331820,-122.031180.", - expected_tool_calls=[ - ExpectedToolCall( - func=get_directions_between_coordinates, - args={ - "origin_latitude": "37.422740", - "origin_longitude": "-122.084961", - "destination_latitude": "37.331820", - "destination_longitude": "-122.031180", - "language": "pt", - "country": "br", - "distance_unit": DEFAULT_GOOGLE_MAPS_DISTANCE_UNIT, - "travel_mode": DEFAULT_GOOGLE_MAPS_TRAVEL_MODE, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="origin_latitude", weight=0.15), - SimilarityCritic(critic_field="origin_longitude", weight=0.15), - SimilarityCritic(critic_field="destination_latitude", weight=0.15), - SimilarityCritic(critic_field="destination_longitude", weight=0.15), - BinaryCritic(critic_field="language", weight=0.1), - BinaryCritic(critic_field="country", weight=0.1), - BinaryCritic(critic_field="distance_unit", weight=0.1), - BinaryCritic(critic_field="travel_mode", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/search/evals/eval_google_search.py b/toolkits/search/evals/eval_google_search.py deleted file mode 100644 index 062d04da..00000000 --- a/toolkits/search/evals/eval_google_search.py +++ /dev/null @@ -1,240 +0,0 @@ -from arcade_evals import ( - EvalRubric, - EvalSuite, - ExpectedToolCall, - NumericCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_search -from arcade_search.tools import search_google - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the Google Search tool -catalog.add_module(arcade_search) - - -@tool_eval() -def google_search_eval_suite() -> EvalSuite: - """Create an evaluation suite for the Google Search tool.""" - suite = EvalSuite( - name="Google Search Tool Evaluation", - system_message="You are an AI assistant that can perform web searches using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Simple search query with default results - suite.add_case( - name="Simple search query with default results", - user_message="Search for 'Climate change effects on polar bears' on Google.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "Climate change effects on polar bears", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with specific number of results - suite.add_case( - name="Search query with specific number of results", - user_message="Find the top 3 articles about quantum computing.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "articles about quantum computing", - "n_results": 3, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - NumericCritic( - critic_field="n_results", - weight=0.3, - value_range=(1, 100), - ), - ], - ) - - # Search query with 'n' results specified in words - suite.add_case( - name="Search query with 'n' results specified in words", - user_message="Give me five recipes for vegan lasagna.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "recipes for vegan lasagna", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - NumericCritic( - critic_field="n_results", - weight=0.3, - value_range=(1, 100), - ), - ], - ) - - # Ambiguous number of results - suite.add_case( - name="Ambiguous number of results", - user_message="Find articles about climate change impacts 10.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "articles about climate change impacts 10", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with multiple instructions - suite.add_case( - name="Search query with multiple instructions", - user_message="Search for the latest news on electric cars, and tell me about Tesla's new model.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "latest news on electric cars", - "n_results": 5, - }, - ), - ExpectedToolCall( - func=search_google, - args={ - "query": "Tesla's new model", - "n_results": 5, - }, - ), - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search with stop words and filler words - suite.add_case( - name="Search with stop words and filler words", - user_message="Could you please search for the best ways to learn French?", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "best ways to learn French", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # No clear query given - suite.add_case( - name="No clear query given", - user_message="Find it for me.", - expected_tool_calls=[], - critics=[], - ) - - # Search query with special characters - suite.add_case( - name="Search query with special characters", - user_message="Find me '@OpenAI's latest research papers'", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "@OpenAI's latest research papers", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with complex instructions - suite.add_case( - name="Search query with complex instructions", - user_message="I need information about the impact of deforestation in the Amazon over the past decade.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "impact of deforestation in the Amazon over the past decade", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query in a different language - suite.add_case( - name="Search query in a different language", - user_message="Busca información sobre la economía de España.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "economía de España", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - # Search query with numeric data - suite.add_case( - name="Search query with numeric data", - user_message="What was the population of Japan in 2020?", - expected_tool_calls=[ - ExpectedToolCall( - func=search_google, - args={ - "query": "population of Japan in 2020", - "n_results": 5, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/search/pyproject.toml b/toolkits/search/pyproject.toml deleted file mode 100644 index 80a632c2..00000000 --- a/toolkits/search/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_search" -version = "1.4.3" -description = "Arcade.dev LLM tools for searching the web" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "serpapi>=0.1.5,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_search/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_search",] diff --git a/toolkits/search/tests/__init__.py b/toolkits/search/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/search/tests/test_google_jobs.py b/toolkits/search/tests/test_google_jobs.py deleted file mode 100644 index e75443df..00000000 --- a/toolkits/search/tests/test_google_jobs.py +++ /dev/null @@ -1,90 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem - -from arcade_search.exceptions import LanguageNotFoundError -from arcade_search.tools.google_jobs import search_jobs - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="serp_api_key", value="fake_api_key")]) - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_search_jobs_success(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search().as_dict.return_value = { - "jobs_results": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ] - } - - result = await search_jobs(mock_context, "engenheiro de software", "Brazil", "pt", 10, None) - assert result == { - "jobs": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ], - "next_page_token": None, - } - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_search_jobs_success_with_custom_language_and_location( - mock_serp_client, mock_context -): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search().as_dict.return_value = { - "jobs_results": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ] - } - - result = await search_jobs( - context=mock_context, - query="engenheiro de software", - location="Brazil", - language="pt", - limit=10, - next_page_token=None, - ) - - mock_serp_client_instance.search.assert_called_with({ - "engine": "google_jobs", - "q": "engenheiro de software", - "hl": "pt", - "location": "Brazil", - }) - - assert result == { - "jobs": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ], - "next_page_token": None, - } - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_search_jobs_language_not_found_error(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search().as_dict.return_value = { - "jobs_results": [ - {"title": "Job 1", "link": "http://example.com/1"}, - {"title": "Job 2", "link": "http://example.com/2"}, - ] - } - - with pytest.raises(LanguageNotFoundError): - await search_jobs( - context=mock_context, - query="backend engineer", - language="invalid_language", - ) diff --git a/toolkits/search/tests/test_google_maps_directions.py b/toolkits/search/tests/test_google_maps_directions.py deleted file mode 100644 index 8a900304..00000000 --- a/toolkits/search/tests/test_google_maps_directions.py +++ /dev/null @@ -1,131 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem - -from arcade_search.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_search.tools.google_maps import ( - get_directions_between_addresses, - get_directions_between_coordinates, -) - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="serp_api_key", value="fake_api_key")]) - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_get_directions_between_coordinates_success(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - result = await get_directions_between_coordinates( - context=mock_context, - origin_latitude="1", - origin_longitude="2", - destination_latitude="3", - destination_longitude="4", - ) - - assert result == { - "directions": [ - { - "arrive_around": { - "datetime": "2025-03-12T14:30:39+00:00", - "timestamp": 1741789839, - }, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_get_directions_between_addresses_success(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - result = await get_directions_between_addresses( - context=mock_context, - origin_address="1", - destination_address="2", - ) - - assert result == { - "directions": [ - { - "arrive_around": { - "datetime": "2025-03-12T14:30:39+00:00", - "timestamp": 1741789839, - }, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_get_directions_between_addresses_country_not_found(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - with pytest.raises(CountryNotFoundError): - await get_directions_between_addresses( - context=mock_context, - origin_address="1", - destination_address="2", - country="invalid", - ) - - -@pytest.mark.asyncio -@patch("arcade_search.utils.SerpClient") -async def test_get_directions_between_addresses_language_not_found(mock_serp_client, mock_context): - mock_serp_client_instance = mock_serp_client.return_value - mock_serp_client_instance.search.return_value.as_dict.return_value = { - "directions": [ - { - "arrive_around": 1741789839, - "distance": "100 miles", - "duration": "1 hour", - } - ] - } - - with pytest.raises(LanguageNotFoundError): - await get_directions_between_addresses( - context=mock_context, - origin_address="1", - destination_address="2", - language="invalid", - ) diff --git a/toolkits/search/tests/test_google_search.py b/toolkits/search/tests/test_google_search.py deleted file mode 100644 index 21b4832a..00000000 --- a/toolkits/search/tests/test_google_search.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem - -from arcade_search.tools import search_google - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="serp_api_key", value="fake_api_key")]) - - -@pytest.mark.asyncio -async def test_search_google_success(mock_context): - with ( - patch("arcade_search.utils.SerpClient") as MockClient, - ): - mock_client_instance = MockClient.return_value - mock_client_instance.search.return_value.as_dict.return_value = { - "organic_results": [ - {"title": "Result 1", "link": "http://example.com/1"}, - {"title": "Result 2", "link": "http://example.com/2"}, - {"title": "Result 3", "link": "http://example.com/3"}, - ] - } - - result = await search_google(mock_context, "test query", 2) - - expected_result = json.dumps([ - {"title": "Result 1", "link": "http://example.com/1"}, - {"title": "Result 2", "link": "http://example.com/2"}, - ]) - assert result == expected_result - - -@pytest.mark.asyncio -async def test_search_google_no_results(mock_context): - with ( - patch("arcade_search.utils.SerpClient") as MockClient, - ): - mock_client_instance = MockClient.return_value - mock_client_instance.search.return_value.as_dict.return_value = {"organic_results": []} - - result = await search_google(mock_context, "test query", 2) - - expected_result = json.dumps([]) - assert result == expected_result diff --git a/toolkits/search/tests/test_utils.py b/toolkits/search/tests/test_utils.py deleted file mode 100644 index fbd0e1f3..00000000 --- a/toolkits/search/tests/test_utils.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -import serpapi -from arcade_tdk.errors import ToolExecutionError - -from arcade_search.utils import call_serpapi, prepare_params - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "engine, kwargs, expected", - [ - ("google", {}, {"engine": "google"}), - ( - "google", - {"q": "test", "window": 10, "time": "00:12:12"}, - { - "engine": "google", - "q": "test", - "window": 10, - "time": "00:12:12", - }, - ), - ], -) -async def test_prepare_params(engine, kwargs, expected): - params = prepare_params(engine, **kwargs) - assert params == expected - - -@pytest.mark.parametrize( - "error_message, sanitized_message", - [ - ( - "You hit your rate limit", - "You hit your rate limit", - ), - ( - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=ABC123456", - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=***", - ), - ( - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=ABC123456 make sure the api key is correct", - "Bad Request for url: https://serpapi.com/search?engine=google_hotels&api_key=*** make sure the api key is correct", - ), - ], -) -def test_call_serpapi_failure(monkeypatch, dummy_context, error_message, sanitized_message): - def fake_serpapi_search(self, params: dict) -> dict: - raise Exception(error_message) # noqa: TRY002 - - monkeypatch.setattr(serpapi.Client, "search", fake_serpapi_search) - - with pytest.raises(ToolExecutionError) as excinfo: - call_serpapi(dummy_context, {}) - - assert excinfo.value.developer_message == sanitized_message diff --git a/toolkits/slack/.pre-commit-config.yaml b/toolkits/slack/.pre-commit-config.yaml deleted file mode 100644 index c1eb7826..00000000 --- a/toolkits/slack/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/slack/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/slack/.ruff.toml b/toolkits/slack/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/slack/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/slack/LICENSE b/toolkits/slack/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/slack/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/slack/Makefile b/toolkits/slack/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/slack/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/slack/arcade_slack/__init__.py b/toolkits/slack/arcade_slack/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/slack/arcade_slack/constants.py b/toolkits/slack/arcade_slack/constants.py deleted file mode 100644 index bed2bb03..00000000 --- a/toolkits/slack/arcade_slack/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -import os - -from arcade_slack.custom_types import PositiveNonZeroInt - -MAX_PAGINATION_SIZE_LIMIT = 200 - -MAX_PAGINATION_TIMEOUT_SECONDS = PositiveNonZeroInt( - os.environ.get( - "MAX_PAGINATION_TIMEOUT_SECONDS", - os.environ.get("MAX_SLACK_PAGINATION_TIMEOUT_SECONDS", 30), - ), - name="MAX_PAGINATION_TIMEOUT_SECONDS or MAX_SLACK_PAGINATION_TIMEOUT_SECONDS", -) - -MAX_CONCURRENT_REQUESTS = PositiveNonZeroInt( - os.environ.get("SLACK_MAX_CONCURRENT_REQUESTS", 3), - name="SLACK_MAX_CONCURRENT_REQUESTS", -) diff --git a/toolkits/slack/arcade_slack/conversation_retrieval.py b/toolkits/slack/arcade_slack/conversation_retrieval.py deleted file mode 100644 index 1a32fa02..00000000 --- a/toolkits/slack/arcade_slack/conversation_retrieval.py +++ /dev/null @@ -1,74 +0,0 @@ -import json -from typing import cast - -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from slack_sdk.errors import SlackApiError -from slack_sdk.web.async_client import AsyncWebClient - -from arcade_slack.models import ( - ConversationType, - FindChannelByNameSentinel, -) -from arcade_slack.utils import ( - async_paginate, - extract_conversation_metadata, -) - - -async def get_conversation_by_id( - auth_token: str, - conversation_id: str, -) -> dict: - """Get metadata of a conversation in Slack by the conversation_id.""" - try: - slack_client = AsyncWebClient(token=auth_token) - response = await slack_client.conversations_info( - channel=conversation_id, - include_locale=True, - include_num_members=True, - ) - return dict(**extract_conversation_metadata(response["channel"])) - - except SlackApiError as e: - slack_error = cast(str, e.response.get("error", "")) - if "not_found" in slack_error.lower(): - message = f"Conversation with ID '{conversation_id}' not found." - raise ToolExecutionError(message=message, developer_message=message) - raise - - -async def get_channel_by_name( - auth_token: str, - channel_name: str, -) -> dict: - channel_name_casefolded = channel_name.lstrip("#").casefold() - - slack_client = AsyncWebClient(token=auth_token) - - results, _ = await async_paginate( - func=slack_client.conversations_list, - response_key="channels", - types=",".join([ - ConversationType.PUBLIC_CHANNEL.value, - ConversationType.PRIVATE_CHANNEL.value, - ]), - exclude_archived=True, - sentinel=FindChannelByNameSentinel(channel_name_casefolded), - ) - - available_channels = [] - - for channel in results: - if channel["name"].casefold() == channel_name_casefolded: - return dict(**extract_conversation_metadata(channel)) - else: - available_channels.append({"id": channel["id"], "name": channel["name"]}) - - error_message = f"Channel with name '{channel_name}' not found." - - raise RetryableToolError( - message=error_message, - developer_message=error_message, - additional_prompt_content=f"Available channels: {json.dumps(available_channels)}", - retry_after_ms=500, - ) diff --git a/toolkits/slack/arcade_slack/critics.py b/toolkits/slack/arcade_slack/critics.py deleted file mode 100644 index e6248789..00000000 --- a/toolkits/slack/arcade_slack/critics.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Any - -from arcade_evals import BinaryCritic - - -class RelativeTimeBinaryCritic(BinaryCritic): - def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]: - """ - Evaluates whether the expected and actual relative time strings are equivalent after - casting. - - Args: - expected: The expected value. - actual: The actual value to compare, cast to the type of expected. - - Returns: - dict: A dictionary containing the match status and score. - """ - try: - actual_casted = self.cast_actual(expected, actual) - except TypeError: - actual_casted = actual - - expected_parts = tuple(map(int, expected.split(":"))) - actual_parts = tuple(map(int, actual_casted.split(":"))) - - if len(expected_parts) != 3 or len(actual_parts) != 3: - return {"match": False, "score": 0.0} - - exp_days, exp_hours, exp_minutes = expected_parts - act_days, act_hours, act_minutes = actual_parts - - match = exp_days == act_days and exp_hours == act_hours and exp_minutes == act_minutes - return {"match": match, "score": self.weight if match else 0.0} diff --git a/toolkits/slack/arcade_slack/custom_types.py b/toolkits/slack/arcade_slack/custom_types.py deleted file mode 100644 index 1c7005a5..00000000 --- a/toolkits/slack/arcade_slack/custom_types.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import NewType - - -class PositiveNonZeroInt(int): - def __new__(cls, value: str | int, name: str = "value") -> "PositiveNonZeroInt": - def validate(val: int) -> int: - if val < 1: - raise ValueError(f"{name} must be a positive non-zero integer, got {val}") - return val - - try: - value = int(value) - except ValueError: - raise ValueError(f"{name} must be a valid integer, got {value!r}") - - validated_value = validate(value) - instance = super().__new__(cls, validated_value) - return instance - - -SlackOffsetSecondsFromUTC = NewType("SlackOffsetSecondsFromUTC", int) # observe it can be negative -SlackPaginationNextCursor = str | None -SlackUserFieldId = NewType("SlackUserFieldId", str) -SlackUserId = NewType("SlackUserId", str) -SlackTeamId = NewType("SlackTeamId", str) -SlackTimestampStr = NewType("SlackTimestampStr", str) diff --git a/toolkits/slack/arcade_slack/exceptions.py b/toolkits/slack/arcade_slack/exceptions.py deleted file mode 100644 index 6f7a6b86..00000000 --- a/toolkits/slack/arcade_slack/exceptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class SlackToolkitError(Exception): - """Base class for all Slack toolkit errors.""" - - -class PaginationTimeoutError(SlackToolkitError): - """Raised when a timeout occurs during pagination.""" - - def __init__(self, timeout_seconds: int): - self.timeout_seconds = timeout_seconds - super().__init__(f"The pagination process timed out after {timeout_seconds} seconds.") diff --git a/toolkits/slack/arcade_slack/message_retrieval.py b/toolkits/slack/arcade_slack/message_retrieval.py deleted file mode 100644 index c0b87764..00000000 --- a/toolkits/slack/arcade_slack/message_retrieval.py +++ /dev/null @@ -1,76 +0,0 @@ -from datetime import datetime, timezone -from typing import Any - -from arcade_tdk.errors import ToolExecutionError -from slack_sdk.web.async_client import AsyncWebClient - -from arcade_slack.utils import ( - async_paginate, - convert_datetime_to_unix_timestamp, - convert_relative_datetime_to_unix_timestamp, - enrich_message_datetime, -) - - -async def retrieve_messages_in_conversation( - conversation_id: str, - auth_token: str | None = None, - oldest_relative: str | None = None, - latest_relative: str | None = None, - oldest_datetime: str | None = None, - latest_datetime: str | None = None, - limit: int | None = None, - next_cursor: str | None = None, -) -> dict: - error_message = None - if oldest_datetime and oldest_relative: - error_message = "Cannot specify both 'oldest_datetime' and 'oldest_relative'." - - if latest_datetime and latest_relative: - error_message = "Cannot specify both 'latest_datetime' and 'latest_relative'." - - if error_message: - raise ToolExecutionError(error_message, developer_message=error_message) - - current_unix_timestamp = int(datetime.now(timezone.utc).timestamp()) - - if latest_relative: - latest_timestamp = convert_relative_datetime_to_unix_timestamp( - latest_relative, current_unix_timestamp - ) - elif latest_datetime: - latest_timestamp = convert_datetime_to_unix_timestamp(latest_datetime) - else: - latest_timestamp = None - - if oldest_relative: - oldest_timestamp = convert_relative_datetime_to_unix_timestamp( - oldest_relative, current_unix_timestamp - ) - elif oldest_datetime: - oldest_timestamp = convert_datetime_to_unix_timestamp(oldest_datetime) - else: - oldest_timestamp = None - - datetime_args: dict[str, Any] = {} - if oldest_timestamp: - datetime_args["oldest"] = oldest_timestamp - if latest_timestamp: - datetime_args["latest"] = latest_timestamp - - slackClient = AsyncWebClient(token=auth_token) - - response, next_cursor = await async_paginate( - slackClient.conversations_history, - "messages", - limit=limit, - next_cursor=next_cursor, - channel=conversation_id, - include_all_metadata=True, - inclusive=True, # Include messages at the start and end of the time range - **datetime_args, - ) - - messages = [enrich_message_datetime(message) for message in response] - - return {"messages": messages, "next_cursor": next_cursor} diff --git a/toolkits/slack/arcade_slack/models.py b/toolkits/slack/arcade_slack/models.py deleted file mode 100644 index 180be431..00000000 --- a/toolkits/slack/arcade_slack/models.py +++ /dev/null @@ -1,370 +0,0 @@ -import asyncio -from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable -from contextlib import suppress -from enum import Enum -from typing import Any, Literal, TypedDict - -from arcade_tdk.errors import ToolExecutionError -from slack_sdk.errors import SlackApiError - -from arcade_slack.custom_types import ( - SlackOffsetSecondsFromUTC, - SlackPaginationNextCursor, - SlackTeamId, - SlackTimestampStr, - SlackUserFieldId, - SlackUserId, -) - - -class ConversationTypeSlackName(str, Enum): - PUBLIC_CHANNEL = "public_channel" # Public channels are visible to all users in the workspace - PRIVATE_CHANNEL = "private_channel" # Private channels are visible to only specific users - MPIM = "mpim" # Multi-person direct message conversation - IM = "im" # Two person direct message conversation - - -class ConversationType(str, Enum): - PUBLIC_CHANNEL = "public_channel" - PRIVATE_CHANNEL = "private_channel" - MULTI_PERSON_DIRECT_MESSAGE = "multi_person_direct_message" - DIRECT_MESSAGE = "direct_message" - - def to_slack_name_str(self) -> str: - mapping = { - ConversationType.PUBLIC_CHANNEL: ConversationTypeSlackName.PUBLIC_CHANNEL.value, - ConversationType.PRIVATE_CHANNEL: ConversationTypeSlackName.PRIVATE_CHANNEL.value, - ConversationType.MULTI_PERSON_DIRECT_MESSAGE: ConversationTypeSlackName.MPIM.value, - ConversationType.DIRECT_MESSAGE: ConversationTypeSlackName.IM.value, - } - - return mapping[self] - - -""" -About Slack dictionaries: Slack does not guarantee the presence of all fields for a given -object. It will vary from endpoint to endpoint and even if the field is present, they say it may -contain a None value or an empty string instead of the actual expected value. - -See, for example, the 'Common Fields' section of the user type definition at: -https://api.slack.com/types/user#fields (https://archive.is/RUZdL) - -Because of that, our TypedDicts ended up having to be mostly total=False and most of the fields' -type hints are Optional. Use Slack dictionary fields with caution. It's advisable to validate the -value before using it and raise errors that are clear to understand, when appropriate. -""" - - -class SlackUserFieldData(TypedDict, total=False): - """Type definition for Slack user field data dictionary. - - Slack type definition: https://api.slack.com/methods/users.profile.set#custom-profile - """ - - value: str | None - alt: bool | None - - -class SlackStatusEmojiDisplayInfo(TypedDict, total=False): - """Type definition for Slack status emoji display info dictionary.""" - - emoji_name: str | None - display_url: str | None - - -class SlackUserProfile(TypedDict, total=False): - """Type definition for Slack user profile dictionary. - - Slack type definition: https://api.slack.com/types/user#profile (https://archive.is/RUZdL) - """ - - title: str | None - phone: str | None - skype: str | None - email: str | None - real_name: str | None - real_name_normalized: str | None - display_name: str | None - display_name_normalized: str | None - first_name: str | None - last_name: str | None - fields: list[dict[SlackUserFieldId, SlackUserFieldData]] | None - image_original: str | None - is_custom_image: bool | None - image_24: str | None - image_32: str | None - image_48: str | None - image_72: str | None - image_192: str | None - image_512: str | None - image_1024: str | None - status_emoji: str | None - status_emoji_display_info: list[SlackStatusEmojiDisplayInfo] | None - status_text: str | None - status_text_canonical: str | None - status_expiration: int | None - avatar_hash: str | None - start_date: str | None - pronouns: str | None - huddle_state: str | None - huddle_state_expiration: int | None - team: SlackTeamId | None - - -class SlackUser(TypedDict, total=False): - """Type definition for Slack user dictionary. - - Slack type definition: https://api.slack.com/types/user (https://archive.is/RUZdL) - """ - - id: SlackUserId - team_id: SlackTeamId - name: str | None - deleted: bool | None - color: str | None - real_name: str | None - tz: str | None - tz_label: str | None - tz_offset: SlackOffsetSecondsFromUTC | None - profile: SlackUserProfile | None - is_admin: bool | None - is_owner: bool | None - is_primary_owner: bool | None - is_restricted: bool | None - is_ultra_restricted: bool | None - is_bot: bool | None - is_app_user: bool | None - is_email_confirmed: bool | None - who_can_share_contact_card: str | None - - -class SlackUserList(TypedDict, total=False): - """Type definition for the returned user list dictionary.""" - - members: list[SlackUser] - - -class SlackConversationPurpose(TypedDict, total=False): - """Type definition for the Slack conversation purpose dictionary.""" - - value: str | None - - -class SlackConversation(TypedDict, total=False): - """Type definition for the Slack conversation dictionary.""" - - id: str | None - name: str | None - is_private: bool | None - is_archived: bool | None - is_member: bool | None - is_channel: bool | None - is_group: bool | None - is_im: bool | None - is_mpim: bool | None - purpose: SlackConversationPurpose | None - num_members: int | None - user: SlackUser | None - is_user_deleted: bool | None - - -class SlackMessage(TypedDict, total=True): - """Type definition for the Slack message dictionary.""" - - type: Literal["message"] - user: SlackUser - text: str - ts: SlackTimestampStr # Slack timestamp as a string (e.g. "1234567890.123456") - - -class Message(SlackMessage, total=False): - """Type definition for the message dictionary. - - Having a human-readable datetime string is useful for LLMs when they need to display the - date/time for the user. If not, they'll try to convert the unix timestamp to a human-readable - date/time,which they don't usually do accurately. - """ - - datetime_timestamp: str # Human-readable datetime string (e.g. "2025-01-22 12:00:00") - - -class ConversationMetadata(TypedDict, total=False): - """Type definition for the conversation metadata dictionary.""" - - id: str | None - name: str | None - conversation_type: str | None - is_private: bool | None - is_archived: bool | None - is_member: bool | None - purpose: str | None - num_members: int | None - user: SlackUser | None - is_user_deleted: bool | None - - -class BasicUserInfo(TypedDict, total=False): - """Type definition for the returned basic user info dictionary.""" - - id: str | None - name: str | None - is_bot: bool | None - email: str | None - display_name: str | None - real_name: str | None - timezone: str | None - - -class SlackConversationsToolResponse(TypedDict, total=True): - """Type definition for the Slack conversations tool response dictionary.""" - - conversations: list[ConversationMetadata] - next_cursor: SlackPaginationNextCursor | None - - -class PaginationSentinel(ABC): - """Base class for pagination sentinel classes.""" - - def __init__(self, **kwargs: Any) -> None: - self.kwargs = kwargs - - @abstractmethod - def __call__(self, last_result: Any) -> bool: - """Determine if the pagination should stop.""" - raise NotImplementedError - - -class FindUserByUsernameSentinel(PaginationSentinel): - """Sentinel class for finding a user by username.""" - - def __call__(self, last_result: Any) -> bool: - for user in last_result: - if not isinstance(user.get("name"), str): - continue - if user.get("name").casefold() == self.kwargs["username"].casefold(): - return True - return False - - -class FindMultipleUsersByUsernameSentinel(PaginationSentinel): - """Sentinel class for finding multiple users by username.""" - - def __init__(self, usernames: list[str]) -> None: - if not usernames: - raise ValueError("usernames must be a non-empty list of strings") - super().__init__(usernames=usernames) - self.usernames_pending = {username.casefold() for username in usernames} - - def _flag_username_found(self, username: str) -> None: - with suppress(KeyError): - self.usernames_pending.remove(username.casefold()) - - def _all_usernames_found(self) -> bool: - return not self.usernames_pending - - def __call__(self, last_result: Any) -> bool: - if not self.usernames_pending: - return True - for user in last_result: - username = user.get("name") - if not isinstance(username, str): - continue - if username.casefold() in self.usernames_pending: - self._flag_username_found(username) - if self._all_usernames_found(): - return True - return False - - -class FindMultipleUsersByIdSentinel(PaginationSentinel): - """Sentinel class for finding multiple users by ID.""" - - def __init__(self, user_ids: list[str]) -> None: - if not user_ids: - raise ValueError("user_ids must be a non-empty list of strings") - super().__init__(user_ids=user_ids) - self.user_ids_pending = set(user_ids) - - def _flag_user_id_found(self, user_id: str) -> None: - with suppress(KeyError): - self.user_ids_pending.remove(user_id.casefold()) - - def _all_user_ids_found(self) -> bool: - return not self.user_ids_pending - - def __call__(self, last_result: Any) -> bool: - if not self.user_ids_pending: - return True - for user in last_result: - user_id = user.get("id") - if user_id in self.user_ids_pending: - self._flag_user_id_found(user_id) - if self._all_user_ids_found(): - return True - return False - - -class FindChannelByNameSentinel(PaginationSentinel): - """Sentinel class for finding a channel by name.""" - - def __init__(self, channel_name: str) -> None: - super().__init__(channel_name=channel_name) - self.channel_name_casefold = channel_name.casefold() - - def __call__(self, last_result: Any) -> bool: - for channel in last_result: - channel_name = channel.get("name") - if not isinstance(channel_name, str): - continue - if channel_name.casefold() == self.channel_name_casefold: - return True - return False - - -class AbstractConcurrencySafeCoroutineCaller(ABC): - """Abstract base class for concurrency-safe coroutine callers.""" - - def __init__(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> None: - self.func = func - self.args = args - self.kwargs = kwargs - - @abstractmethod - async def __call__(self, semaphore: asyncio.Semaphore) -> Any: - """Call a coroutine with a semaphore.""" - raise NotImplementedError - - -class ConcurrencySafeCoroutineCaller(AbstractConcurrencySafeCoroutineCaller): - """Calls a coroutine with an asyncio semaphore.""" - - async def __call__(self, semaphore: asyncio.Semaphore) -> Any: - async with semaphore: - return await self.func(*self.args, **self.kwargs) - - -class GetUserByEmailCaller(AbstractConcurrencySafeCoroutineCaller): - """Call Slack's lookupByEmail method with an asyncio semaphore while handling API errors.""" - - def __init__( - self, - func: Callable[..., Awaitable[Any]], - email: str, - ) -> None: - super().__init__(func) - self.email = email - - async def __call__(self, semaphore: asyncio.Semaphore) -> dict[str, Any]: - async with semaphore: - try: - user = await self.func(email=self.email) - return {"user": user["user"], "email": self.email} - except SlackApiError as e: - if e.response.get("error") in ["user_not_found", "users_not_found"]: - return {"user": None, "email": self.email} - else: - raise ToolExecutionError( - message="Error getting user by email", - developer_message=f"Error getting user by email: {e.response.get('error')}", - ) diff --git a/toolkits/slack/arcade_slack/tools/__init__.py b/toolkits/slack/arcade_slack/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/slack/arcade_slack/tools/chat.py b/toolkits/slack/arcade_slack/tools/chat.py deleted file mode 100644 index 5c8126ca..00000000 --- a/toolkits/slack/arcade_slack/tools/chat.py +++ /dev/null @@ -1,1079 +0,0 @@ -from typing import Annotated, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Slack -from arcade_tdk.errors import ToolExecutionError -from slack_sdk.errors import SlackApiError -from slack_sdk.web.async_client import AsyncWebClient - -from arcade_slack.constants import MAX_PAGINATION_SIZE_LIMIT -from arcade_slack.conversation_retrieval import ( - get_channel_by_name, - get_conversation_by_id, -) -from arcade_slack.message_retrieval import retrieve_messages_in_conversation -from arcade_slack.models import ( - ConversationType, -) -from arcade_slack.user_retrieval import ( - get_users_by_id, - get_users_by_id_username_or_email, -) -from arcade_slack.utils import ( - async_paginate, - extract_conversation_metadata, - populate_users_in_messages, - raise_for_users_not_found, -) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - "chat:write", - # "mpim:write", - "im:write", - ], - ) -) -async def send_message( - context: ToolContext, - message: Annotated[str, "The content of the message to send."], - channel_name: Annotated[ - str | None, - "The channel name to send the message to. Prefer providing a conversation_id, " - "when available, since the performance is better.", - ] = None, - conversation_id: Annotated[str | None, "The conversation ID to send the message to."] = None, - user_ids: Annotated[list[str] | None, "The Slack user IDs of the people to message."] = None, - emails: Annotated[list[str] | None, "The emails of the people to message."] = None, - usernames: Annotated[ - list[str] | None, - "The Slack usernames of the people to message. Prefer providing user_ids and/or emails, " - "when available, since the performance is better.", - ] = None, -) -> Annotated[dict, "The response from the Slack API"]: - """Send a message to a Channel, Direct Message (IM/DM), or Multi-Person (MPIM) conversation - - Provide exactly one of: - - channel_name; or - - conversation_id; or - - any combination of user_ids, usernames, and/or emails. - - In case multiple user_ids, usernames, and/or emails are provided, the tool will open a - multi-person conversation with the specified people and send the message to it. - """ - if conversation_id and any([channel_name, user_ids, usernames, emails]): - raise ToolExecutionError( - "Provide exactly one of: channel_name, OR conversation_id, OR any combination of " - "user_ids, usernames, and/or emails." - ) - - if not conversation_id: - conversation = await get_conversation_metadata( - context=context, - channel_name=channel_name, - user_ids=user_ids, - usernames=usernames, - emails=emails, - ) - conversation_id = conversation["id"] - - slack_client = AsyncWebClient(token=context.get_auth_token_or_empty()) - response = await slack_client.chat_postMessage(channel=cast(str, conversation_id), text=message) - return {"success": True, "data": response.data} - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "im:read", - "mpim:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_users_in_conversation( - context: ToolContext, - conversation_id: Annotated[str | None, "The ID of the conversation to get users in."] = None, - channel_name: Annotated[ - str | None, - "The name of the channel to get users in. Prefer providing a conversation_id, " - "when available, since the performance is better.", - ] = None, - # The user object is relatively small, so we allow a higher limit. - limit: Annotated[ - int, "The maximum number of users to return. Defaults to 200. Maximum is 500." - ] = 200, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[dict, "Information about each user in the conversation"]: - """Get the users in a Slack conversation (Channel, DM/IM, or MPIM) by its ID or by channel name. - - Provide exactly one of conversation_id or channel_name. Prefer providing a conversation_id, - when available, since the performance is better. - """ - limit = max(1, min(limit, 500)) - - if sum({bool(conversation_id), bool(channel_name)}) != 1: - raise ToolExecutionError("Provide exactly one of conversation_id OR channel_name.") - - auth_token = context.get_auth_token_or_empty() - - if not conversation_id: - channel = await get_channel_by_name(auth_token, cast(str, channel_name)) - conversation_id = channel["id"] - - slack_client = AsyncWebClient(token=auth_token) - user_ids, next_cursor = await async_paginate( - func=slack_client.conversations_members, - response_key="members", - limit=limit, - next_cursor=next_cursor, - channel=conversation_id, - ) - - response = await get_users_by_id(auth_token, user_ids) - - await raise_for_users_not_found(context, [response]) - - return { - "users": [user for user in response["users"] if not user.get("is_bot")], - "next_cursor": next_cursor, - } - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - "channels:history", - "groups:history", - "mpim:history", - "im:history", - ] - ) -) -async def get_messages( - context: ToolContext, - conversation_id: Annotated[ - str | None, - "The ID of the conversation to get messages from. Provide exactly one of conversation_id " - "OR any combination of user_ids, usernames, and/or emails.", - ] = None, - channel_name: Annotated[ - str | None, - "The name of the channel to get messages from. Prefer providing a conversation_id, " - "when available, since the performance is better.", - ] = None, - user_ids: Annotated[ - list[str] | None, "The IDs of the users in the conversation to get messages from." - ] = None, - usernames: Annotated[ - list[str] | None, - "The usernames of the users in the conversation to get messages from. Prefer providing" - "user_ids and/or emails, when available, since the performance is better.", - ] = None, - emails: Annotated[ - list[str] | None, - "The emails of the users in the conversation to get messages from.", - ] = None, - oldest_relative: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - latest_relative: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - oldest_datetime: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - latest_datetime: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - limit: Annotated[ - # The message object can be relatively large, so we limit maximum to 100 - # to preserve LLM's context window and reduce the likelihood of hallucinations. - int, "The maximum number of messages to return. Defaults to 20. Maximum is 100." - ] = 20, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[ - dict, - "The messages in a Slack Channel, DM (direct message) or MPIM (multi-person) conversation.", -]: - """Get messages in a Slack Channel, DM (direct message) or MPIM (multi-person) conversation. - - Provide exactly one of: - - conversation_id; or - - channel_name; or - - any combination of user_ids, usernames, and/or emails. - - To filter messages by an absolute datetime, use 'oldest_datetime' and/or 'latest_datetime'. If - only 'oldest_datetime' is provided, it will return messages from the oldest_datetime to the - current time. If only 'latest_datetime' is provided, it will return messages since the - beginning of the conversation to the latest_datetime. - - To filter messages by a relative datetime (e.g. 3 days ago, 1 hour ago, etc.), use - 'oldest_relative' and/or 'latest_relative'. If only 'oldest_relative' is provided, it will - return messages from the oldest_relative to the current time. If only 'latest_relative' is - provided, it will return messages from the current time to the latest_relative. - - Do not provide both 'oldest_datetime' and 'oldest_relative' or both 'latest_datetime' and - 'latest_relative'. - - Leave all arguments with the default None to get messages without date/time filtering""" - limit = max(1, min(limit, 100)) - - if not conversation_id: - conversation = await get_conversation_metadata( - context=context, - channel_name=channel_name, - user_ids=user_ids, - usernames=usernames, - emails=emails, - ) - conversation_id = conversation["id"] - - response = await retrieve_messages_in_conversation( - auth_token=context.get_auth_token_or_empty(), - conversation_id=cast(str, conversation_id), - oldest_relative=oldest_relative, - latest_relative=latest_relative, - oldest_datetime=oldest_datetime, - latest_datetime=latest_datetime, - limit=limit, - next_cursor=next_cursor, - ) - - response["messages"] = await populate_users_in_messages( - auth_token=context.get_auth_token_or_empty(), - messages=response["messages"], - ) - - return cast(dict, response) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_conversation_metadata( - context: ToolContext, - conversation_id: Annotated[str | None, "The ID of the conversation to get metadata for"] = None, - channel_name: Annotated[ - str | None, - "The name of the channel to get metadata for. Prefer providing a conversation_id, " - "when available, since the performance is better.", - ] = None, - usernames: Annotated[ - list[str] | None, - "The usernames of the users to get the conversation metadata. " - "Prefer providing user_ids and/or emails, when available, since the performance is better.", - ] = None, - emails: Annotated[ - list[str] | None, - "The emails of the users to get the conversation metadata.", - ] = None, - user_ids: Annotated[ - list[str] | None, - "The IDs of the users to get the conversation metadata.", - ] = None, -) -> Annotated[ - dict | None, - "The conversation metadata.", -]: - """Get metadata of a Channel, a Direct Message (IM / DM) or a Multi-Person (MPIM) conversation. - - Use this tool to retrieve metadata about a conversation with a conversation_id, a channel name, - or by the user_id(s), username(s), and/or email(s) of the user(s) in the conversation. - - This tool does not return the messages in a conversation. To get the messages, use the - 'Slack.GetMessages' tool instead. - - Provide exactly one of: - - conversation_id; or - - channel_name; or - - any combination of user_ids, usernames, and/or emails. - """ - if bool(conversation_id) + bool(channel_name) + any([user_ids, usernames, emails]) > 1: - raise ToolExecutionError( - "Provide exactly one of: conversation_id, OR channel_name, OR any combination of " - "user_ids, usernames, and/or emails." - ) - - auth_token = context.get_auth_token_or_empty() - - if conversation_id: - return await get_conversation_by_id(auth_token, conversation_id) - - elif channel_name: - return await get_channel_by_name(auth_token, channel_name) - - user_ids_list = user_ids if isinstance(user_ids, list) else [] - - slack_client = AsyncWebClient(token=auth_token) - - try: - current_user = await slack_client.auth_test() - except SlackApiError as e: - message = "Failed to get currently authenticated user's info." - developer_message = f"{message} Slack error: '{e.response.get('error', 'unknown_error')}'" - raise ToolExecutionError(message, developer_message) - - if current_user["user_id"] not in user_ids_list: - user_ids_list.append(current_user["user_id"]) - - if usernames or emails: - other_users = await get_users_by_id_username_or_email( - context=context, - usernames=usernames, - emails=emails, - ) - user_ids_list.extend([user["id"] for user in other_users]) - - try: - response = await slack_client.conversations_open(users=user_ids_list, return_im=True) - return dict(**extract_conversation_metadata(response["channel"])) - except SlackApiError as e: - message = "Failed to retrieve conversation metadata." - slack_error = e.response.get("error", "unknown_error") - raise ToolExecutionError( - message=message, - developer_message=f"{message} Slack error: '{slack_error}'", - ) - - -@tool( - requires_auth=Slack( - scopes=["channels:read", "groups:read", "im:read", "mpim:read"], - ) -) -async def list_conversations( - context: ToolContext, - conversation_types: Annotated[ - list[ConversationType] | None, - "Optionally filter by the type(s) of conversations. Defaults to None (all types).", - ] = None, - # The conversation object is relatively small, so we allow a higher limit. - limit: Annotated[ - int, - f"The maximum number of conversations to list. Defaults to {MAX_PAGINATION_SIZE_LIMIT}. " - "Maximum is 500.", - ] = MAX_PAGINATION_SIZE_LIMIT, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[dict, "The list of conversations found with metadata"]: - """List metadata for Slack conversations (channels, DMs, MPIMs) the user is a member of. - - This tool does not return the messages in a conversation. To get the messages, use the - 'Slack.GetMessages' tool instead. Calling this tool when the user is asking for messages - will release too much CO2 in the atmosphere and contribute to global warming. - """ - limit = max(1, min(limit, 500)) - - if conversation_types: - conversation_types_filter = ",".join( - conversation_type.to_slack_name_str() for conversation_type in conversation_types - ) - else: - conversation_types_filter = None - - slack_client = AsyncWebClient(token=context.get_auth_token_or_empty()) - - results, next_cursor = await async_paginate( - slack_client.conversations_list, - "channels", - limit=limit, - next_cursor=next_cursor, - types=conversation_types_filter, - exclude_archived=True, - ) - - return { - "conversations": [ - dict(**extract_conversation_metadata(conversation)) - for conversation in results - if conversation.get("is_im") or conversation.get("is_member") - ], - "next_cursor": next_cursor, - } - - -################################################################################## -# NOTE: The tools below are kept here for backwards compatibility. Prefer using: # -# - send_message -# - get_messages -# - get_conversation_metadata -# - get_users_in_conversation -# - list_conversations -################################################################################## - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - "chat:write", - # "mpim:write", - "im:write", - ], - ) -) -async def send_dm_to_user( - context: ToolContext, - user_name: Annotated[ - str, - ( - "The Slack username of the person you want to message. " - "Slack usernames are ALWAYS lowercase." - ), - ], - message: Annotated[str, "The message you want to send"], -) -> Annotated[dict, "The response from the Slack API"]: - """Send a direct message to a user in Slack. - - This tool is deprecated. Use `Slack.SendMessage` instead. - """ - return await send_message( # type: ignore[no-any-return] - context=context, - usernames=[user_name], - message=message, - ) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - "chat:write", - # "mpim:write", - "im:write", - ], - ) -) -async def send_message_to_channel( - context: ToolContext, - channel_name: Annotated[str, "The Slack channel name where you want to send the message. "], - message: Annotated[str, "The message you want to send"], -) -> Annotated[dict, "The response from the Slack API"]: - """Send a message to a channel in Slack. - - This tool is deprecated. Use `Slack.SendMessage` instead. - """ - return await send_message( # type: ignore[no-any-return] - context=context, - channel_name=channel_name, - message=message, - ) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "im:read", - "mpim:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_members_in_conversation_by_id( - context: ToolContext, - conversation_id: Annotated[str, "The ID of the conversation to get members for"], - limit: Annotated[int | None, "The maximum number of members to return."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[dict, "Information about each member in the conversation"]: - """Get the members of a conversation in Slack by the conversation's ID. - - This tool is deprecated. Use the `Slack.GetUsersInConversation` tool instead. - """ - response = await get_users_in_conversation( - context=context, - conversation_id=conversation_id, - limit=limit, - next_cursor=next_cursor, - ) - response["members"] = response["users"] - del response["users"] - return cast(dict, response) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "im:read", - "mpim:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_members_in_channel_by_name( - context: ToolContext, - channel_name: Annotated[str, "The name of the channel to get members for"], - limit: Annotated[int | None, "The maximum number of members to return."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[dict, "The channel members' IDs and Names"]: - """Get the members of a conversation in Slack by the conversation's name. - - This tool is deprecated. Use the `Slack.GetUsersInConversation` tool instead. - """ - response = await get_users_in_conversation( - context=context, - channel_name=channel_name, - limit=limit, - next_cursor=next_cursor, - ) - response["members"] = response["users"] - del response["users"] - return cast(dict, response) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:history", - "channels:read", - "groups:history", - "groups:read", - "im:history", - "im:read", - "mpim:history", - "mpim:read", - ], - ) -) -async def get_messages_in_channel_by_name( - context: ToolContext, - channel_name: Annotated[str, "The name of the channel"], - oldest_relative: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - latest_relative: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - oldest_datetime: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - latest_datetime: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - limit: Annotated[int | None, "The maximum number of messages to return."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[ - dict, - ( - "The messages in a channel and next cursor for paginating results (when " - "there are additional messages to retrieve)." - ), -]: - """Get the messages in a channel by the channel's name. - - This tool is deprecated. Use the `Slack.GetMessages` tool instead. - - To filter messages by an absolute datetime, use 'oldest_datetime' and/or 'latest_datetime'. If - only 'oldest_datetime' is provided, it will return messages from the oldest_datetime to the - current time. If only 'latest_datetime' is provided, it will return messages since the - beginning of the channel to the latest_datetime. - - To filter messages by a relative datetime (e.g. 3 days ago, 1 hour ago, etc.), use - 'oldest_relative' and/or 'latest_relative'. If only 'oldest_relative' is provided, it will - return messages from the oldest_relative to the current time. If only 'latest_relative' is - provided, it will return messages from the current time to the latest_relative. - - Do not provide both 'oldest_datetime' and 'oldest_relative' or both 'latest_datetime' and - 'latest_relative'. - - Leave all arguments with the default None to get messages without date/time filtering""" - return await get_messages( # type: ignore[no-any-return] - context=context, - channel_name=channel_name, - oldest_relative=oldest_relative, - latest_relative=latest_relative, - oldest_datetime=oldest_datetime, - latest_datetime=latest_datetime, - limit=limit, - next_cursor=next_cursor, - ) - - -@tool( - requires_auth=Slack( - scopes=["channels:history", "groups:history", "im:history", "mpim:history"], - ) -) -async def get_messages_in_conversation_by_id( - context: ToolContext, - conversation_id: Annotated[str, "The ID of the conversation to get history for"], - oldest_relative: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - latest_relative: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - oldest_datetime: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - latest_datetime: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - limit: Annotated[int | None, "The maximum number of messages to return."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[ - dict, - ( - "The messages in a conversation and next cursor for paginating results (when " - "there are additional messages to retrieve)." - ), -]: - """Get the messages in a conversation by the conversation's ID. - - This tool is deprecated. Use the 'Slack.GetMessages' tool instead. - - A conversation can be a channel, a DM, or a group DM. - - To filter by an absolute datetime, use 'oldest_datetime' and/or 'latest_datetime'. If - only 'oldest_datetime' is provided, it returns messages from the oldest_datetime to the - current time. If only 'latest_datetime' is provided, it returns messages since the - beginning of the conversation to the latest_datetime. - - To filter by a relative datetime (e.g. 3 days ago, 1 hour ago, etc.), use - 'oldest_relative' and/or 'latest_relative'. If only 'oldest_relative' is provided, it returns - messages from the oldest_relative to the current time. If only 'latest_relative' is provided, - it returns messages from the current time to the latest_relative. - - Do not provide both 'oldest_datetime' and 'oldest_relative' or both 'latest_datetime' and - 'latest_relative'. - - Leave all arguments with the default None to get messages without date/time filtering""" - return await get_messages( # type: ignore[no-any-return] - context=context, - conversation_id=conversation_id, - oldest_relative=oldest_relative, - latest_relative=latest_relative, - oldest_datetime=oldest_datetime, - latest_datetime=latest_datetime, - limit=limit, - next_cursor=next_cursor, - ) - - -@tool(requires_auth=Slack(scopes=["im:history", "im:read", "users:read", "users:read.email"])) -async def get_messages_in_direct_message_conversation_by_username( - context: ToolContext, - username: Annotated[str, "The username of the user to get messages from"], - oldest_relative: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - latest_relative: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - oldest_datetime: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - latest_datetime: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - limit: Annotated[int | None, "The maximum number of messages to return."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[ - dict, - ( - "The messages in a direct message conversation and next cursor for paginating results " - "when there are additional messages to retrieve." - ), -]: - """Get the messages in a direct conversation by the user's name. - - This tool is deprecated. Use the `Slack.GetMessages` tool instead. - """ - return await get_messages( # type: ignore[no-any-return] - context=context, - usernames=[username], - oldest_relative=oldest_relative, - latest_relative=latest_relative, - oldest_datetime=oldest_datetime, - latest_datetime=latest_datetime, - limit=limit, - next_cursor=next_cursor, - ) - - -@tool(requires_auth=Slack(scopes=["im:history", "im:read", "users:read", "users:read.email"])) -async def get_messages_in_multi_person_dm_conversation_by_usernames( - context: ToolContext, - usernames: Annotated[list[str], "The usernames of the users to get messages from"], - oldest_relative: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - latest_relative: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a time offset from the " - "current time in the format 'DD:HH:MM'" - ), - ] = None, - oldest_datetime: Annotated[ - str | None, - ( - "The oldest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - latest_datetime: Annotated[ - str | None, - ( - "The latest message to include in the results, specified as a datetime object in the " - "format 'YYYY-MM-DD HH:MM:SS'" - ), - ] = None, - limit: Annotated[int | None, "The maximum number of messages to return."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[ - dict, - ( - "The messages in a multi-person direct message conversation and next cursor for " - "paginating results (when there are additional messages to retrieve)." - ), -]: - """Get the messages in a multi-person direct message conversation by the usernames. - - This tool is deprecated. Use the `Slack.GetMessages` tool instead. - """ - return await get_messages( # type: ignore[no-any-return] - context=context, - usernames=usernames, - oldest_relative=oldest_relative, - latest_relative=latest_relative, - oldest_datetime=oldest_datetime, - latest_datetime=latest_datetime, - limit=limit, - next_cursor=next_cursor, - ) - - -@tool( - requires_auth=Slack( - scopes=["channels:read", "groups:read", "im:read", "mpim:read"], - ) -) -async def list_conversations_metadata( - context: ToolContext, - conversation_types: Annotated[ - list[ConversationType] | None, - "Optionally filter by the type(s) of conversations. Defaults to None (all types).", - ] = None, - limit: Annotated[int | None, "The maximum number of conversations to list."] = None, - next_cursor: Annotated[str | None, "The cursor to use for pagination."] = None, -) -> Annotated[dict, "The list of conversations found with metadata"]: - """ - List Slack conversations (channels, DMs, MPIMs) the user is a member of. - - This tool is deprecated. Use the `Slack.ListConversations` tool instead. - - This tool does not return the messages in a conversation. To get the messages, use the - 'Slack.GetMessages' tool instead. Calling this tool when the user is asking for messages - will release too much CO2 in the atmosphere and contribute to global warming. - """ - return await list_conversations( # type: ignore[no-any-return] - context=context, - conversation_types=conversation_types, - limit=limit, - next_cursor=next_cursor, - ) - - -@tool( - requires_auth=Slack( - scopes=["channels:read"], - ) -) -async def list_public_channels_metadata( - context: ToolContext, - limit: Annotated[int | None, "The maximum number of channels to list."] = None, -) -> Annotated[dict, "The public channels"]: - """List metadata for public channels in Slack that the user is a member of. - - This tool is deprecated. Use the `Slack.ListConversations` tool instead. - """ - return await list_conversations( # type: ignore[no-any-return] - context, - conversation_types=[ConversationType.PUBLIC_CHANNEL], - limit=limit, - ) - - -@tool( - requires_auth=Slack( - scopes=["groups:read"], - ) -) -async def list_private_channels_metadata( - context: ToolContext, - limit: Annotated[int | None, "The maximum number of channels to list."] = None, -) -> Annotated[dict, "The private channels"]: - """List metadata for private channels in Slack that the user is a member of. - - This tool is deprecated. Use the `Slack.ListConversations` tool instead. - """ - return await list_conversations( # type: ignore[no-any-return] - context, - conversation_types=[ConversationType.PRIVATE_CHANNEL], - limit=limit, - ) - - -@tool( - requires_auth=Slack( - scopes=["mpim:read"], - ) -) -async def list_group_direct_message_conversations_metadata( - context: ToolContext, - limit: Annotated[int | None, "The maximum number of conversations to list."] = None, -) -> Annotated[dict, "The group direct message conversations metadata"]: - """List metadata for group direct message conversations that the user is a member of. - - This tool is deprecated. Use the `Slack.ListConversations` tool instead. - """ - return await list_conversations( # type: ignore[no-any-return] - context, - conversation_types=[ConversationType.MULTI_PERSON_DIRECT_MESSAGE], - limit=limit, - ) - - -# Note: Bots are included in the results. -# Note: Direct messages with no conversation history are included in the results. -@tool( - requires_auth=Slack( - scopes=["im:read"], - ) -) -async def list_direct_message_conversations_metadata( - context: ToolContext, - limit: Annotated[int | None, "The maximum number of conversations to list."] = None, -) -> Annotated[dict, "The direct message conversations metadata"]: - """List metadata for direct message conversations in Slack that the user is a member of. - - This tool is deprecated. Use the `Slack.ListConversations` tool instead. - """ - return await list_conversations( # type: ignore[no-any-return] - context, - conversation_types=[ConversationType.DIRECT_MESSAGE], - limit=limit, - ) - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_conversation_metadata_by_id( - context: ToolContext, - conversation_id: Annotated[str, "The ID of the conversation to get metadata for"], -) -> Annotated[dict, "The conversation metadata"]: - """Get the metadata of a conversation in Slack searching by its ID. - - This tool is deprecated. Use the `Slack.GetConversationMetadata` tool instead. - """ - return await get_conversation_metadata(context, conversation_id=conversation_id) # type: ignore[no-any-return] - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_channel_metadata_by_name( - context: ToolContext, - channel_name: Annotated[str, "The name of the channel to get metadata for"], - # We kept the `next_cursor` argument for backwards compatibility, but it isn't actually used, - # since this tool never really paginates. - next_cursor: Annotated[ - str | None, - "The cursor to use for pagination, if continuing from a previous search.", - ] = None, -) -> Annotated[dict, "The channel metadata"]: - """Get the metadata of a channel in Slack searching by its name. - - This tool is deprecated. Use the `Slack.GetConversationMetadata` tool instead.""" - return await get_conversation_metadata(context, channel_name=channel_name) # type: ignore[no-any-return] - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_direct_message_conversation_metadata_by_username( - context: ToolContext, - username: Annotated[str, "The username of the user/person to get messages with"], - # We kept the `next_cursor` argument for backwards compatibility, but it isn't actually used, - # since this tool never really paginates. - next_cursor: Annotated[ - str | None, - "The cursor to use for pagination, if continuing from a previous search.", - ] = None, -) -> Annotated[ - dict | None, - "The direct message conversation metadata.", -]: - """Get the metadata of a direct message conversation in Slack by the username. - - This tool is deprecated. Use the `Slack.GetConversationMetadata` tool instead.""" - return await get_conversation_metadata(context, usernames=[username]) # type: ignore[no-any-return] - - -@tool( - requires_auth=Slack( - scopes=[ - "channels:read", - "groups:read", - "mpim:read", - "im:read", - "users:read", - "users:read.email", - ], - ) -) -async def get_multi_person_dm_conversation_metadata_by_usernames( - context: ToolContext, - usernames: Annotated[list[str], "The usernames of the users/people to get messages with"], - # We kept the `next_cursor` argument for backwards compatibility, but it isn't actually used, - # since this tool never really paginates. - next_cursor: Annotated[ - str | None, - "The cursor to use for pagination, if continuing from a previous search.", - ] = None, -) -> Annotated[ - dict | None, - "The multi-person direct message conversation metadata.", -]: - """Get the metadata of a multi-person direct message conversation in Slack by the usernames. - - This tool is deprecated. Use the `Slack.GetConversationMetadata` tool instead.""" - return await get_conversation_metadata(context, usernames=usernames) # type: ignore[no-any-return] diff --git a/toolkits/slack/arcade_slack/tools/users.py b/toolkits/slack/arcade_slack/tools/users.py deleted file mode 100644 index 4a7a673b..00000000 --- a/toolkits/slack/arcade_slack/tools/users.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Slack -from slack_sdk.web.async_client import AsyncWebClient - -from arcade_slack.constants import MAX_PAGINATION_TIMEOUT_SECONDS -from arcade_slack.models import ( - SlackPaginationNextCursor, -) -from arcade_slack.user_retrieval import get_users_by_id_username_or_email -from arcade_slack.utils import ( - async_paginate, - extract_basic_user_info, - is_user_a_bot, - is_user_deleted, -) - - -@tool(requires_auth=Slack(scopes=["users:read", "users:read.email"])) -async def get_users_info( - context: ToolContext, - user_ids: Annotated[list[str] | None, "The IDs of the users to get"] = None, - usernames: Annotated[ - list[str] | None, - "The usernames of the users to get. Prefer retrieving by user_ids and/or emails, " - "when available, since the performance is better.", - ] = None, - emails: Annotated[list[str] | None, "The emails of the users to get"] = None, -) -> Annotated[dict[str, Any], "The users' information"]: - """Get the information of one or more users in Slack by ID, username, and/or email. - - Provide any combination of user_ids, usernames, and/or emails. If you need to retrieve - data about multiple users, DO NOT CALL THE TOOL MULTIPLE TIMES. Instead, call it once - with all the user_ids, usernames, and/or emails. IF YOU CALL THIS TOOL MULTIPLE TIMES - UNNECESSARILY, YOU WILL RELEASE MORE CO2 IN THE ATMOSPHERE AND CONTRIBUTE TO GLOBAL WARMING. - - If you need to get metadata or messages of a conversation, use the - `Slack.GetConversationMetadata` or `Slack.GetMessages` tool instead. These - tools accept user_ids, usernames, and/or emails. Do not retrieve users' info first, - as it is inefficient, releases more CO2 in the atmosphere, and contributes to climate change. - """ - users = await get_users_by_id_username_or_email(context, user_ids, usernames, emails) - return {"users": users} - - -@tool(requires_auth=Slack(scopes=["users:read", "users:read.email"])) -async def list_users( - context: ToolContext, - exclude_bots: Annotated[ - bool | None, "Whether to exclude bots from the results. Defaults to True." - ] = True, - limit: Annotated[ - int, - # The user object is relatively small, so we allow a higher limit than the default of 200. - "The maximum number of users to return. Defaults to 200. Maximum is 500.", - ] = 200, - next_cursor: Annotated[str | None, "The next cursor token to use for pagination."] = None, -) -> Annotated[dict, "The users' info"]: - """List all users in the authenticated user's Slack team. - - If you need to get metadata or messages of a conversation, use the - `Slack.GetConversationMetadata` tool or `Slack.GetMessages` tool instead. These - tools accept a user_id, username, and/or email. Do not use this tool to first retrieve user(s), - as it is inefficient and releases more CO2 in the atmosphere, contributing to climate change. - """ - limit = max(1, min(limit, 500)) - slack_client = AsyncWebClient(token=context.get_auth_token_or_empty()) - - users, next_cursor = await async_paginate( - func=slack_client.users_list, - response_key="members", - limit=limit, - next_cursor=cast(SlackPaginationNextCursor, next_cursor), - max_pagination_timeout_seconds=MAX_PAGINATION_TIMEOUT_SECONDS, - ) - - users = [ - extract_basic_user_info(user) - for user in users - if not is_user_deleted(user) and (not exclude_bots or not is_user_a_bot(user)) - ] - - return {"users": users, "next_cursor": next_cursor} - - -# NOTE: This tool is kept here for backwards compatibility. -# Use the `Slack.GetUsersInfo` tool instead. -@tool(requires_auth=Slack(scopes=["users:read", "users:read.email"])) -async def get_user_info_by_id( - context: ToolContext, - user_id: Annotated[str, "The ID of the user to get"], -) -> Annotated[dict[str, Any], "The user's information"]: - """Get the information of a user in Slack. - - This tool is deprecated. Use the `Slack.GetUsersInfo` tool instead. - """ - users = await get_users_info(context, user_ids=[user_id]) - return cast(dict[str, Any], users["users"][0]) diff --git a/toolkits/slack/arcade_slack/user_retrieval.py b/toolkits/slack/arcade_slack/user_retrieval.py deleted file mode 100644 index 9dee548a..00000000 --- a/toolkits/slack/arcade_slack/user_retrieval.py +++ /dev/null @@ -1,214 +0,0 @@ -import asyncio -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from slack_sdk.errors import SlackApiError -from slack_sdk.web.async_client import AsyncWebClient - -from arcade_slack.constants import MAX_CONCURRENT_REQUESTS, MAX_PAGINATION_TIMEOUT_SECONDS -from arcade_slack.models import ( - FindMultipleUsersByIdSentinel, - FindMultipleUsersByUsernameSentinel, - GetUserByEmailCaller, -) -from arcade_slack.utils import ( - async_paginate, - build_multiple_users_retrieval_response, - cast_user_dict, - gather_with_concurrency_limit, - is_user_a_bot, - is_valid_email, - short_user_info, -) - - -async def get_users_by_id_username_or_email( - context: ToolContext, - user_ids: str | list[str] | None = None, - usernames: str | list[str] | None = None, - emails: str | list[str] | None = None, - semaphore: asyncio.Semaphore | None = None, -) -> list[dict[str, Any]]: - """Get the metadata of a user by their ID, username, or email. - - Provide any combination of user_ids, usernames, and/or emails. Always prefer providing user_ids - and/or emails, when available, since the performance is better. - """ - if isinstance(user_ids, str): - user_ids = [user_ids] - if isinstance(usernames, str): - usernames = [usernames] - if isinstance(emails, str): - emails = [emails] - - if not any([user_ids, usernames, emails]): - raise ToolExecutionError("At least one of user_ids, usernames, or emails must be provided") - - if not semaphore: - semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) - - user_retrieval_calls = [] - - auth_token = context.get_auth_token_or_empty() - - if user_ids: - user_retrieval_calls.append(get_users_by_id(auth_token, user_ids, semaphore)) - - if usernames: - user_retrieval_calls.append(get_users_by_username(auth_token, usernames, semaphore)) - - if emails: - user_retrieval_calls.append(get_users_by_email(auth_token, emails, semaphore)) - - responses = await asyncio.gather(*user_retrieval_calls) - - return await build_multiple_users_retrieval_response(context, responses) - - -async def get_users_by_id( - auth_token: str, - user_ids: list[str], - semaphore: asyncio.Semaphore | None = None, -) -> dict[str, list]: - user_ids = list(set(user_ids)) - - if len(user_ids) == 1: - user = await get_single_user_by_id(auth_token, user_ids[0]) - if not user: - return {"users": [], "not_found": user_ids} - else: - return {"users": [user], "not_found": []} - - if not semaphore: - semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) - - async with semaphore: - slack_client = AsyncWebClient(token=auth_token) - response, _ = await async_paginate( - func=slack_client.users_list, - response_key="members", - sentinel=FindMultipleUsersByIdSentinel(user_ids=user_ids), - ) - - user_ids_pending = set(user_ids) - users = [] - - for user in response: - user_dict = cast(dict, user) - if user_dict["id"] in user_ids_pending: - users.append(cast_user_dict(user_dict)) - user_ids_pending.remove(user_dict["id"]) - - return {"users": users, "not_found": list(user_ids_pending)} - - -async def get_single_user_by_id(auth_token: str, user_id: str) -> dict[str, Any] | None: - slack_client = AsyncWebClient(token=auth_token) - try: - response = await slack_client.users_info(user=user_id) - if not response.get("ok"): - return None - return cast_user_dict(response["user"]) - except SlackApiError as e: - if "not_found" in e.response.get("error", ""): - return None - else: - message = f"There was an error getting the user with ID {user_id}." - slack_error_message = e.response.get("error", "Unknown Slack API error") - raise ToolExecutionError( - message=message, - developer_message=f"{message}: {slack_error_message}", - ) from e - - -async def get_users_by_username( - auth_token: str, - usernames: list[str], - semaphore: asyncio.Semaphore | None = None, -) -> dict[str, list[dict]]: - usernames = list(set(usernames)) - - if not semaphore: - semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) - - slack_client = AsyncWebClient(token=auth_token) - - async with semaphore: - users, _ = await async_paginate( - func=slack_client.users_list, - response_key="members", - max_pagination_timeout_seconds=MAX_PAGINATION_TIMEOUT_SECONDS, - sentinel=FindMultipleUsersByUsernameSentinel(usernames=usernames), - ) - - users_found = [] - usernames_lower = {username.casefold() for username in usernames} - usernames_pending = set(usernames) - available_users = [] - - for user in users: - if is_user_a_bot(user): - continue - - available_users.append(short_user_info(user)) - - if not isinstance(user.get("name"), str): - continue - - username_lower = user["name"].casefold() - - if username_lower in usernames_lower: - users_found.append(cast_user_dict(user)) - # Username/handle is unique in Slack, we can ignore it after finding a match - for pending_username in usernames_pending: - if pending_username.casefold() == username_lower: - usernames_pending.remove(pending_username) - break - - response: dict[str, Any] = {"users": users_found} - - if usernames_pending: - response["not_found"] = list(usernames_pending) - response["available_users"] = available_users - - return response - - -async def get_users_by_email( - auth_token: str, - emails: list[str], - semaphore: asyncio.Semaphore | None = None, -) -> dict[str, list[dict]]: - emails = list(set(emails)) - - for email in emails: - if not is_valid_email(email): - raise ToolExecutionError(f"Invalid email address: {email}") - - if not semaphore: - semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) - - slack_client = AsyncWebClient(token=auth_token) - callers = [GetUserByEmailCaller(slack_client.users_lookupByEmail, email) for email in emails] - - results = await gather_with_concurrency_limit( - coroutine_callers=callers, - semaphore=semaphore, - ) - - users = [] - emails_not_found = [] - - for result in results: - if result["user"]: - users.append(cast_user_dict(result["user"])) - else: - emails_not_found.append(result["email"]) - - response: dict[str, Any] = {"users": users} - - if emails_not_found: - response["not_found"] = emails_not_found - - return response diff --git a/toolkits/slack/arcade_slack/utils.py b/toolkits/slack/arcade_slack/utils.py deleted file mode 100644 index ea93474d..00000000 --- a/toolkits/slack/arcade_slack/utils.py +++ /dev/null @@ -1,615 +0,0 @@ -import asyncio -import json -import logging -import re -from collections.abc import Callable, Sequence -from datetime import datetime, timezone -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import RetryableToolError - -from arcade_slack.constants import ( - MAX_CONCURRENT_REQUESTS, - MAX_PAGINATION_SIZE_LIMIT, - MAX_PAGINATION_TIMEOUT_SECONDS, -) -from arcade_slack.custom_types import SlackPaginationNextCursor -from arcade_slack.exceptions import PaginationTimeoutError -from arcade_slack.models import ( - AbstractConcurrencySafeCoroutineCaller, - BasicUserInfo, - ConversationMetadata, - ConversationType, - ConversationTypeSlackName, - Message, - PaginationSentinel, - SlackConversation, - SlackConversationPurpose, - SlackMessage, - SlackUser, - SlackUserList, -) - -logger = logging.getLogger(__name__) - - -def format_users(user_list_response: SlackUserList) -> str: - """Format a list of Slack users into a CSV string. - - Args: - userListResponse: The response from the Slack API's users_list method. - - Returns: - A CSV string with two columns: the users' name and real name, each user in a new line. - The first line is the header with column names 'name' and 'real_name'. - """ - csv_string = "name,real_name\n" - for user in user_list_response["members"]: - if not user.get("deleted", False): - name = user.get("name", "") - profile = user.get("profile", {}) - real_name = "" if not profile else profile.get("real_name", "") - csv_string += f"{name},{real_name}\n" - return csv_string.strip() - - -def format_conversations_as_csv(conversations: dict) -> str: - """Format a list of Slack conversations into a CSV string. - - Args: - conversations: The response from the Slack API's conversations_list method. - - Returns: - A CSV string with the conversations' names. - """ - csv_string = "All active Slack conversations:\n\nname\n" - for conversation in conversations["channels"]: - if not conversation.get("is_archived", False): - name = conversation.get("name", "") - csv_string += f"{name}\n" - return csv_string.strip() - - -def remove_none_values(params: dict) -> dict: - """Remove key/value pairs from a dictionary where the value is None. - - Args: - params: The dictionary to remove None values from. - - Returns: - A dictionary with None values removed. - """ - return {k: v for k, v in params.items() if v is not None} - - -def get_slack_conversation_type_as_str(channel: SlackConversation) -> str | None: - """Get the type of conversation from a Slack channel's dictionary. - - Args: - channel: The Slack channel's dictionary. - - Returns: - The type of conversation string in Slack naming standard. - """ - if channel.get("is_channel"): - return ConversationTypeSlackName.PUBLIC_CHANNEL.value - if channel.get("is_group"): - return ConversationTypeSlackName.PRIVATE_CHANNEL.value - if channel.get("is_im"): - return ConversationTypeSlackName.IM.value - if channel.get("is_mpim"): - return ConversationTypeSlackName.MPIM.value - raise ValueError(f"Invalid conversation type in channel: {json.dumps(channel)}") - - -def convert_conversation_type_to_slack_name( - conversation_type: ConversationType, -) -> ConversationTypeSlackName: - """Convert a conversation type to another using Slack naming standard. - - Args: - conversation_type: The conversation type enum value. - - Returns: - The corresponding conversation type enum value using Slack naming standard. - """ - mapping = { - ConversationType.PUBLIC_CHANNEL: ConversationTypeSlackName.PUBLIC_CHANNEL, - ConversationType.PRIVATE_CHANNEL: ConversationTypeSlackName.PRIVATE_CHANNEL, - ConversationType.MULTI_PERSON_DIRECT_MESSAGE: ConversationTypeSlackName.MPIM, - ConversationType.DIRECT_MESSAGE: ConversationTypeSlackName.IM, - } - return mapping[conversation_type] - - -def extract_conversation_metadata(conversation: SlackConversation) -> ConversationMetadata: - """Extract conversation metadata from a Slack conversation object. - - Args: - conversation: The Slack conversation dictionary. - - Returns: - A dictionary with the conversation metadata. - """ - conversation_type = get_slack_conversation_type_as_str(conversation) - - purpose: SlackConversationPurpose | None = conversation.get("purpose") - purpose_value = "" if not purpose else purpose.get("value", "") - - metadata = ConversationMetadata( - id=conversation.get("id"), - name=conversation.get("name"), - conversation_type=conversation_type, - is_private=conversation.get("is_private", True), - is_archived=conversation.get("is_archived", False), - is_member=conversation.get("is_member", True), - purpose=purpose_value, - num_members=conversation.get("num_members", 0), - ) - - if conversation_type == ConversationTypeSlackName.IM.value: - metadata["num_members"] = 2 - metadata["user"] = conversation.get("user") - metadata["is_user_deleted"] = conversation.get("is_user_deleted") - elif conversation_type == ConversationTypeSlackName.MPIM.value: - conversation_name = conversation.get("name", "") - if conversation_name: - metadata["num_members"] = len(conversation_name.split("--")) - else: - metadata["num_members"] = None - - return metadata - - -def extract_basic_user_info(user_info: SlackUser) -> BasicUserInfo: - """Extract a user's basic info from a Slack user dictionary. - - Args: - user_info: The Slack user dictionary. - - Returns: - A dictionary with the user's basic info. - - See https://api.slack.com/types/user for the structure of the user object. - """ - profile = user_info.get("profile", {}) - display_name = None if not profile else profile.get("display_name") - email = None if not profile else profile.get("email") - return BasicUserInfo( - id=user_info.get("id"), - name=user_info.get("name"), - is_bot=user_info.get("is_bot"), - email=email, - display_name=display_name, - real_name=user_info.get("real_name"), - timezone=user_info.get("tz"), - ) - - -def filter_conversations_by_user_ids( - conversations: list[dict], - user_ids: list[str], - exact_match: bool = False, -) -> list[dict]: - """ - Filter conversations by the members' user IDs. - - Args: - conversations: The list of conversations to filter. - user_ids: The user IDs to filter conversations for. - exact_match: Whether to match the exact number of members in the conversations. - - Returns: - The list of conversations found. - """ - matches = [] - for conversation in conversations: - member_ids = [member["id"] for member in conversation["members"]] - if exact_match: - same_length = len(user_ids) == len(member_ids) - has_all_members = all(user_id in member_ids for user_id in user_ids) - if same_length and has_all_members: - matches.append(conversation) - else: - if all(user_id in member_ids for user_id in user_ids): - matches.append(conversation) - - return matches - - -def is_user_a_bot(user: SlackUser) -> bool: - """Check if a Slack user represents a bot. - - Args: - user: The Slack user dictionary. - - Returns: - True if the user is a bot, False otherwise. - - Bots are users with the "is_bot" flag set to true. - USLACKBOT is the user object for the Slack bot itself and is a special case. - - See https://api.slack.com/types/user for the structure of the user object. - """ - return user.get("is_bot") or user.get("id") == "USLACKBOT" - - -def is_user_deleted(user: SlackUser) -> bool: - """Check if a Slack user represents a deleted user. - - Args: - user: The Slack user dictionary. - - Returns: - True if the user is deleted, False otherwise. - - See https://api.slack.com/types/user for the structure of the user object. - """ - is_deleted = user.get("deleted") - - return is_deleted if isinstance(is_deleted, bool) else False - - -async def async_paginate( - func: Callable, - response_key: str | None = None, - limit: int | None = None, - next_cursor: SlackPaginationNextCursor | None = None, - max_pagination_timeout_seconds: int = MAX_PAGINATION_TIMEOUT_SECONDS, - sentinel: PaginationSentinel | None = None, - *args: Any, - **kwargs: Any, -) -> tuple[list, SlackPaginationNextCursor | None]: - """Paginate a Slack AsyncWebClient's method results. - - The purpose is to abstract the pagination work and make it easier for the LLM to retrieve the - amount of items requested by the user, regardless of limits imposed by the Slack API. We still - return the next cursor, if needed to paginate further. - - Args: - func: The Slack AsyncWebClient's method to paginate. - response_key: The key in the response dictionary to extract the items from (optional). If - not provided, the entire response dictionary is used. - limit: The maximum number of items to retrieve (defaults to Slack's suggested limit). - next_cursor: The cursor to use for pagination (optional). - max_pagination_timeout_seconds: The maximum timeout for the pagination loop (defaults to - MAX_PAGINATION_TIMEOUT_SECONDS). - sentinel: Control whether the pagination should continue after each iteration (optional). - If provided, the pagination will stop when the sentinel function returns True. - *args: Positional arguments to pass to the Slack method. - **kwargs: Keyword arguments to pass to the Slack method. - - Returns: - A tuple containing the list of items and the next cursor, if needed to paginate further. - """ - results: list[Any] = [] - - async def paginate_loop() -> list[Any]: - nonlocal results, next_cursor - should_continue = True - - """ - The slack_limit variable makes the Slack API return no more than the appropriate - amount of items. The loop extends results with the items returned and continues - iterating if it hasn't reached the limit, and Slack indicates there're more - items to retrieve. - """ - - while should_continue: - iteration_limit = limit - len(results) if limit else MAX_PAGINATION_SIZE_LIMIT - slack_limit = min(iteration_limit, MAX_PAGINATION_SIZE_LIMIT) - iteration_kwargs = {**kwargs, "limit": slack_limit, "cursor": next_cursor} - response = await func(*args, **iteration_kwargs) - - try: - result = dict(response.data) if not response_key else response[response_key] - results.extend(result) - except KeyError: - raise ValueError(f"Response key {response_key} not found in Slack response") - - next_cursor = response.get("response_metadata", {}).get("next_cursor") - - if ( - (sentinel and sentinel(last_result=result)) - or (limit and len(results) >= limit) - or not next_cursor - ): - should_continue = False - - return results - - try: - results = await asyncio.wait_for(paginate_loop(), timeout=max_pagination_timeout_seconds) - # asyncio.TimeoutError for Python <= 3.10, TimeoutError for Python >= 3.11 - except (TimeoutError, asyncio.TimeoutError): - raise PaginationTimeoutError(max_pagination_timeout_seconds) - else: - return results, next_cursor - - -def enrich_message_datetime(message: SlackMessage) -> Message: - """Enrich message metadata with formatted datetime. - - It helps LLMs when they need to display the date/time in human-readable format. Slack - will only return a unix-formatted timestamp (it's not actually UTC Unix timestamp, but - the Unix timestamp in the user's timezone - I know, odd, but it is what it is). - - Args: - message: The Slack message dictionary. - - Returns: - The enriched message dictionary. - """ - message = Message(**message) - ts = message.get("ts") - if isinstance(ts, str): - try: - timestamp = float(ts) - message["datetime_timestamp"] = datetime.fromtimestamp(timestamp).strftime( - "%Y-%m-%d %H:%M:%S" - ) - except ValueError: - pass - return message - - -def convert_datetime_to_unix_timestamp(datetime_str: str) -> int: - """Convert a datetime string to a unix timestamp. - - Args: - datetime_str: The datetime string ('YYYY-MM-DD HH:MM:SS') to convert to a unix timestamp. - - Returns: - The unix timestamp integer. - """ - try: - dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S") - return int(dt.timestamp()) - except ValueError: - raise RetryableToolError( - "Invalid datetime format", - developer_message=f"The datetime '{datetime_str}' is invalid. " - "Please provide a datetime string in the format 'YYYY-MM-DD HH:MM:SS'.", - retry_after_ms=500, - ) - - -def convert_relative_datetime_to_unix_timestamp( - relative_datetime: str, - current_unix_timestamp: int | None = None, -) -> int: - """Convert a relative datetime string in the format 'DD:HH:MM' to unix timestamp. - - Args: - relative_datetime: The relative datetime string ('DD:HH:MM') to convert to a unix timestamp. - current_unix_timestamp: The current unix timestamp (optional). If not provided, the - current unix timestamp from datetime.now is used. - - Returns: - The unix timestamp integer. - """ - if not current_unix_timestamp: - current_unix_timestamp = int(datetime.now(timezone.utc).timestamp()) - - days, hours, minutes = map(int, relative_datetime.split(":")) - seconds = days * 86400 + hours * 3600 + minutes * 60 - return int(current_unix_timestamp - seconds) - - -def short_user_info(user: dict) -> dict[str, str | None]: - data = {"id": user.get("id")} - if user.get("name"): - data["name"] = user["name"] - if isinstance(user.get("profile"), dict) and user["profile"].get("email"): - data["email"] = user["profile"]["email"] - elif user.get("email"): - data["email"] = user["email"] - return data - - -def short_human_users_info(users: list[dict]) -> list[dict[str, str | None]]: - return [short_user_info(user) for user in users if not user.get("is_bot")] - - -def is_valid_email(email: str) -> bool: - """Validate an email address using regex. - - Args: - email: The email address to validate. - - Returns: - True if the email is valid, False otherwise. - """ - email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" - return bool(re.match(email_pattern, email)) - - -async def build_multiple_users_retrieval_response( - context: ToolContext, - users_responses: list[dict[str, Any]], -) -> list[dict[str, Any]]: - """Builds response list for the get_multiple_users_by_usernames_or_emails function.""" - await raise_for_users_not_found(context, users_responses) - - users = [] - - for users_response in users_responses: - users.extend(users_response["users"]) - - return cast(list[dict[str, Any]], users) - - -async def raise_for_users_not_found( - context: ToolContext, users_responses: list[dict[str, Any]] -) -> None: - """Raise an error if any user was not found in the responses.""" - users_not_found, available_users = collect_users_not_found_in_responses(users_responses) - - if users_not_found: - not_found_message = ", ".join(users_not_found) - s = "" if len(users_not_found) == 1 else "s" - message = f"User{s} not found: {not_found_message}" - available_users_prompt = await get_available_users_prompt(context, available_users) - - raise RetryableToolError( - message=message, - developer_message=message, - additional_prompt_content=available_users_prompt, - retry_after_ms=500, - ) - - -def collect_users_not_found_in_responses( - responses: list[dict[str, Any]], -) -> tuple[list[str], list[dict[str, Any]]]: - users_not_found = [] - available_users = [] - - for response in responses: - if response.get("not_found"): - users_not_found.extend(response["not_found"]) - if response.get("available_users"): - available_users = response["available_users"] - - return users_not_found, available_users - - -async def get_available_users_prompt( - context: ToolContext, - available_users: list[dict] | None = None, - limit: int = 100, -) -> str: - try: - from arcade_slack.tools.users import list_users # Avoid circular import - - if isinstance(available_users, list) and available_users: - available_users = [ - user for user in available_users if not is_user_a_bot(SlackUser(**user)) - ] - available_users_str = json.dumps(short_human_users_info(available_users)) - next_cursor = None - potentially_more_users = True - else: - users = await list_users(context, limit=limit, exclude_bots=True) - next_cursor = users["next_cursor"] - available_users_str = json.dumps(short_human_users_info(users["users"])) - potentially_more_users = bool(next_cursor) - - if not potentially_more_users: - return f"The users available are: {available_users_str}" - else: - msg = ( - f"Some of the available users are: {available_users_str}. Potentially more users " - f"can be retrieved by calling the 'Slack.{list_users.__tool_name__}' tool" - ) - if next_cursor: - msg += f" using the next cursor: '{next_cursor}' to continue pagination." - return msg - except Exception as e: - return ( - "The tool tried to retrieve a list of available users, but failed with error: " - f"{type(e).__name__}: {e!s}. Use the 'Slack.{list_users.__tool_name__}' tool " - "to get a list of users." - ) - - -async def gather_with_concurrency_limit( - coroutine_callers: Sequence[AbstractConcurrencySafeCoroutineCaller], - semaphore: asyncio.Semaphore | None = None, - max_concurrent_requests: int = MAX_CONCURRENT_REQUESTS, -) -> list[Any]: - if not semaphore: - semaphore = asyncio.Semaphore(max_concurrent_requests) - - return await asyncio.gather(*[caller(semaphore) for caller in coroutine_callers]) # type: ignore[no-any-return] - - -def cast_user_dict(user: dict[str, Any]) -> dict[str, Any]: - slack_user = SlackUser(**cast(dict, user)) - return dict(**extract_basic_user_info(slack_user)) - - -async def populate_users_in_messages(auth_token: str, messages: list[dict]) -> list[dict]: - if not messages: - return messages - - users = await get_users_from_messages(auth_token, messages) - users_by_id = {user["id"]: {"id": user["id"], "name": user["name"]} for user in users} - - for message in messages: - try: - if "user" not in message or message.get("type") != "message": - continue - - # Message author - message["user"] = users_by_id.get( - message.get("user"), {"id": message["user"], "name": None} - ) - - # User mentions in the message text - text_mentions = re.findall(r"<@([A-Z0-9]+)>", message.get("text", "")) - for user_id in text_mentions: - if user_id in users_by_id: - user = users_by_id.get(user_id, {"id": user_id, "name": None}) - name = user.get("name") - message["text"] = message["text"].replace( - f"<@{user_id}>", f"<@{name} (id:{user_id})>" if name else f"<@{user_id}>" - ) - - # User mentions in reactions - reactions = message.get("reactions") - if isinstance(reactions, list): - for reaction in reactions: - reaction_users = [] - for user_id in reaction.get("users", []): - reaction_users.append( - users_by_id.get(user_id, {"id": user_id, "name": None}) - ) - reaction["users"] = reaction_users - # If any data is missing, just leave the message as it is - except Exception as exc: - logger.exception(exc) # noqa: TRY401 - - return messages - - -async def get_users_from_messages(auth_token: str, messages: list[dict]) -> list[dict[str, Any]]: - if not messages: - return [] - - from arcade_slack.user_retrieval import get_users_by_id # Avoid circular import - - user_ids = get_user_ids_from_messages(messages) - response = await get_users_by_id(auth_token, user_ids) - - return response["users"] - - -def get_user_ids_from_messages(messages: list[dict]) -> list[str]: - if not messages: - return [] - - user_ids = [] - - for message in messages: - if message.get("type") != "message": - continue - - # Message author - user = message.get("user") - if isinstance(user, str) and user: - user_ids.append(user) - - # User mentions in the message text - text = message.get("text") - if isinstance(text, str) and text: - user_ids.extend(re.findall(r"<@([A-Z0-9]+)>", text)) - - # User mentions in reactions - reactions = message.get("reactions") - if isinstance(reactions, list): - for reaction in reactions: - user_ids.extend(reaction.get("users", [])) - - return user_ids diff --git a/toolkits/slack/conftest.py b/toolkits/slack/conftest.py deleted file mode 100644 index d8f95066..00000000 --- a/toolkits/slack/conftest.py +++ /dev/null @@ -1,163 +0,0 @@ -import random -import string -from collections.abc import Callable - -import pytest -from arcade_tdk import ToolAuthorizationContext, ToolContext - - -@pytest.fixture -def mock_context(): - mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 - return ToolContext(authorization=mock_auth) - - -@pytest.fixture -def mock_chat_slack_client(mocker): - mock_client = mocker.patch("arcade_slack.tools.chat.AsyncWebClient", autospec=True) - return mock_client.return_value - - -@pytest.fixture -def mock_users_slack_client(mocker): - mock_client = mocker.patch("arcade_slack.tools.users.AsyncWebClient", autospec=True) - return mock_client.return_value - - -@pytest.fixture -def mock_user_retrieval_slack_client(mocker): - mock_client = mocker.patch("arcade_slack.user_retrieval.AsyncWebClient", autospec=True) - return mock_client.return_value - - -@pytest.fixture -def mock_conversation_retrieval_slack_client(mocker): - mock_client = mocker.patch("arcade_slack.conversation_retrieval.AsyncWebClient", autospec=True) - return mock_client.return_value - - -@pytest.fixture -def mock_message_retrieval_slack_client(mocker): - mock_client = mocker.patch("arcade_slack.message_retrieval.AsyncWebClient", autospec=True) - return mock_client.return_value - - -@pytest.fixture -def random_str_factory(): - def random_str_factory(length: int = 10): - return "".join(random.choices(string.ascii_letters + string.digits, k=length)) # noqa: S311 - - return random_str_factory - - -@pytest.fixture -def random_ts_factory(): - def random_ts_factory(): - return f"{random.uniform(1735689600.000000, 1751327999.999999)}" # noqa: S311 - - return random_ts_factory - - -@pytest.fixture -def dummy_channel_factory(random_str_factory: Callable[[int], str]): - def dummy_channel_factory( - id_: str | None = None, - name: str | None = None, - is_member: bool = True, - is_private: bool = False, - is_archived: bool = False, - is_channel: bool = False, - is_im: bool = False, - is_mpim: bool = False, - num_members: int | None = None, - user: str | None = None, - is_user_deleted: bool = False, - ): - channel = { - "id": id_ or f"channel_id_{random_str_factory()}", - "is_member": is_member, - "is_private": is_private, - "is_archived": is_archived, - } - - if name or is_channel or is_mpim: - channel["name"] = name or f"channel_name_{random_str_factory()}" - - if is_channel: - channel["is_channel"] = True - if is_im: - channel["is_im"] = True - if is_mpim: - channel["is_group"] = True - if num_members: - channel["num_members"] = num_members - if user or is_im: - channel["user"] = user or f"user_id_{random_str_factory()}" - if is_user_deleted: - channel["is_user_deleted"] = is_user_deleted - - return channel - - return dummy_channel_factory - - -@pytest.fixture -def dummy_user_factory(random_str_factory: Callable[[int], str]): - def dummy_user_factory( - id_: str | None = None, - name: str | None = None, - email: str | None = None, - is_bot: bool = False, - ): - return { - "id": id_ or random_str_factory(), - "name": name or random_str_factory(), - "profile": { - "email": email or f"{random_str_factory()}@{random_str_factory()}.com", - }, - "is_bot": is_bot, - } - - return dummy_user_factory - - -@pytest.fixture -def dummy_reaction_factory(random_str_factory): - def reaction_factory( - name: str | None = None, - user_ids: list[str] | None = None, - count: int | None = None, - ): - count = count or random.randint(1, 10) # noqa: S311 - if user_ids: - count = len(user_ids) - return { - "count": count, - "name": name or random_str_factory(), - "users": user_ids or [random_str_factory() for _ in range(count)], - } - - return reaction_factory - - -@pytest.fixture -def dummy_message_factory(random_str_factory, random_ts_factory): - def message_factory( - user_id: str | None = None, - text: str | None = None, - reactions: list[dict] | None = None, - type_: str = "message", - ts: float | None = None, - ): - message = { - "user": user_id or random_str_factory(), - "text": text or random_str_factory(), - "type": type_, - "ts": ts or random_ts_factory(), - } - - if reactions: - message["reactions"] = reactions - return message - - return message_factory diff --git a/toolkits/slack/evals/chat/eval_get_metadata.py b/toolkits/slack/evals/chat/eval_get_metadata.py deleted file mode 100644 index 33661274..00000000 --- a/toolkits/slack/evals/chat/eval_get_metadata.py +++ /dev/null @@ -1,206 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.tools.chat import get_conversation_metadata - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def get_conversations_metadata_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting conversations metadata.""" - suite = EvalSuite( - name="Slack Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to get information from conversations, users, etc.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get channel metadata by name", - user_message="Get the metadata of the #general channel", - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - suite.add_case( - name="Get conversation metadata by id", - user_message="Get the metadata of the conversation with id '1234567890'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - suite.add_case( - name="Get conversation metadata by username mentioning DM", - user_message="get the metadata of the DM with janedoe", - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["janedoe"], - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - suite.add_case( - name="Get conversation metadata by username mentioning IM", - user_message="get metadata about my IM with janedoe", - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["janedoe"], - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - suite.add_case( - name="Get conversation metadata by email mentioning DM", - user_message="get the metadata of the DM with jane.doe@acme.com", - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": ["jane.doe@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - suite.add_case( - name="Get conversation metadata by email mentioning IM", - user_message="get the metadata of the IM with jane.doe@acme.com", - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": ["jane.doe@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - suite.add_case( - name="Get conversation metadata by mixed user ID, email, and username", - user_message=( - "get the metadata of the multi-person conversation I have with these users together: " - "janedoe, john@acme.com, and U0123456789" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_conversation_metadata, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": ["U0123456789"], - "usernames": ["janedoe"], - "emails": ["john@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=1 / 5), - BinaryCritic(critic_field="channel_name", weight=1 / 5), - BinaryCritic(critic_field="user_ids", weight=1 / 5), - BinaryCritic(critic_field="usernames", weight=1 / 5), - BinaryCritic(critic_field="emails", weight=1 / 5), - ], - ) - - return suite diff --git a/toolkits/slack/evals/chat/eval_get_users_in_conversation.py b/toolkits/slack/evals/chat/eval_get_users_in_conversation.py deleted file mode 100644 index 434454d2..00000000 --- a/toolkits/slack/evals/chat/eval_get_users_in_conversation.py +++ /dev/null @@ -1,81 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.tools.chat import get_users_in_conversation - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def get_users_in_conversation_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting conversations members.""" - suite = EvalSuite( - name="Slack Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to send messages and get information from conversations, users, etc.", - catalog=catalog, - rubric=rubric, - ) - - user_messages = [ - "Get the members of the #general channel", - "Get the users in the #general channel", - "Get a list of people in the #general channel", - "Get a list of people in the general channel", - "Show me who's in the #general channel", - "Who is in the general channel?", - ] - - for user_message in user_messages: - suite.add_case( - name=f"Get users in channel by channel name: {user_message}", - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_in_conversation, - args={ - "conversation_id": None, - "channel_name": "general", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.4), - BinaryCritic(critic_field="channel_name", weight=0.6), - ], - ) - - suite.add_case( - name="Get users in conversation by conversation id", - user_message="Get the users in the conversation with id '1234567890'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_in_conversation, - args={ - "conversation_id": "1234567890", - "channel_name": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.6), - BinaryCritic(critic_field="channel_name", weight=0.4), - ], - ) - - return suite diff --git a/toolkits/slack/evals/chat/eval_list_conversations.py b/toolkits/slack/evals/chat/eval_list_conversations.py deleted file mode 100644 index fcb80c66..00000000 --- a/toolkits/slack/evals/chat/eval_list_conversations.py +++ /dev/null @@ -1,175 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.models import ConversationType -from arcade_slack.tools.chat import list_conversations - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def list_conversations_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools listing conversations.""" - suite = EvalSuite( - name="Slack Messaging Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to send messages and get information from conversations, users, etc.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="List all conversations I am a member of", - user_message="List all conversations I am a member of", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - suite.add_case( - name="List 10 conversations I am a member of", - user_message="List 10 conversations I am a member of", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": None, - "limit": 10, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="List all public channels", - user_message="List all public channels", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": [ConversationType.PUBLIC_CHANNEL.value], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - suite.add_case( - name="List all private channels", - user_message="List all private channels", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": [ConversationType.PRIVATE_CHANNEL.value], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - suite.add_case( - name="List all public and private channels", - user_message="List all public and private channels", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": [ - ConversationType.PUBLIC_CHANNEL.value, - ConversationType.PRIVATE_CHANNEL.value, - ], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - suite.add_case( - name="List direct message channels", - user_message="List direct message channels", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": [ - ConversationType.DIRECT_MESSAGE.value, - ], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - suite.add_case( - name="List group direct message channels", - user_message="List group direct message channels", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": [ - ConversationType.MULTI_PERSON_DIRECT_MESSAGE.value, - ], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - suite.add_case( - name="List my multi-person conversations", - user_message="List my multi-person conversations", - expected_tool_calls=[ - ExpectedToolCall( - func=list_conversations, - args={ - "conversation_types": [ - ConversationType.MULTI_PERSON_DIRECT_MESSAGE.value, - ], - }, - ) - ], - critics=[ - BinaryCritic(critic_field="conversation_types", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/slack/evals/chat/messages/eval_get_channel_messages.py b/toolkits/slack/evals/chat/messages/eval_get_channel_messages.py deleted file mode 100644 index ffa6b036..00000000 --- a/toolkits/slack/evals/chat/messages/eval_get_channel_messages.py +++ /dev/null @@ -1,622 +0,0 @@ -import json -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.critics import RelativeTimeBinaryCritic -from arcade_slack.tools.chat import get_messages - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def get_messages_in_channel_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting messages in channels.""" - suite = EvalSuite( - name="Slack Chat Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to send messages and get information from conversations, users, etc.", - catalog=catalog, - rubric=rubric, - ) - - no_arguments_user_messages_by_channel_name = [ - "what are the latest messages in the #general channel", - "show me the messages in the general channel", - "list the messages in the #general channel", - "list the messages in the general channel", - ] - - for i, user_message in enumerate(no_arguments_user_messages_by_channel_name): - suite.add_case( - name=f"Get messages in conversation by name {i}: '{user_message}'", - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.6), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - ], - ) - - no_arguments_user_messages_by_conversation_id = [ - "Get the history of the conversation with id '1234567890'", - "Get the history of the conversation with id '1234567890'", - "list the messages in the conversation with id '1234567890'", - "list the messages in the conversation with id '1234567890'", - ] - - for user_message in no_arguments_user_messages_by_conversation_id: - suite.add_case( - name=f"Get conversation history by id: '{user_message}'", - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.6), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - ], - ) - - suite.add_case( - name="Get conversation history with limit by name", - user_message="Get the last 10 messages in the #general channel", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "limit": 10, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.3), - ], - ) - - suite.add_case( - name="Get conversation history with limit by id", - user_message="Get the last 25 messages in the conversation with id '1234567890'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "limit": 25, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.3), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.3), - ], - ) - - # Relative time eval cases by id - - suite.add_case( - name="Get conversation history oldest relative by id (2 days ago)", - user_message="Get the messages in the conversation with id '1234567890' starting 2 days ago", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "02:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.3), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - suite.add_case( - name="Get conversation history oldest and latest relative by id", - user_message="Get the messages in the conversation with id '1234567890' from 2 days ago to 3 hours ago", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "02:00:00", - "latest_relative": "00:03:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.2), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.2), - RelativeTimeBinaryCritic(critic_field="latest_relative", weight=0.2), - ], - ) - - suite.add_case( - name="Get conversation history oldest relative by id (1 week ago)", - user_message="Get the messages in the conversation with id '1234567890' starting 1 week ago", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "07:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.3), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - suite.add_case( - name="Get conversation history oldest relative by id (yesterday)", - user_message="Get the messages in the conversation with id '1234567890' from yesterday", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "01:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.3), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - # Relative time eval cases by name - - suite.add_case( - name="Get conversation history oldest relative by name (2 days ago)", - user_message="Get the messages in the #general channel starting 2 days ago", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "02:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - suite.add_case( - name="Get conversation history oldest and latest relative by name", - user_message="Get the messages in the #general channel from 2 days ago to 3 hours ago", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "02:00:00", - "latest_relative": "00:03:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.2), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.2), - RelativeTimeBinaryCritic(critic_field="latest_relative", weight=0.2), - ], - ) - - suite.add_case( - name="Get conversation history oldest relative by name (yesterday)", - user_message="Get the messages in the #general channel from yesterday", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "01:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - suite.add_case( - name="Get conversation history oldest relative by name (last week)", - user_message="Get the messages in the #general channel from last week", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_relative": "07:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - # Absolute time eval cases by id - - suite.add_case( - name="Get conversation history oldest absolute by id (on a specific date)", - user_message="Get the messages in the conversation with id '1234567890' from 2025-01-20", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_datetime": "2025-01-20 00:00:00", - "latest_datetime": "2025-01-20 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.2), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - ], - ) - - suite.add_case( - name="Get conversation history oldest absolute by id (between a date range)", - user_message="Get the messages in the conversation with id '1234567890' from 2025-01-20 to 2025-01-25", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": "1234567890", - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_datetime": "2025-01-20 00:00:00", - "latest_datetime": "2025-01-25 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.2), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - ], - ) - - suite.add_case( - name="Get conversation history oldest absolute by name (on a specific date)", - user_message="Get the messages in the #general channel from 2025-01-20", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_datetime": "2025-01-20 00:00:00", - "latest_datetime": "2025-01-20 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.2), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - # We use a timedelta of 10 seconds because sometimes the LLM will select the limit - # date at 23:59:59, other times it'll select the next day at 00:00:00. - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(seconds=10) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(seconds=10) - ), - ], - ) - - suite.add_case( - name="Get conversation history oldest absolute by name (between a date range)", - user_message="Get the messages in the #general channel from 2025-01-20 to 2025-01-25", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "oldest_datetime": "2025-01-20 00:00:00", - "latest_datetime": "2025-01-25 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.2), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - # We use a timedelta of 10 seconds because sometimes the LLM will select the limit - # date at 23:59:59, other times it'll select the next day at 00:00:00. - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(seconds=10) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(seconds=10) - ), - ], - ) - - # Eval case for pagination - - suite.add_case( - name="Get conversation history with pagination", - user_message="get the next 5 messages", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "usernames": None, - "emails": None, - "limit": 5, - "next_cursor": "dXNlcjpVsDjzOTZGVDlQRA==", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.2), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="next_cursor", weight=0.2), - BinaryCritic(critic_field="limit", weight=0.2), - ], - additional_messages=[ - {"role": "user", "content": "Get the last 2 messages on the general channel"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Slack_GetConversationHistoryByName", - "arguments": json.dumps({ - "conversation_name": "general", - "limit": 2, - }), - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "messages": [ - { - "blocks": [ - { - "block_id": "abc123", - "elements": [ - { - "elements": [ - { - "text": "Almost there, Boss, need to get some evals in!", - "type": "text", - } - ], - "type": "rich_text_section", - } - ], - "type": "rich_text", - } - ], - "client_msg_id": "msg_id_0", - "datetime_timestamp": "2025-01-21 16:59:55", - "team": "617263616465207465616D20697320617420626F7373206C6576656C", - "text": "Almost there, Boss, need to get some evals in!", - "ts": "1737507595.598529", - "type": "message", - "user": "77686F2069732074686520626F73733F", - }, - { - "blocks": [ - { - "block_id": "xyz456", - "elements": [ - { - "elements": [ - { - "text": "hey, are the Slack Tools ready yet?", - "type": "text", - } - ], - "type": "rich_text_section", - } - ], - "type": "rich_text", - } - ], - "client_msg_id": "msg_id_1", - "datetime_timestamp": "2025-01-21 16:57:35", - "team": "617263616465207465616D20697320617420626F7373206C6576656C", - "text": "hey, are the Slack Tools ready yet?", - "ts": "1737507595.598529", - "type": "message", - "user": "73616D2069732074686520626F7373", - }, - ], - "next_cursor": "dXNlcjpVsDjzOTZGVDlQRA==", - }), - "tool_call_id": "call_1", - "name": "Slack_GetConversationHistoryByName", - }, - { - "role": "assistant", - "content": 'Here are the last 2 messages from the general channel:\n\n1. **User:** 77686F2069732074686520626F73733F \n **Message:** "Almost there, Boss, need to get some evals in!" \n **Timestamp:** 2025-01-21 16:59:55\n\n2. **User:** 73616D2069732074686520626F7373 \n **Message:** "hey, are the Slack Tools ready yet?" \n **Timestamp:** 2025-01-21 16:57:35', - }, - ], - ) - - return suite diff --git a/toolkits/slack/evals/chat/messages/eval_get_dm_messages.py b/toolkits/slack/evals/chat/messages/eval_get_dm_messages.py deleted file mode 100644 index 88156592..00000000 --- a/toolkits/slack/evals/chat/messages/eval_get_dm_messages.py +++ /dev/null @@ -1,191 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.critics import RelativeTimeBinaryCritic -from arcade_slack.tools.chat import get_messages - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def get_messages_in_direct_message_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting messages in direct messages.""" - suite = EvalSuite( - name="Slack Chat Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to send messages and get information from conversations, users, etc.", - catalog=catalog, - rubric=rubric, - ) - - no_arguments_user_messages_by_username = [ - "what are the latest messages I exchanged with janedoe", - "show my messages with janedoe on Slack", - "get the messages I exchanged with janedoe", - "get the message history with janedoe", - ] - - for i, user_message in enumerate(no_arguments_user_messages_by_username): - suite.add_case( - name=f"{user_message} [{i}]", - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["janedoe"], - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.6), - BinaryCritic(critic_field="emails", weight=0.1), - ], - ) - - no_arguments_user_messages_by_email = [ - "what are the latest messages I exchanged with jane.doe@acme.com", - "show my messages with jane.doe@acme.com on Slack", - ] - - for i, user_message in enumerate(no_arguments_user_messages_by_email): - suite.add_case( - name=f"{user_message} [{i}]", - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": ["jane.doe@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.6), - ], - ) - - suite.add_case( - name="get messages in direct conversation by username (on a specific date)", - user_message="get the messages I exchanged with janedoe on 2025-01-31", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["janedoe"], - "emails": None, - "oldest_datetime": "2025-01-31 00:00:00", - "latest_datetime": "2025-01-31 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.2), - BinaryCritic(critic_field="emails", weight=0.1), - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - ], - ) - - suite.add_case( - name="get messages in direct conversation by email (on a specific date)", - user_message="get the messages I exchanged with jane.doe@acme.com on 2025-01-31", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": None, - "emails": ["jane.doe@acme.com"], - "oldest_datetime": "2025-01-31 00:00:00", - "latest_datetime": "2025-01-31 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.2), - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - ], - ) - - suite.add_case( - name="Get conversation history oldest relative by username (2 days ago)", - user_message="Get the messages I exchanged with janedoe starting 2 days ago", - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["janedoe"], - "emails": None, - "oldest_relative": "02:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.3), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - return suite diff --git a/toolkits/slack/evals/chat/messages/eval_get_mpim_messages.py b/toolkits/slack/evals/chat/messages/eval_get_mpim_messages.py deleted file mode 100644 index 028e4ad1..00000000 --- a/toolkits/slack/evals/chat/messages/eval_get_mpim_messages.py +++ /dev/null @@ -1,165 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - BinaryCritic, - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.critics import RelativeTimeBinaryCritic -from arcade_slack.tools.chat import get_messages - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def get_messages_in_multi_person_direct_message_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting messages in multi-person direct messages.""" - suite = EvalSuite( - name="Slack Chat Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to send messages and get information from conversations, users, etc.", - catalog=catalog, - rubric=rubric, - ) - - no_arguments_user_messages_by_username = [ - ( - "what are the latest messages I exchanged in the MPIM " - "with the usernames john, ryan, and jennifer" - ), - ("show the messages in the MPIM with the usernames john, ryan, and jennifer on Slack"), - ("list the messages I exchanged in the MPIM with the usernames john, ryan, and jennifer"), - ("list the message history in the MPIM with the usernames john, ryan, and jennifer"), - ] - - for i, user_message in enumerate(no_arguments_user_messages_by_username): - suite.add_case( - name=f"{user_message} [{i}]", - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["john", "ryan", "jennifer"], - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.6), - BinaryCritic(critic_field="emails", weight=0.1), - ], - ) - - suite.add_case( - name="get messages in multi person direct conversation with mixed usernames and emails", - user_message=( - "get the messages I exchanged in the mpim with " - "the usernames john, ryan, and jennifer@acme.com" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["john", "ryan"], - "emails": ["jennifer@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.35), - BinaryCritic(critic_field="emails", weight=0.35), - ], - ) - - suite.add_case( - name="get messages in direct conversation by username (on a specific date)", - user_message=( - "get the messages I exchanged in the mpim with " - "the usernames john, ryan, and jennifer on 2025-01-31" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["john", "ryan", "jennifer"], - "emails": None, - "oldest_datetime": "2025-01-31 00:00:00", - "latest_datetime": "2025-01-31 23:59:59", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.2), - BinaryCritic(critic_field="emails", weight=0.1), - DatetimeCritic( - critic_field="oldest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - DatetimeCritic( - critic_field="latest_datetime", weight=0.2, max_difference=timedelta(minutes=2) - ), - ], - ) - - suite.add_case( - name="Get conversation history oldest relative by username (2 days ago)", - user_message=( - "Get the messages I exchanged in the MPIM with " - "the usernames john, ryan, and jennifer starting 2 days ago" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=get_messages, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "usernames": ["john", "ryan", "jennifer"], - "emails": None, - "oldest_relative": "02:00:00", - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.3), - BinaryCritic(critic_field="emails", weight=0.1), - RelativeTimeBinaryCritic(critic_field="oldest_relative", weight=0.3), - ], - ) - - return suite diff --git a/toolkits/slack/evals/chat/messages/eval_send_messages.py b/toolkits/slack/evals/chat/messages/eval_send_messages.py deleted file mode 100644 index 70a40688..00000000 --- a/toolkits/slack/evals/chat/messages/eval_send_messages.py +++ /dev/null @@ -1,279 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.tools.chat import send_message - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def send_message_eval_suite() -> EvalSuite: - """Create an evaluation suite for Slack messaging tools.""" - suite = EvalSuite( - name="Slack Messaging Tools Evaluation", - system_message="You are an AI assistant that can send direct messages and post messages to channels in Slack using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Send DM to User Scenarios - suite.add_case( - name="Send DM to user with clear username", - user_message="Send a direct message to johndoe saying 'Hello, can we meet at 3 PM?'", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "message": "Hello, can we meet at 3 PM?", - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "emails": None, - "usernames": ["johndoe"], - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="message", weight=0.3, similarity_threshold=0.9), - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.3), - ], - ) - - suite.add_case( - name="Send message to channel with clear name", - user_message="Post 'The new feature is now live!' in the #announcements channel", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "message": "The new feature is now live!", - "conversation_id": None, - "channel_name": "announcements", - "user_ids": None, - "emails": None, - "usernames": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="message", weight=0.3, similarity_threshold=0.9), - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - ], - ) - - suite.add_case( - name="Send message to channel with ambiguous name", - user_message="Inform the team in the general channel about the upcoming maintenance", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "message": "Attention team: There will be upcoming maintenance. Please save your work and expect some downtime.", - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "emails": None, - "usernames": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="message", weight=0.3, similarity_threshold=0.9), - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - ], - ) - - # Adversarial Scenarios - suite.add_case( - name="Ambiguous between DM and channel message", - user_message="general", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "message": "Great job on the presentation!", - "conversation_id": None, - "channel_name": "general", - "user_ids": None, - "emails": None, - "usernames": None, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="message", weight=0.3, similarity_threshold=0.9), - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - ], - additional_messages=[ - {"role": "user", "content": "Send 'Great job on the presentation!' to the team"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "Slack_ListConversationsMetadata", - "arguments": '{"limit":20, "conversation_types":["public_channel"]}', - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "conversations": [ - { - "conversation_type": "public_channel", - "id": "channel1", - "is_archived": False, - "is_member": True, - "is_private": False, - "name": "random", - "num_members": 999, - "purpose": "Random stuff", - }, - { - "conversation_type": "public_channel", - "id": "channel2", - "is_archived": False, - "is_member": True, - "is_private": False, - "name": "general", - "num_members": 999, - "purpose": "Just a general channel", - }, - ], - "next_cursor": "", - }), - "tool_call_id": "call_1", - "name": "Slack_ListPublicChannelsMetadata", - }, - { - "role": "assistant", - "content": 'To send the message "Great job on the presentation!" to the team, please let me know which Slack channel you\'d like to use:\n\n1. #random\n2. #general\n\nPlease let me know your choice!', - }, - ], - ) - - suite.add_case( - name="Multiple recipients in DM request", - user_message="Send DMs to the users 'alice' and 'bob' about rescheduling our meeting tomorrow. I have too much work to do.", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "emails": None, - "usernames": ["alice"], - }, - ), - ExpectedToolCall( - func=send_message, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "emails": None, - "usernames": ["bob"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.6), - ], - ) - - suite.add_case( - name="Multiple recipients in MPIM request", - user_message="Send a message to the users 'alice' and 'bob' about rescheduling our meeting tomorrow. I have too much work to do.", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "conversation_id": None, - "channel_name": None, - "user_ids": None, - "emails": None, - "usernames": ["alice", "bob"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.1), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.6), - ], - ) - - suite.add_case( - name="Channel name similar to username", - user_message="Post 'sounds great!' in john-project channel", - expected_tool_calls=[ - ExpectedToolCall( - func=send_message, - args={ - "conversation_id": None, - "channel_name": "john-project", - "user_ids": None, - "emails": None, - "usernames": None, - "message": "Sounds great!", - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="message", weight=0.3, similarity_threshold=0.9), - BinaryCritic(critic_field="conversation_id", weight=0.1), - BinaryCritic(critic_field="channel_name", weight=0.3), - BinaryCritic(critic_field="user_ids", weight=0.1), - BinaryCritic(critic_field="emails", weight=0.1), - BinaryCritic(critic_field="usernames", weight=0.1), - ], - ) - - return suite diff --git a/toolkits/slack/evals/eval_users.py b/toolkits/slack/evals/eval_users.py deleted file mode 100644 index 9b724145..00000000 --- a/toolkits/slack/evals/eval_users.py +++ /dev/null @@ -1,259 +0,0 @@ -import json - -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_slack -from arcade_slack.tools.users import ( - get_users_info, - list_users, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.8, - warn_threshold=0.9, -) - - -catalog = ToolCatalog() -# Register the Slack tools -catalog.add_module(arcade_slack) - - -@tool_eval() -def get_user_info_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools getting user info by id.""" - suite = EvalSuite( - name="Slack Users Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to get information about users.", - catalog=catalog, - rubric=rubric, - ) - - expected_user_id = "U12345" - - get_user_info_by_id_eval_cases = [ - ( - "get user info by id", - f"What is the name of the user with id {expected_user_id}?", - ), - ( - "get user info by id", - f"get information about the user with id {expected_user_id}", - ), - ] - - for name, user_message in get_user_info_by_id_eval_cases: - suite.add_case( - name=name, - user_message=user_message, - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_info, - args={ - "user_ids": [expected_user_id], - "usernames": None, - "emails": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="user_ids", weight=1 / 3), - BinaryCritic(critic_field="usernames", weight=1 / 3), - BinaryCritic(critic_field="emails", weight=1 / 3), - ], - ) - - suite.add_case( - name="get user by username", - user_message="get the user 'johndoe'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_info, - args={ - "usernames": ["johndoe"], - "user_ids": None, - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="usernames", weight=1 / 3), - BinaryCritic(critic_field="user_ids", weight=1 / 3), - BinaryCritic(critic_field="emails", weight=1 / 3), - ], - ) - - suite.add_case( - name="get user by email", - user_message="get the user 'john.doe@acme.com'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_info, - args={ - "usernames": None, - "user_ids": None, - "emails": ["john.doe@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="usernames", weight=1 / 3), - BinaryCritic(critic_field="user_ids", weight=1 / 3), - BinaryCritic(critic_field="emails", weight=1 / 3), - ], - ) - - suite.add_case( - name="get multiple users by username", - user_message="get the users with the usernames 'johndoe' and 'foobar'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_info, - args={ - "usernames": ["johndoe", "foobar"], - "user_ids": None, - "emails": None, - }, - ), - ], - critics=[ - BinaryCritic(critic_field="usernames", weight=1 / 3), - BinaryCritic(critic_field="user_ids", weight=1 / 3), - BinaryCritic(critic_field="emails", weight=1 / 3), - ], - ) - - suite.add_case( - name="get multiple users by email", - user_message="get the users with the emails 'john.doe@acme.com' and 'jane.doe@acme.com'", - expected_tool_calls=[ - ExpectedToolCall( - func=get_users_info, - args={ - "usernames": None, - "user_ids": None, - "emails": ["john.doe@acme.com", "jane.doe@acme.com"], - }, - ), - ], - critics=[ - BinaryCritic(critic_field="usernames", weight=1 / 3), - BinaryCritic(critic_field="user_ids", weight=1 / 3), - BinaryCritic(critic_field="emails", weight=1 / 3), - ], - ) - - return suite - - -@tool_eval() -def list_users_eval_suite() -> EvalSuite: - """Create an evaluation suite for tools listing users.""" - suite = EvalSuite( - name="Slack Users Tools Evaluation", - system_message="You are an AI assistant that can interact with Slack to get information about users.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="list users", - user_message="list all users on my slack workspace", - expected_tool_calls=[ - ExpectedToolCall(func=list_users, args={}), - ], - ) - - suite.add_case( - name="list users without bots", - user_message="list all users on my slack workspace, except bots", - expected_tool_calls=[ - ExpectedToolCall(func=list_users, args={"exclude_bots": True}), - ], - critics=[ - BinaryCritic(critic_field="exclude_bots", weight=1.0), - ], - ) - - suite.add_case( - name="list 10 users without bots", - user_message="get a list of 10 users on my slack workspace, except bots", - expected_tool_calls=[ - ExpectedToolCall(func=list_users, args={"exclude_bots": True, "limit": 10}), - ], - critics=[ - BinaryCritic(critic_field="exclude_bots", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.5), - ], - ) - - suite.add_case( - name="test list users with pagination", - user_message="get the next 5 users", - expected_tool_calls=[ - ExpectedToolCall( - func=list_users, - args={"limit": 5, "next_cursor": "dXNlcjpVsDjzOTZGVDlQRA=="}, - ), - ], - critics=[ - BinaryCritic(critic_field="limit", weight=0.5), - BinaryCritic(critic_field="next_cursor", weight=0.5), - ], - additional_messages=[ - {"role": "user", "content": "get a list of 2 users from my slack workspace"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": {"name": "Slack_ListUsers", "arguments": '{"limit":2}'}, - } - ], - }, - { - "role": "tool", - "content": json.dumps({ - "next_cursor": "dXNlcjpVsDjzOTZGVDlQRA==", - "users": [ - { - "display_name": "John Doe", - "email": "john.doe@acme.com", - "id": "U123", - "is_bot": False, - "name": "john.doe", - "real_name": "John Doe", - "timezone": "America/Los_Angeles", - }, - { - "display_name": "Jane Doe", - "email": "jane.doe@acme.com", - "id": "U124", - "is_bot": False, - "name": "jane.doe", - "real_name": "Jane Doe", - "timezone": "America/Los_Angeles", - }, - ], - }), - "tool_call_id": "call_1", - "name": "Slack_ListUsers", - }, - { - "role": "assistant", - "content": "Here are two users from your Slack workspace:\n\n1. **John Doe**\n - Display Name: John Doe\n - Email: john.doe@acme.com\n - Timezone: America/Los_Angeles\n\n2. **Jane Doe**\n - Display Name: Jane Doe\n - Email: jane.doe@acme.com\n - Timezone: America/Los_Angeles\n\nIf you need more information or additional users, feel free to ask!", - }, - ], - ) - - return suite diff --git a/toolkits/slack/pyproject.toml b/toolkits/slack/pyproject.toml deleted file mode 100644 index 4445a1b0..00000000 --- a/toolkits/slack/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_slack" -version = "0.5.2" -description = "Arcade.dev LLM tools for Slack" -requires-python = ">=3.10" -dependencies = [ "aiodns>=1.0,<2.0.0", "typing; python_version < '3.7'", "aiohttp>=3.7.3,<4.0.0", "arcade-tdk>=2.0.0,<3.0.0", "slack-sdk>=3.31.0,<4.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_slack/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_slack",] diff --git a/toolkits/slack/tests/__init__.py b/toolkits/slack/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/slack/tests/test_chat.py b/toolkits/slack/tests/test_chat.py deleted file mode 100644 index 05558d3a..00000000 --- a/toolkits/slack/tests/test_chat.py +++ /dev/null @@ -1,1168 +0,0 @@ -import json -from datetime import datetime, timezone -from unittest.mock import Mock, call, patch - -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from slack_sdk.errors import SlackApiError -from slack_sdk.web.async_slack_response import AsyncSlackResponse - -from arcade_slack.constants import MAX_PAGINATION_SIZE_LIMIT -from arcade_slack.models import ConversationType, ConversationTypeSlackName -from arcade_slack.tools.chat import ( - get_conversation_metadata, - get_messages, - get_users_in_conversation, - list_conversations, - send_message, -) -from arcade_slack.utils import cast_user_dict, extract_conversation_metadata - - -@pytest.fixture -def mock_list_conversations(mocker): - return mocker.patch("arcade_slack.tools.chat.list_conversations", autospec=True) - - -@pytest.fixture -def mock_channel_info() -> dict: - return {"name": "general", "id": "C12345", "is_member": True, "is_channel": True} - - -@pytest.mark.asyncio -async def test_send_message_to_conversation_id( - mock_context, - mock_chat_slack_client, -): - mock_slack_response = Mock(spec=AsyncSlackResponse) - mock_slack_response.data = {"ok": True} - mock_chat_slack_client.chat_postMessage.return_value = mock_slack_response - - response = await send_message(mock_context, conversation_id="abc123", message="Hello!") - - assert response["success"] is True - assert response["data"]["ok"] is True - mock_chat_slack_client.chat_postMessage.assert_called_once_with(channel="abc123", text="Hello!") - - -@pytest.mark.asyncio -async def test_send_message_to_username( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, -): - mock_chat_slack_client.auth_test.return_value = {"ok": True, "user_id": "current_user_id"} - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [{"name": "foo", "id": "bar"}], - "response_metadata": {"next_cursor": "123"}, - }, - { - "ok": True, - "members": [{"name": "foobar", "id": "foobar_user_id"}], - }, - ] - mock_chat_slack_client.conversations_open.return_value = { - "ok": True, - "channel": { - "id": "conversation_id", - "is_im": True, - }, - } - mock_slack_response = Mock(spec=AsyncSlackResponse) - mock_slack_response.data = {"ok": True} - mock_chat_slack_client.chat_postMessage.return_value = mock_slack_response - - response = await send_message( - context=mock_context, - usernames=["foobar"], - message="Hello, world!", - ) - - assert response["success"] is True - assert response["data"]["ok"] is True - - mock_chat_slack_client.auth_test.assert_called_once() - assert mock_user_retrieval_slack_client.users_list.call_count == 2 - mock_chat_slack_client.conversations_open.assert_called_once_with( - users=[ - "current_user_id", - "foobar_user_id", - ], - return_im=True, - ) - mock_chat_slack_client.chat_postMessage.assert_called_once_with( - channel="conversation_id", - text="Hello, world!", - ) - - -@pytest.mark.asyncio -async def test_send_dm_to_inexistent_user( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, -): - mock_chat_slack_client.auth_test.return_value = {"ok": True, "user_id": "current_user_id"} - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [{"name": "foo", "id": "bar"}], - } - - with pytest.raises(RetryableToolError) as error: - await send_message(mock_context, usernames=["inexistent_user"], message="Hello!") - - assert "inexistent_user" in error.value.message - assert "foo" in error.value.additional_prompt_content - assert "bar" in error.value.additional_prompt_content - mock_user_retrieval_slack_client.users_list.assert_called_once() - mock_chat_slack_client.conversations_open.assert_not_called() - mock_chat_slack_client.chat_postMessage.assert_not_called() - - -@pytest.mark.asyncio -async def test_send_message_to_channel_success( - mock_context, - mock_chat_slack_client, - mock_conversation_retrieval_slack_client, -): - mock_conversation_retrieval_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [{"id": "channel_id", "name": "general", "is_member": True, "is_group": True}], - } - mock_slack_response = Mock(spec=AsyncSlackResponse) - mock_slack_response.data = {"ok": True} - mock_chat_slack_client.chat_postMessage.return_value = mock_slack_response - - response = await send_message(mock_context, channel_name="general", message="Hello, channel!") - - assert response["success"] is True - assert response["data"]["ok"] is True - mock_conversation_retrieval_slack_client.conversations_list.assert_called_once() - mock_chat_slack_client.chat_postMessage.assert_called_once_with( - channel="channel_id", text="Hello, channel!" - ) - - -@pytest.mark.asyncio -async def test_send_message_to_inexistent_channel( - mock_context, - mock_chat_slack_client, - mock_conversation_retrieval_slack_client, -): - mock_conversation_retrieval_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [ - { - "id": "another_channel_id", - "name": "another_channel", - "is_member": True, - "is_group": True, - } - ], - } - - with pytest.raises(RetryableToolError) as error: - await send_message(mock_context, channel_name="inexistent_channel", message="Hello!") - - assert "inexistent_channel" in error.value.message - assert "another_channel" in error.value.additional_prompt_content - assert "another_channel_id" in error.value.additional_prompt_content - - mock_conversation_retrieval_slack_client.conversations_list.assert_called_once() - mock_chat_slack_client.chat_postMessage.assert_not_called() - - -@pytest.mark.asyncio -async def test_list_conversations_metadata_with_default_args( - mock_context, mock_chat_slack_client, mock_channel_info -): - mock_chat_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [mock_channel_info], - } - - response = await list_conversations(mock_context) - - assert response["conversations"] == [extract_conversation_metadata(mock_channel_info)] - assert response["next_cursor"] is None - - mock_chat_slack_client.conversations_list.assert_called_once_with( - types=None, - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ) - - -@pytest.mark.asyncio -async def test_list_conversations_metadata_with_more_pages( - mock_context, mock_chat_slack_client, dummy_channel_factory, random_str_factory -): - channel1 = dummy_channel_factory(is_channel=True) - channel2 = dummy_channel_factory(is_im=True) - channel3 = dummy_channel_factory(is_mpim=True) - next_cursor = random_str_factory() - - mock_chat_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [channel1, channel2, channel3], - "response_metadata": {"next_cursor": next_cursor}, - } - - response = await list_conversations(mock_context, limit=3) - - assert response["conversations"] == [ - extract_conversation_metadata(channel1), - extract_conversation_metadata(channel2), - extract_conversation_metadata(channel3), - ] - assert response["next_cursor"] == next_cursor - - -@pytest.mark.asyncio -async def test_list_conversations_metadata_filtering_single_conversation_type( - mock_context, mock_chat_slack_client, mock_channel_info -): - mock_chat_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [mock_channel_info], - } - - response = await list_conversations( - mock_context, conversation_types=[ConversationType.PUBLIC_CHANNEL] - ) - - assert response["conversations"] == [extract_conversation_metadata(mock_channel_info)] - assert response["next_cursor"] is None - - mock_chat_slack_client.conversations_list.assert_called_once_with( - types=ConversationTypeSlackName.PUBLIC_CHANNEL.value, - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ) - - -@pytest.mark.asyncio -async def test_list_conversations_metadata_filtering_multiple_conversation_types( - mock_context, mock_chat_slack_client, mock_channel_info -): - mock_chat_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [mock_channel_info], - } - - response = await list_conversations( - mock_context, - conversation_types=[ - ConversationType.PUBLIC_CHANNEL, - ConversationType.PRIVATE_CHANNEL, - ], - ) - - assert response["conversations"] == [extract_conversation_metadata(mock_channel_info)] - assert response["next_cursor"] is None - - mock_chat_slack_client.conversations_list.assert_called_once_with( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ) - - -@pytest.mark.asyncio -async def test_list_conversations_metadata_with_custom_pagination_args( - mock_context, mock_chat_slack_client, mock_channel_info -): - mock_chat_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [mock_channel_info] * 3, - "response_metadata": {"next_cursor": "456"}, - } - - response = await list_conversations(mock_context, limit=3, next_cursor="123") - - assert response["conversations"] == [ - extract_conversation_metadata(mock_channel_info) for _ in range(3) - ] - assert response["next_cursor"] == "456" - - mock_chat_slack_client.conversations_list.assert_called_once_with( - types=None, - exclude_archived=True, - limit=3, - cursor="123", - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "faulty_slack_function_name, tool_function, tool_args", - [ - ("users_list", send_message, {"usernames": ["testuser"], "message": "Hello!"}), - ("conversations_list", send_message, {"channel_name": "general", "message": "Hello!"}), - ], -) -async def test_tools_with_slack_error( - mock_context, mock_chat_slack_client, faulty_slack_function_name, tool_function, tool_args -): - mock_chat_slack_client.auth_test.return_value = {"ok": True, "user_id": "current_user_id"} - getattr(mock_chat_slack_client, faulty_slack_function_name).side_effect = SlackApiError( - message="test_slack_error", - response={"ok": False, "error": "test_slack_error"}, - ) - - with pytest.raises(ToolExecutionError) as e: - await tool_function(mock_context, **tool_args) - assert "test_slack_error" in str(e.value) - - -@pytest.mark.asyncio -async def test_get_conversation_metadata_by_id( - mock_context, mock_conversation_retrieval_slack_client, mock_channel_info -): - mock_conversation_retrieval_slack_client.conversations_info.return_value = { - "ok": True, - "channel": mock_channel_info, - } - - response = await get_conversation_metadata(mock_context, conversation_id="C12345") - - assert response == extract_conversation_metadata(mock_channel_info) - mock_conversation_retrieval_slack_client.conversations_info.assert_called_once_with( - channel="C12345", - include_locale=True, - include_num_members=True, - ) - - -@pytest.mark.asyncio -async def test_get_conversation_metadata_by_id_slack_api_error( - mock_context, - mock_conversation_retrieval_slack_client, - mock_channel_info, -): - mock_conversation_retrieval_slack_client.conversations_info.side_effect = SlackApiError( - message="channel_not_found", - response={"ok": False, "error": "channel_not_found"}, - ) - - with pytest.raises(ToolExecutionError) as e: - await get_conversation_metadata(mock_context, conversation_id="C12345") - - assert "C12345" in e.value.message - assert "not found" in e.value.message - - -@pytest.mark.asyncio -async def test_get_conversation_metadata_by_channel_name( - mock_context, - mock_conversation_retrieval_slack_client, - dummy_channel_factory, - random_str_factory, -): - channel_name = random_str_factory() - channel1 = dummy_channel_factory(is_channel=True, name=f"{channel_name}_another_channel") - channel2 = dummy_channel_factory(is_channel=True, name=channel_name) - - mock_conversation_retrieval_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [channel1, channel2], - } - - response = await get_conversation_metadata(mock_context, channel_name=channel_name) - - assert response == extract_conversation_metadata(channel2) - mock_conversation_retrieval_slack_client.conversations_list.assert_called_once_with( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ) - - -@pytest.mark.asyncio -async def test_get_conversation_metadata_by_channel_name_triggering_pagination( - mock_context, - mock_conversation_retrieval_slack_client, - dummy_channel_factory, - random_str_factory, -): - target_channel_name = random_str_factory() - target_channel = dummy_channel_factory(is_channel=True, name=target_channel_name) - another_channel = dummy_channel_factory( - is_channel=True, name=f"{target_channel_name}_another_channel" - ) - - mock_conversation_retrieval_slack_client.conversations_list.side_effect = [ - { - "ok": True, - "channels": [another_channel], - "response_metadata": {"next_cursor": "123"}, - }, - { - "ok": True, - "channels": [target_channel], - "response_metadata": {"next_cursor": None}, - }, - ] - - response = await get_conversation_metadata(mock_context, channel_name=target_channel_name) - - assert response == extract_conversation_metadata(target_channel) - assert mock_conversation_retrieval_slack_client.conversations_list.call_count == 2 - mock_conversation_retrieval_slack_client.conversations_list.assert_has_calls([ - call( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ), - call( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor="123", - ), - ]) - - -@pytest.mark.asyncio -async def test_get_conversation_metadata_by_channel_name_not_found( - mock_context, - mock_conversation_retrieval_slack_client, - dummy_channel_factory, - random_str_factory, -): - not_found_name = random_str_factory() - channel1 = dummy_channel_factory(is_channel=True, name=f"{not_found_name}_first") - channel2 = dummy_channel_factory(is_channel=True, name=f"{not_found_name}_second") - - mock_conversation_retrieval_slack_client.conversations_list.side_effect = [ - { - "ok": True, - "channels": [channel1], - "response_metadata": {"next_cursor": "123"}, - }, - { - "ok": True, - "channels": [channel2], - "response_metadata": {"next_cursor": None}, - }, - ] - - with pytest.raises(RetryableToolError) as error: - await get_conversation_metadata(mock_context, channel_name=not_found_name) - - assert "not found" in error.value.message - assert not_found_name in error.value.message - assert ( - json.dumps([ - {"id": channel1["id"], "name": channel1["name"]}, - {"id": channel2["id"], "name": channel2["name"]}, - ]) - in error.value.additional_prompt_content - ) - - assert mock_conversation_retrieval_slack_client.conversations_list.call_count == 2 - mock_conversation_retrieval_slack_client.conversations_list.assert_has_calls([ - call( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ), - call( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor="123", - ), - ]) - - -@pytest.mark.asyncio -async def test_get_conversation_metadata_by_username( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_channel_factory, -): - current_user = dummy_user_factory(id_="U1", name="current_user") - other_user = dummy_user_factory(id_="U2", name="other_user") - conversation = dummy_channel_factory(is_im=True) - - mock_chat_slack_client.auth_test.return_value = { - "ok": True, - "user_id": current_user["id"], - } - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [current_user, other_user], - "response_metadata": {"next_cursor": None}, - } - - mock_chat_slack_client.conversations_open.return_value = { - "ok": True, - "channel": conversation, - } - - response = await get_conversation_metadata(mock_context, usernames=[other_user["name"]]) - - assert response == extract_conversation_metadata(conversation) - - -@pytest.mark.asyncio -async def test_get_dm_conversation_metadata_by_username_not_found( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_channel_factory, - random_str_factory, -): - current_user = dummy_user_factory(id_="U1", name="current_user") - other_user = dummy_user_factory(id_="U2", name="other_user") - conversation = dummy_channel_factory(is_im=True) - not_found_user_name = random_str_factory() - - mock_chat_slack_client.auth_test.return_value = { - "ok": True, - "user_id": current_user["id"], - } - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [current_user, other_user], - "response_metadata": {"next_cursor": None}, - } - - mock_chat_slack_client.conversations_open.return_value = { - "ok": True, - "channel": conversation, - } - - with pytest.raises(RetryableToolError) as error: - await get_conversation_metadata(mock_context, usernames=[not_found_user_name]) - - assert "not found" in error.value.message - assert not_found_user_name in error.value.message - assert other_user["id"] in error.value.additional_prompt_content - assert other_user["name"] in error.value.additional_prompt_content - - mock_chat_slack_client.conversations_open.assert_not_called() - - -@pytest.mark.asyncio -async def test_get_mpim_conversation_metadata_by_usernames( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_channel_factory, -): - current_user = dummy_user_factory(id_="U1", name="current_user") - other_user1 = dummy_user_factory(id_="U2", name="other_user1") - other_user2 = dummy_user_factory(id_="U3", name="other_user2") - other_user3 = dummy_user_factory(id_="U4", name="other_user3") - other_user4 = dummy_user_factory(id_="U5", name="other_user4") - - conversation = dummy_channel_factory(is_mpim=True) - - mock_chat_slack_client.auth_test.return_value = { - "ok": True, - "user_id": current_user["id"], - } - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [current_user, other_user1, other_user3], - "response_metadata": {"next_cursor": "users_list_cursor1"}, - }, - { - "ok": True, - "members": [current_user, other_user2, other_user4], - "response_metadata": {"next_cursor": None}, - }, - ] - - mock_chat_slack_client.conversations_open.return_value = { - "ok": True, - "channel": conversation, - } - - response = await get_conversation_metadata( - mock_context, - usernames=[other_user1["name"], other_user2["name"]], - ) - - assert response == extract_conversation_metadata(conversation) - - mock_chat_slack_client.conversations_open.assert_called_once_with( - users=[current_user["id"], other_user1["id"], other_user2["id"]], - return_im=True, - ) - - -@pytest.mark.asyncio -async def test_get_mpim_conversation_metadata_by_user_ids_and_usernames( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_channel_factory, -): - current_user = dummy_user_factory(id_="U1", name="current_user") - other_user1 = dummy_user_factory(id_="U2", name="other_user1") - other_user2 = dummy_user_factory(id_="U3", name="other_user2") - other_user3 = dummy_user_factory(id_="U4", name="other_user3") - other_user4 = dummy_user_factory(id_="U5", name="other_user4") - - conversation = dummy_channel_factory(is_mpim=True) - - mock_chat_slack_client.auth_test.return_value = { - "ok": True, - "user_id": current_user["id"], - } - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [current_user, other_user1, other_user3], - "response_metadata": {"next_cursor": "users_list_cursor1"}, - }, - { - "ok": True, - "members": [current_user, other_user2, other_user4], - "response_metadata": {"next_cursor": None}, - }, - ] - - mock_chat_slack_client.conversations_open.return_value = { - "ok": True, - "channel": conversation, - } - - response = await get_conversation_metadata( - mock_context, - user_ids=[other_user3["id"]], - usernames=[other_user1["name"], other_user2["name"]], - ) - - assert response == extract_conversation_metadata(conversation) - - mock_chat_slack_client.conversations_open.assert_called_once_with( - users=[other_user3["id"], current_user["id"], other_user1["id"], other_user2["id"]], - return_im=True, - ) - - -@pytest.mark.asyncio -async def test_get_mpim_conversation_metadata_by_user_ids_usernames_and_emails( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_channel_factory, -): - current_user = dummy_user_factory(id_="U1", name="current_user") - other_user1 = dummy_user_factory(id_="U2", name="other_user1") - other_user2 = dummy_user_factory(id_="U3", name="other_user2") - other_user3 = dummy_user_factory(id_="U4", name="other_user3") - other_user4 = dummy_user_factory(id_="U5", name="other_user4") - other_user5 = dummy_user_factory(id_="U6", name="other_user5") - - conversation = dummy_channel_factory(is_mpim=True) - - mock_chat_slack_client.auth_test.return_value = { - "ok": True, - "user_id": current_user["id"], - } - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [current_user, other_user1, other_user3], - "response_metadata": {"next_cursor": "users_list_cursor1"}, - }, - { - "ok": True, - "members": [current_user, other_user2, other_user4], - "response_metadata": {"next_cursor": None}, - }, - ] - - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = [ - { - "ok": True, - "user": other_user5, - }, - ] - - mock_chat_slack_client.conversations_open.return_value = { - "ok": True, - "channel": conversation, - } - - response = await get_conversation_metadata( - mock_context, - user_ids=[other_user3["id"]], - usernames=[other_user1["name"], other_user2["name"]], - emails=[other_user5["profile"]["email"]], - ) - - assert response == extract_conversation_metadata(conversation) - - mock_chat_slack_client.conversations_open.assert_called_once_with( - users=[ - other_user3["id"], - current_user["id"], - other_user1["id"], - other_user2["id"], - other_user5["id"], - ], - return_im=True, - ) - - -@pytest.mark.asyncio -async def test_get_users_in_conversation_by_id_with_conversation_and_user_paginations( - mock_context, - mock_chat_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - random_str_factory, -): - conversation_id = random_str_factory() - user1 = dummy_user_factory(id_="1") - user2 = dummy_user_factory(id_="2") - user3 = dummy_user_factory(id_="3") - - mock_chat_slack_client.conversations_members.side_effect = [ - { - "ok": True, - "members": [user1["id"], user2["id"]], - "response_metadata": {"next_cursor": "conversations_members_cursor1"}, - }, - { - "ok": True, - "members": [user3["id"]], - "response_metadata": {"next_cursor": "conversations_members_cursor2"}, - }, - ] - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2], - "response_metadata": {"next_cursor": "users_list_cursor"}, - }, - { - "ok": True, - "members": [user3], - "response_metadata": {"next_cursor": None}, - }, - ] - - response = await get_users_in_conversation( - context=mock_context, - conversation_id=conversation_id, - limit=3, - ) - - assert response == { - "users": [ - cast_user_dict(user1), - cast_user_dict(user2), - cast_user_dict(user3), - ], - "next_cursor": "conversations_members_cursor2", - } - - mock_chat_slack_client.conversations_members.assert_has_calls([ - call( - channel=conversation_id, - limit=3, - cursor=None, - ), - call( - channel=conversation_id, - limit=1, - cursor="conversations_members_cursor1", - ), - ]) - - mock_user_retrieval_slack_client.users_list.assert_has_calls([ - call( - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ), - call( - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor="users_list_cursor", - ), - ]) - - -@pytest.mark.asyncio -async def test_get_users_in_conversation_by_channel_name( - mock_context, - mock_chat_slack_client, - mock_conversation_retrieval_slack_client, - mock_user_retrieval_slack_client, - dummy_channel_factory, - dummy_user_factory, - random_str_factory, -): - channel_name = random_str_factory() - channel1 = dummy_channel_factory(is_channel=True, name=f"{channel_name}_another_channel") - channel2 = dummy_channel_factory(is_channel=True, name=channel_name) - - mock_conversation_retrieval_slack_client.conversations_list.side_effect = [ - { - "ok": True, - "channels": [channel1], - "response_metadata": {"next_cursor": "123"}, - }, - { - "ok": True, - "channels": [channel2], - "response_metadata": {"next_cursor": None}, - }, - ] - - user1 = dummy_user_factory(id_="1") - user2 = dummy_user_factory(id_="2") - - mock_chat_slack_client.conversations_members.side_effect = [ - { - "ok": True, - "members": [user1["id"], user2["id"]], - "response_metadata": {"next_cursor": None}, - }, - ] - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2], - "response_metadata": {"next_cursor": None}, - }, - ] - - response = await get_users_in_conversation(mock_context, channel_name=channel_name) - - assert response == { - "users": [ - cast_user_dict(user1), - cast_user_dict(user2), - ], - "next_cursor": None, - } - - -@pytest.mark.asyncio -async def test_get_users_in_conversation_by_channel_name_not_found( - mock_context, - mock_conversation_retrieval_slack_client, - dummy_channel_factory, - random_str_factory, -): - not_found_channel_name = random_str_factory() - channel1 = dummy_channel_factory(is_channel=True, name=f"{not_found_channel_name}_first") - channel2 = dummy_channel_factory(is_channel=True, name=f"{not_found_channel_name}_second") - - mock_conversation_retrieval_slack_client.conversations_list.side_effect = [ - { - "ok": True, - "channels": [channel1], - "response_metadata": {"next_cursor": "123"}, - }, - { - "ok": True, - "channels": [channel2], - "response_metadata": {"next_cursor": None}, - }, - ] - - with pytest.raises(RetryableToolError) as error: - await get_users_in_conversation(mock_context, channel_name=not_found_channel_name) - - assert "not found" in error.value.message - assert not_found_channel_name in error.value.message - assert ( - json.dumps([ - {"id": channel1["id"], "name": channel1["name"]}, - {"id": channel2["id"], "name": channel2["name"]}, - ]) - in error.value.additional_prompt_content - ) - - assert mock_conversation_retrieval_slack_client.conversations_list.call_count == 2 - mock_conversation_retrieval_slack_client.conversations_list.assert_has_calls([ - call( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor=None, - ), - call( - types=f"{ConversationTypeSlackName.PUBLIC_CHANNEL.value},{ConversationTypeSlackName.PRIVATE_CHANNEL.value}", - exclude_archived=True, - limit=MAX_PAGINATION_SIZE_LIMIT, - cursor="123", - ), - ]) - - -@pytest.mark.asyncio -async def test_get_messages_by_conversation_id( - mock_context, - mock_message_retrieval_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_message_factory, -): - user = dummy_user_factory() - message = dummy_message_factory(user_id=user["id"]) - - mock_message_retrieval_slack_client.conversations_history.return_value = { - "ok": True, - "messages": [message], - "response_metadata": {"next_cursor": "cursor"}, - } - - mock_user_retrieval_slack_client.users_info.return_value = { - "ok": True, - "user": user, - } - - response = await get_messages(mock_context, "C12345", limit=1) - - assert response["next_cursor"] == "cursor" - assert len(response["messages"]) == 1 - returned_message = response["messages"][0] - assert returned_message["user"] == {"id": user["id"], "name": user["name"]} - assert returned_message["text"] == message["text"] - - mock_message_retrieval_slack_client.conversations_history.assert_called_once_with( - channel="C12345", - include_all_metadata=True, - inclusive=True, - limit=1, - cursor=None, - ) - - -@pytest.mark.asyncio -@patch("arcade_slack.message_retrieval.convert_relative_datetime_to_unix_timestamp") -@patch("arcade_slack.message_retrieval.datetime") -async def test_get_messages_by_conversation_id_with_relative_datetime_args( - mock_datetime, - mock_convert_relative_datetime_to_unix_timestamp, - mock_context, - mock_message_retrieval_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_message_factory, -): - user = dummy_user_factory() - message = dummy_message_factory(user_id=user["id"]) - - mock_message_retrieval_slack_client.conversations_history.return_value = { - "ok": True, - "messages": [message], - } - - mock_user_retrieval_slack_client.users_info.return_value = { - "ok": True, - "user": user, - } - - expected_oldest_timestamp = 1716489600 - expected_latest_timestamp = 1716403200 - - mock_datetime.now.return_value = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - expected_current_unix_timestamp = int(mock_datetime.now.return_value.timestamp()) - mock_convert_relative_datetime_to_unix_timestamp.side_effect = [ - expected_latest_timestamp, - expected_oldest_timestamp, - ] - - response = await get_messages( - context=mock_context, - conversation_id="C12345", - oldest_relative="02:00:00", - latest_relative="01:00:00", - limit=1, - ) - - assert response["next_cursor"] is None - assert len(response["messages"]) == 1 - returned_message = response["messages"][0] - assert returned_message["user"] == {"id": user["id"], "name": user["name"]} - assert returned_message["text"] == message["text"] - - mock_convert_relative_datetime_to_unix_timestamp.assert_has_calls([ - call("01:00:00", expected_current_unix_timestamp), - call("02:00:00", expected_current_unix_timestamp), - ]) - mock_message_retrieval_slack_client.conversations_history.assert_called_once_with( - channel="C12345", - include_all_metadata=True, - inclusive=True, - limit=1, - cursor=None, - oldest=expected_oldest_timestamp, - latest=expected_latest_timestamp, - ) - - -@pytest.mark.asyncio -@patch("arcade_slack.message_retrieval.convert_datetime_to_unix_timestamp") -async def test_get_messages_by_conversation_id_with_absolute_datetime_args( - mock_convert_datetime_to_unix_timestamp, - mock_context, - mock_message_retrieval_slack_client, - mock_user_retrieval_slack_client, - dummy_user_factory, - dummy_message_factory, -): - user = dummy_user_factory() - message = dummy_message_factory(user_id=user["id"]) - - mock_message_retrieval_slack_client.conversations_history.return_value = { - "ok": True, - "messages": [message], - } - - mock_user_retrieval_slack_client.users_info.return_value = { - "ok": True, - "user": user, - } - - expected_latest_timestamp = 1716403200 - expected_oldest_timestamp = 1716489600 - - mock_convert_datetime_to_unix_timestamp.side_effect = [ - expected_latest_timestamp, - expected_oldest_timestamp, - ] - - response = await get_messages( - context=mock_context, - conversation_id="C12345", - oldest_datetime="2025-01-01 00:00:00", - latest_datetime="2025-01-02 00:00:00", - limit=1, - ) - - assert response["next_cursor"] is None - assert len(response["messages"]) == 1 - returned_message = response["messages"][0] - assert returned_message["user"] == {"id": user["id"], "name": user["name"]} - assert returned_message["text"] == message["text"] - - mock_convert_datetime_to_unix_timestamp.assert_has_calls([ - call("2025-01-02 00:00:00"), - call("2025-01-01 00:00:00"), - ]) - mock_message_retrieval_slack_client.conversations_history.assert_called_once_with( - channel="C12345", - include_all_metadata=True, - inclusive=True, - limit=1, - cursor=None, - oldest=expected_oldest_timestamp, - latest=expected_latest_timestamp, - ) - - -@pytest.mark.asyncio -async def test_get_messages_by_conversation_id_with_messed_oldest_args( - mock_context, mock_message_retrieval_slack_client -): - with pytest.raises(ToolExecutionError): - await get_messages( - context=mock_context, - conversation_id="C12345", - oldest_datetime="2025-01-01 00:00:00", - oldest_relative="01:00:00", - ) - - mock_message_retrieval_slack_client.conversations_history.assert_not_called() - - -@pytest.mark.asyncio -async def test_get_messages_by_conversation_id_with_messed_latest_args( - mock_context, mock_message_retrieval_slack_client -): - with pytest.raises(ToolExecutionError): - await get_messages( - context=mock_context, - conversation_id="C12345", - latest_datetime="2025-01-01 00:00:00", - latest_relative="01:00:00", - ) - - mock_message_retrieval_slack_client.conversations_history.assert_not_called() - - -@pytest.mark.asyncio -async def test_get_messages_by_channel_name( - mock_context, - mock_message_retrieval_slack_client, - mock_conversation_retrieval_slack_client, - mock_user_retrieval_slack_client, - dummy_message_factory, - dummy_user_factory, -): - mock_conversation_retrieval_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [ - { - "id": "C12345", - "name": "general", - "is_member": True, - "is_channel": True, - } - ], - } - - user = dummy_user_factory() - message = dummy_message_factory(user_id=user["id"]) - mock_message_retrieval_slack_client.conversations_history.return_value = { - "ok": True, - "messages": [message], - } - - mock_user_retrieval_slack_client.users_info.return_value = { - "ok": True, - "user": user, - } - - response = await get_messages( - context=mock_context, - channel_name="general", - limit=1, - ) - - assert response["next_cursor"] is None - assert len(response["messages"]) == 1 - returned_message = response["messages"][0] - assert returned_message["user"] == {"id": user["id"], "name": user["name"]} - assert returned_message["text"] == message["text"] - - mock_message_retrieval_slack_client.conversations_history.assert_called_once_with( - channel="C12345", - include_all_metadata=True, - inclusive=True, - limit=1, - cursor=None, - ) diff --git a/toolkits/slack/tests/test_models.py b/toolkits/slack/tests/test_models.py deleted file mode 100644 index 208473a0..00000000 --- a/toolkits/slack/tests/test_models.py +++ /dev/null @@ -1,107 +0,0 @@ -import asyncio -from unittest.mock import AsyncMock - -import pytest -from arcade_tdk.errors import ToolExecutionError -from slack_sdk.errors import SlackApiError - -from arcade_slack.models import ( - ConcurrencySafeCoroutineCaller, - FindChannelByNameSentinel, - FindMultipleUsersByUsernameSentinel, - FindUserByUsernameSentinel, - GetUserByEmailCaller, -) - - -def test_find_user_by_username_sentinel(): - sentinel = FindUserByUsernameSentinel(username="jenifer") - assert sentinel(last_result=[{"name": "jack"}]) is False - assert sentinel(last_result=[{"name": "john"}, {"name": "jack"}]) is False - assert sentinel(last_result=[{"name": "hello"}, {"name": "jenifer"}]) is True - assert sentinel(last_result=[{"name": "JENIFER"}]) is True - - -def test_find_multiple_users_by_username_sentinel(): - sentinel = FindMultipleUsersByUsernameSentinel(usernames=["jenifer", "jack"]) - assert sentinel(last_result=[{"name": "jack"}]) is False - assert sentinel(last_result=[{"name": "john"}, {"name": "jack"}]) is False - assert sentinel(last_result=[{"name": "hello"}, {"name": "JENIFER"}]) is True - assert sentinel(last_result=[{"name": "world"}]) is True - - -def test_find_channel_by_name_sentinel(): - sentinel = FindChannelByNameSentinel(channel_name="foobar") - assert sentinel(last_result=[{"name": "foo"}]) is False - assert sentinel(last_result=[{"name": "foo"}, {"name": "bar"}]) is False - assert sentinel(last_result=[{"name": "foo"}, {"name": "foobar"}]) is True - assert sentinel(last_result=[{"name": "FOObar"}]) is True - - -@pytest.mark.asyncio -async def test_concurrency_safe_coroutine_caller(): - mock_func = AsyncMock() - mock_semaphore = AsyncMock(spec=asyncio.Semaphore) - - caller = ConcurrencySafeCoroutineCaller(mock_func, "arg1", "arg2", kwarg1="kwarg1") - response = await caller(semaphore=mock_semaphore) - - assert response == mock_func.return_value - mock_func.assert_awaited_once_with("arg1", "arg2", kwarg1="kwarg1") - mock_semaphore.__aenter__.assert_awaited_once() - mock_semaphore.__aexit__.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_get_user_by_email_caller_success(): - mock_func = AsyncMock() - mock_func.return_value = {"user": {"id": "U1234567890", "name": "John Doe"}} - mock_semaphore = AsyncMock(spec=asyncio.Semaphore) - - caller = GetUserByEmailCaller(mock_func, "test@example.com") - response = await caller(semaphore=mock_semaphore) - - assert response == { - "user": {"id": "U1234567890", "name": "John Doe"}, - "email": "test@example.com", - } - mock_func.assert_awaited_once_with(email="test@example.com") - mock_semaphore.__aenter__.assert_awaited_once() - mock_semaphore.__aexit__.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_get_user_by_email_caller_not_found_error(): - mock_func = AsyncMock( - side_effect=SlackApiError(message="User not found", response={"error": "user_not_found"}) - ) - mock_semaphore = AsyncMock(spec=asyncio.Semaphore) - - caller = GetUserByEmailCaller(mock_func, "test@example.com") - response = await caller(semaphore=mock_semaphore) - - assert response == { - "user": None, - "email": "test@example.com", - } - mock_func.assert_awaited_once_with(email="test@example.com") - mock_semaphore.__aenter__.assert_awaited_once() - mock_semaphore.__aexit__.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_get_user_by_email_caller_unknown_slack_api_error(): - mock_func = AsyncMock( - side_effect=SlackApiError(message="Unknown error", response={"error": "unknown_error"}) - ) - mock_semaphore = AsyncMock(spec=asyncio.Semaphore) - - caller = GetUserByEmailCaller(mock_func, "test@example.com") - with pytest.raises(ToolExecutionError) as error: - await caller(semaphore=mock_semaphore) - - assert error.value.message == "Error getting user by email" - assert error.value.developer_message == "Error getting user by email: unknown_error" - mock_func.assert_awaited_once_with(email="test@example.com") - mock_semaphore.__aenter__.assert_awaited_once() - mock_semaphore.__aexit__.assert_awaited_once() diff --git a/toolkits/slack/tests/test_user_retrieval.py b/toolkits/slack/tests/test_user_retrieval.py deleted file mode 100644 index c584dd4b..00000000 --- a/toolkits/slack/tests/test_user_retrieval.py +++ /dev/null @@ -1,313 +0,0 @@ -import json - -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from slack_sdk.errors import SlackApiError - -from arcade_slack.user_retrieval import ( - get_single_user_by_id, - get_users_by_id, - get_users_by_id_username_or_email, -) -from arcade_slack.utils import ( - cast_user_dict, - extract_basic_user_info, - short_user_info, -) - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_emails_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - - emails = [user1["profile"]["email"], user2["profile"]["email"]] - - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = [ - {"ok": True, "user": user1}, - {"ok": True, "user": user2}, - ] - - response = await get_users_by_id_username_or_email(context=mock_context, emails=emails) - - assert response == [extract_basic_user_info(user1), extract_basic_user_info(user2)] - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_usernames_or_emails_with_emails_not_found( - mock_context, - mock_user_retrieval_slack_client, - mock_users_slack_client, - dummy_user_factory, -): - user1 = dummy_user_factory(email="user1@example.com") - - emails = [user1["profile"]["email"], "not_found@example.com"] - - async def lookup_by_email_side_effect(*, email): - if email == "user1@example.com": - return {"ok": True, "user": user1} - raise SlackApiError( - message="User not found", - response={"error": "user_not_found"}, - ) - - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = lookup_by_email_side_effect - - mock_users_slack_client.users_list.return_value = { - "ok": True, - "members": [user1], - } - - with pytest.raises(RetryableToolError) as error: - await get_users_by_id_username_or_email(context=mock_context, emails=emails) - - assert "not_found@example.com" in error.value.message - assert json.dumps(short_user_info(user1)) in error.value.additional_prompt_content - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_usernames_or_emails_with_usernames_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - - usernames = [user1["name"], user2["name"]] - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2], - } - - response = await get_users_by_id_username_or_email(context=mock_context, usernames=usernames) - - assert response == [extract_basic_user_info(user1), extract_basic_user_info(user2)] - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_usernames_or_emails_with_usernames_not_found( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - - usernames = [user1["name"], "username_not_found"] - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2, user3], - } - - with pytest.raises(RetryableToolError) as error: - await get_users_by_id_username_or_email(context=mock_context, usernames=usernames) - - assert "username_not_found" in error.value.message - assert json.dumps(short_user_info(user1)) in error.value.additional_prompt_content - assert json.dumps(short_user_info(user2)) in error.value.additional_prompt_content - assert json.dumps(short_user_info(user3)) in error.value.additional_prompt_content - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_mixed_usernames_and_emails_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - user4 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2], - } - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = [ - {"ok": True, "user": user3}, - {"ok": True, "user": user4}, - ] - - response = await get_users_by_id_username_or_email( - context=mock_context, - usernames=[user1["name"], user2["name"]], - emails=[user3["profile"]["email"], user4["profile"]["email"]], - ) - - assert response == [ - extract_basic_user_info(user1), - extract_basic_user_info(user2), - extract_basic_user_info(user3), - extract_basic_user_info(user4), - ] - - -@pytest.mark.asyncio -async def test_get_single_user_by_id_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user = dummy_user_factory() - - mock_user_retrieval_slack_client.users_info.return_value = {"ok": True, "user": user} - - response = await get_single_user_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_id=user["id"], - ) - - assert response == cast_user_dict(user) - - -@pytest.mark.asyncio -async def test_get_single_user_by_id_not_found( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user = dummy_user_factory() - - mock_user_retrieval_slack_client.users_info.side_effect = SlackApiError( - message="User not found", - response={"error": "user_not_found"}, - ) - - response = await get_single_user_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_id=user["id"], - ) - - assert response is None - - -@pytest.mark.asyncio -async def test_get_single_user_by_id_not_ok( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user = dummy_user_factory() - - mock_user_retrieval_slack_client.users_info.return_value = {"ok": False, "error": "not_ok"} - - response = await get_single_user_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_id=user["id"], - ) - - assert response is None - - -@pytest.mark.asyncio -async def test_get_single_user_by_id_unknown_error( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user = dummy_user_factory() - - mock_user_retrieval_slack_client.users_info.side_effect = SlackApiError( - message="Unknown error", - response={"error": "unknown_error_string"}, - ) - - with pytest.raises(ToolExecutionError) as error: - await get_single_user_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_id=user["id"], - ) - - assert user["id"] in error.value.message - assert "unknown_error_string" in error.value.developer_message - - -@pytest.mark.asyncio -async def test_get_users_by_id_with_one_user_id_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_info.return_value = {"ok": True, "user": user1} - - response = await get_users_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_ids=[user1["id"]], - ) - - assert response == {"users": [cast_user_dict(user1)], "not_found": []} - - mock_user_retrieval_slack_client.users_list.assert_not_called() - - -@pytest.mark.asyncio -async def test_get_users_by_id_with_one_user_id_not_found( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_info.side_effect = SlackApiError( - message="User not found", - response={"error": "user_not_found"}, - ) - - response = await get_users_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_ids=[user1["id"]], - ) - - assert response == {"users": [], "not_found": [user1["id"]]} - - mock_user_retrieval_slack_client.users_list.assert_not_called() - - -@pytest.mark.asyncio -async def test_get_users_by_id_with_multiple_user_ids_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - user4 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2], - "response_metadata": {"next_cursor": "next_cursor"}, - }, - {"ok": True, "members": [user3, user4]}, - ] - - response = await get_users_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_ids=[user1["id"], user4["id"]], - ) - - assert response == {"users": [cast_user_dict(user1), cast_user_dict(user4)], "not_found": []} - - -@pytest.mark.asyncio -async def test_get_users_by_id_with_multiple_user_ids_some_not_found( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory(id_="U1") - user2 = dummy_user_factory(id_="U2") - user3 = dummy_user_factory(id_="U3") - user4 = dummy_user_factory(id_="U4") - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2], - "response_metadata": {"next_cursor": "next_cursor"}, - }, - { - "ok": True, - "members": [user3, user4], - "response_metadata": {"next_cursor": None}, - }, - ] - - response = await get_users_by_id( - auth_token=mock_context.get_auth_token_or_empty(), - user_ids=[user1["id"], user4["id"], "user_not_exists"], - ) - - assert response == { - "users": [cast_user_dict(user1), cast_user_dict(user4)], - "not_found": ["user_not_exists"], - } diff --git a/toolkits/slack/tests/test_users.py b/toolkits/slack/tests/test_users.py deleted file mode 100644 index d2849bb7..00000000 --- a/toolkits/slack/tests/test_users.py +++ /dev/null @@ -1,374 +0,0 @@ -import json -from unittest.mock import patch - -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError -from slack_sdk.errors import SlackApiError - -from arcade_slack.tools.users import ( - get_users_info, - list_users, -) -from arcade_slack.utils import extract_basic_user_info, short_user_info - - -@pytest.mark.asyncio -async def test_get_user_info_by_id_success(mock_context, mock_user_retrieval_slack_client): - # Mock the response from slackClient.users_info - mock_user = { - "id": "U12345", - "name": "testuser", - "real_name": "Test User", - "profile": {"email": "testuser@example.com"}, - } - mock_user_retrieval_slack_client.users_info.return_value = {"ok": True, "user": mock_user} - - response = await get_users_info(mock_context, user_ids=["U12345"]) - - mock_user_retrieval_slack_client.users_info.assert_called_once_with(user="U12345") - - expected_response = extract_basic_user_info(mock_user) - assert response == {"users": [expected_response]} - - -@pytest.mark.asyncio -@patch("arcade_slack.tools.users.list_users") -async def test_get_user_info_by_id_user_not_found( - mock_list_users, mock_context, mock_user_retrieval_slack_client -): - error_response = {"ok": False, "error": "user_not_found"} - mock_user_retrieval_slack_client.users_info.side_effect = SlackApiError( - message="User not found", - response=error_response, - ) - - existing_user = {"id": "U12345", "name": "testuser"} - mock_list_users.return_value = {"users": [existing_user]} - mock_list_users.__tool_name__ = list_users.__tool_name__ - - with pytest.raises(RetryableToolError) as e: - await get_users_info(mock_context, user_ids=["U99999"]) - - assert existing_user["id"] in e.value.additional_prompt_content - - mock_user_retrieval_slack_client.users_info.assert_called_once_with(user="U99999") - mock_list_users.assert_called_once_with(mock_context, limit=100, exclude_bots=True) - - -@pytest.mark.asyncio -async def test_list_users_success(mock_context, mock_users_slack_client): - mock_users_slack_client.users_list.return_value = { - "ok": True, - "members": [{"id": "U12345"}], - } - response = await list_users(mock_context) - assert response == { - "users": [extract_basic_user_info({"id": "U12345"})], - "next_cursor": None, - } - - -@pytest.mark.asyncio -async def test_list_users_with_pagination_success( - dummy_user_factory, mock_context, mock_users_slack_client -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - user4 = dummy_user_factory() - - mock_users_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2], - "response_metadata": {"next_cursor": "cursor_xyz"}, - }, - { - "ok": True, - "members": [user3, user4], - "response_metadata": {"next_cursor": None}, - }, - ] - response = await list_users(mock_context, limit=3) - assert response == { - "users": [ - extract_basic_user_info(user1), - extract_basic_user_info(user2), - extract_basic_user_info(user3), - extract_basic_user_info(user4), - ], - "next_cursor": None, - } - - assert mock_users_slack_client.users_list.call_count == 2 - - -@pytest.mark.asyncio -async def test_get_user_by_username_success( - mock_context, - mock_user_retrieval_slack_client, - dummy_user_factory, -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2], - } - - response = await get_users_info(mock_context, usernames=[user1["name"]]) - - assert response == {"users": [extract_basic_user_info(user1)]} - - -@pytest.mark.asyncio -async def test_get_user_by_username_with_pagination_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - user4 = dummy_user_factory() - user5 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "members": [user3, user4], - "response_metadata": {"next_cursor": "cursor2"}, - }, - { - "ok": True, - "members": [user5], - "response_metadata": {"next_cursor": None}, - }, - ] - - response = await get_users_info(mock_context, usernames=[user3["name"]]) - - assert response == {"users": [extract_basic_user_info(user3)]} - - assert mock_user_retrieval_slack_client.users_list.call_count == 2 - - -@pytest.mark.asyncio -async def test_get_user_by_username_not_found( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory(is_bot=True) - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2, user3], - } - - with pytest.raises(RetryableToolError) as e: - await get_users_info(mock_context, usernames=[user1["name"] + "not_found"]) - - # Check that the error message contains the available users - assert user1["id"] in e.value.additional_prompt_content - assert user2["id"] in e.value.additional_prompt_content - assert user3["id"] not in e.value.additional_prompt_content - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_username_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2, user3], - } - - response = await get_users_info( - mock_context, - usernames=[user1["name"], user2["name"]], - ) - - assert response == { - "users": [ - extract_basic_user_info(user1), - extract_basic_user_info(user2), - ] - } - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_username_with_pagination_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - user4 = dummy_user_factory() - user5 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_list.side_effect = [ - {"ok": True, "members": [user1, user2], "response_metadata": {"next_cursor": "cursor1"}}, - {"ok": True, "members": [user3, user4], "response_metadata": {"next_cursor": "cursor2"}}, - {"ok": True, "members": [user5], "response_metadata": {"next_cursor": None}}, - ] - - response = await get_users_info(mock_context, usernames=[user1["name"], user3["name"]]) - - assert response == {"users": [extract_basic_user_info(user1), extract_basic_user_info(user3)]} - assert mock_user_retrieval_slack_client.users_list.call_count == 2 - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_username_not_found( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory(is_bot=True) - - mock_user_retrieval_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2, user3], - } - - not_found_username = f"{user2['name']} not_found" - - with pytest.raises(RetryableToolError) as e: - await get_users_info(mock_context, usernames=[user1["name"], not_found_username]) - - assert user1["id"] in e.value.additional_prompt_content - assert user2["id"] in e.value.additional_prompt_content - assert user3["id"] not in e.value.additional_prompt_content - - -@pytest.mark.asyncio -async def test_get_user_by_email_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user = dummy_user_factory() - mock_user_retrieval_slack_client.users_lookupByEmail.return_value = { - "ok": True, - "user": user, - } - response = await get_users_info(mock_context, emails=[user["profile"]["email"]]) - assert response == {"users": [extract_basic_user_info(user)]} - - -@pytest.mark.asyncio -async def test_get_user_by_email_not_found( - mock_context, mock_users_slack_client, mock_user_retrieval_slack_client, dummy_user_factory -): - additional_user = dummy_user_factory(email="additional_user@example.com") - - async def lookup_by_email_side_effect(*, email): - if email == "additional_user@example.com": - return {"ok": True, "user": additional_user} - raise SlackApiError( - message="User not found", - response={"ok": False, "error": "user_not_found"}, - ) - - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = lookup_by_email_side_effect - mock_users_slack_client.users_list.return_value = { - "ok": True, - "members": [additional_user], - } - - with pytest.raises(RetryableToolError) as e: - await get_users_info(mock_context, emails=["not_found@example.com"]) - - assert "not_found@example.com" in e.value.message - assert json.dumps(short_user_info(additional_user)) in e.value.additional_prompt_content - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_email_success( - mock_context, mock_user_retrieval_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - user3 = dummy_user_factory() - - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = [ - {"ok": True, "user": user1}, - {"ok": True, "user": user2}, - {"ok": True, "user": user3}, - ] - - response = await get_users_info( - mock_context, - emails=[ - user1["profile"]["email"], - user2["profile"]["email"], - user3["profile"]["email"], - ], - ) - - assert response == { - "users": [ - extract_basic_user_info(user1), - extract_basic_user_info(user2), - extract_basic_user_info(user3), - ] - } - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_email_with_invalid_address( - mock_context, mock_user_retrieval_slack_client -): - with pytest.raises(ToolExecutionError) as e: - await get_users_info( - mock_context, - emails=["amyra@jades.com", "invalid_address"], - ) - - mock_user_retrieval_slack_client.assert_not_called() - assert e.value.message == "Invalid email address: invalid_address" - - -@pytest.mark.asyncio -async def test_get_multiple_users_by_email_not_found( - mock_context, mock_user_retrieval_slack_client, mock_users_slack_client, dummy_user_factory -): - user1 = dummy_user_factory() - user2 = dummy_user_factory() - - async def lookup_by_email_side_effect(*, email): - if email == user1["profile"]["email"]: - return {"ok": True, "user": user1} - if email == user2["profile"]["email"]: - return {"ok": True, "user": user2} - raise SlackApiError( - message="User not found", - response={"ok": False, "error": "user_not_found"}, - ) - - mock_user_retrieval_slack_client.users_lookupByEmail.side_effect = lookup_by_email_side_effect - mock_users_slack_client.users_list.return_value = { - "ok": True, - "members": [user1, user2], - } - - with pytest.raises(RetryableToolError) as e: - await get_users_info( - mock_context, - emails=[ - user1["profile"]["email"], - user2["profile"]["email"], - "not_found@example.com", - ], - ) - - assert "not_found@example.com" in e.value.message - assert json.dumps(short_user_info(user1)) in e.value.additional_prompt_content - assert json.dumps(short_user_info(user2)) in e.value.additional_prompt_content diff --git a/toolkits/slack/tests/test_utils.py b/toolkits/slack/tests/test_utils.py deleted file mode 100644 index dc4aec85..00000000 --- a/toolkits/slack/tests/test_utils.py +++ /dev/null @@ -1,689 +0,0 @@ -import asyncio -import copy -import json -from unittest.mock import AsyncMock, call, patch - -import pytest -from arcade_tdk.errors import RetryableToolError -from slack_sdk.errors import SlackApiError -from slack_sdk.web.async_client import AsyncWebClient - -from arcade_slack.exceptions import PaginationTimeoutError -from arcade_slack.models import ( - ConcurrencySafeCoroutineCaller, - FindMultipleUsersByUsernameSentinel, - FindUserByUsernameSentinel, -) -from arcade_slack.utils import ( - async_paginate, - build_multiple_users_retrieval_response, - filter_conversations_by_user_ids, - gather_with_concurrency_limit, - is_valid_email, - populate_users_in_messages, -) - - -@pytest.mark.asyncio -async def test_async_paginate(): - mock_slack_client = AsyncMock() - mock_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [{"id": "123"}], - "response_metadata": {"next_cursor": None}, - } - - results, next_cursor = await async_paginate( - func=mock_slack_client.conversations_list, - response_key="channels", - ) - - assert results == [{"id": "123"}] - assert next_cursor is None - - -@pytest.mark.asyncio -async def test_async_paginate_with_find_user_sentinel(): - mock_slack_client = AsyncMock() - mock_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - ], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "members": [{"id": "789", "name": "Jenifer"}], - "response_metadata": {"next_cursor": "cursor2"}, - }, - { - "ok": True, - "members": [{"id": "007", "name": "James"}], - "response_metadata": {"next_cursor": None}, - }, - ] - - results, next_cursor = await async_paginate( - func=mock_slack_client.users_list, - response_key="members", - sentinel=FindUserByUsernameSentinel(username="jenifer"), - ) - - assert results == [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - {"id": "789", "name": "Jenifer"}, - ] - assert next_cursor == "cursor2" - - -@pytest.mark.asyncio -async def test_async_paginate_with_find_user_sentinel_not_found(): - mock_slack_client = AsyncMock() - mock_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - ], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "members": [{"id": "789", "name": "Jenifer"}], - "response_metadata": {"next_cursor": "cursor2"}, - }, - { - "ok": True, - "members": [{"id": "007", "name": "James"}], - "response_metadata": {"next_cursor": None}, - }, - ] - - results, next_cursor = await async_paginate( - func=mock_slack_client.users_list, - response_key="members", - sentinel=FindUserByUsernameSentinel(username="Do not find me"), - ) - - assert results == [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - {"id": "789", "name": "Jenifer"}, - {"id": "007", "name": "James"}, - ] - assert next_cursor is None - - -@pytest.mark.asyncio -async def test_async_paginate_with_find_multiple_users_sentinel(): - mock_slack_client = AsyncMock() - mock_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - ], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "members": [ - {"id": "789", "name": "Jenifer"}, - {"id": "101", "name": "Janis"}, - ], - "response_metadata": {"next_cursor": "cursor2"}, - }, - { - "ok": True, - "members": [{"id": "007", "name": "James"}], - "response_metadata": {"next_cursor": None}, - }, - ] - - results, next_cursor = await async_paginate( - func=mock_slack_client.users_list, - response_key="members", - sentinel=FindMultipleUsersByUsernameSentinel(usernames=["jenifer", "jack"]), - ) - - assert results == [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - {"id": "789", "name": "Jenifer"}, - {"id": "101", "name": "Janis"}, - ] - assert next_cursor == "cursor2" - - -@pytest.mark.asyncio -async def test_async_paginate_with_find_multiple_users_sentinel_not_found(): - mock_slack_client = AsyncMock() - mock_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - ], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "members": [ - {"id": "789", "name": "Jenifer"}, - {"id": "101", "name": "Janis"}, - ], - "response_metadata": {"next_cursor": "cursor2"}, - }, - { - "ok": True, - "members": [{"id": "007", "name": "James"}], - "response_metadata": {"next_cursor": None}, - }, - ] - - results, next_cursor = await async_paginate( - func=mock_slack_client.users_list, - response_key="members", - sentinel=FindMultipleUsersByUsernameSentinel( - usernames=["jenifer", "jack", "do not find me"] - ), - ) - - assert results == [ - {"id": "123", "name": "Jack"}, - {"id": "456", "name": "John"}, - {"id": "789", "name": "Jenifer"}, - {"id": "101", "name": "Janis"}, - {"id": "007", "name": "James"}, - ] - assert next_cursor is None - - -@pytest.mark.asyncio -async def test_async_paginate_with_response_error(): - mock_slack_client = AsyncMock() - mock_slack_client.conversations_list.side_effect = SlackApiError( - message="slack_error", - response={"ok": False, "error": "slack_error"}, - ) - - with pytest.raises(SlackApiError) as e: - await async_paginate( - func=mock_slack_client.conversations_list, - response_key="channels", - ) - assert str(e.value) == "slack_error" - - -@pytest.mark.asyncio -async def test_async_paginate_with_custom_pagination_args(): - mock_slack_client = AsyncMock() - mock_slack_client.conversations_list.return_value = { - "ok": True, - "channels": [{"id": "123"}], - "response_metadata": {"next_cursor": "456"}, - } - - results, next_cursor = await async_paginate( - func=mock_slack_client.conversations_list, - response_key="channels", - limit=1, - next_cursor="123", - hello="world", - ) - - assert results == [{"id": "123"}] - assert next_cursor == "456" - - mock_slack_client.conversations_list.assert_called_once_with( - hello="world", - limit=1, - cursor="123", - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "test_limit, last_next_cursor, last_expected_limit", - [(5, "cursor3", 1), (None, None, 2)], -) -async def test_async_paginate_large_limit(test_limit, last_next_cursor, last_expected_limit): - mock_slack_client = AsyncMock(spec=AsyncWebClient) - mock_slack_client.conversations_list.side_effect = [ - { - "ok": True, - "channels": [{"id": "channel1"}, {"id": "channel2"}], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "channels": [{"id": "channel3"}, {"id": "channel4"}], - "response_metadata": {"next_cursor": "cursor2"}, - }, - { - "ok": True, - "channels": [{"id": "channel5"}], - "response_metadata": {"next_cursor": last_next_cursor}, - }, - ] - - with patch("arcade_slack.utils.MAX_PAGINATION_SIZE_LIMIT", 2): - results, next_cursor = await async_paginate( - func=mock_slack_client.conversations_list, - response_key="channels", - limit=test_limit, - hello="world", - ) - - assert results == [ - {"id": "channel1"}, - {"id": "channel2"}, - {"id": "channel3"}, - {"id": "channel4"}, - {"id": "channel5"}, - ] - assert next_cursor == last_next_cursor - assert mock_slack_client.conversations_list.call_count == 3 - mock_slack_client.conversations_list.assert_has_calls([ - call(hello="world", limit=2, cursor=None), - call(hello="world", limit=2, cursor="cursor1"), - call(hello="world", limit=last_expected_limit, cursor="cursor2"), - ]) - - -@pytest.mark.asyncio -async def test_async_paginate_large_limit_with_response_error(): - mock_slack_client = AsyncMock() - mock_slack_client.conversations_list.side_effect = [ - { - "ok": True, - "channels": [{"id": "channel1"}, {"id": "channel2"}], - "response_metadata": {"next_cursor": "cursor1"}, - }, - SlackApiError(message="slack_error", response={"ok": False, "error": "slack_error"}), - { - "ok": True, - "channels": [{"id": "channel5"}], - "response_metadata": {"next_cursor": "cursor3"}, - }, - ] - - with ( - patch("arcade_slack.utils.MAX_PAGINATION_SIZE_LIMIT", 2), - pytest.raises(SlackApiError) as e, - ): - await async_paginate( - func=mock_slack_client.conversations_list, - response_key="channels", - limit=5, - hello="world", - ) - assert str(e.value) == "slack_error" - - assert mock_slack_client.conversations_list.call_count == 2 - mock_slack_client.conversations_list.assert_has_calls([ - call(hello="world", limit=2, cursor=None), - call(hello="world", limit=2, cursor="cursor1"), - ]) - - -@pytest.mark.asyncio -async def test_async_paginate_with_timeout(): - # Mock Slack client - mock_slack_client = AsyncMock() - - # Simulate a network delay by making the mock function sleep - async def mock_conversations_list(*args, **kwargs): - await asyncio.sleep(1) # Sleep for 1 second to simulate delay - return { - "ok": True, - "channels": [{"id": "123"}], - "response_metadata": {"next_cursor": None}, - } - - mock_slack_client.conversations_list.side_effect = mock_conversations_list - - # Set a low timeout to trigger the timeout error quickly during the test - max_pagination_timeout_seconds = 0.1 # 100 milliseconds - - with pytest.raises(PaginationTimeoutError) as exc_info: - await async_paginate( - func=mock_slack_client.conversations_list, - response_key="channels", - max_pagination_timeout_seconds=max_pagination_timeout_seconds, - ) - - assert ( - str(exc_info.value) - == f"The pagination process timed out after {max_pagination_timeout_seconds} seconds." - ) - - -def test_filter_conversations_by_user_ids(): - conversations = [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}, - {"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]}, - ] - response = filter_conversations_by_user_ids( - conversations=conversations, - user_ids=["user1", "user2"], - exact_match=False, - ) - assert response == [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}, - ] - - -def test_filter_conversations_by_user_ids_empty_response(): - conversations = [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user3"}, {"id": "user4"}]}, - {"id": "456", "members": [{"id": "user2"}, {"id": "user3"}, {"id": "user4"}]}, - ] - response = filter_conversations_by_user_ids( - conversations=conversations, - user_ids=["user1", "user2"], - exact_match=False, - ) - assert response == [] - - -def test_filter_conversations_by_user_ids_multiple_matches(): - conversations = [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}, - {"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]}, - { - "id": "789", - "members": [{"id": "user4"}, {"id": "user1"}, {"id": "user2"}, {"id": "user3"}], - }, - ] - response = filter_conversations_by_user_ids( - conversations=conversations, - user_ids=["user1", "user2"], - exact_match=False, - ) - assert response == [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}, - { - "id": "789", - "members": [{"id": "user4"}, {"id": "user1"}, {"id": "user2"}, {"id": "user3"}], - }, - ] - - -def test_filter_conversations_by_user_ids_exact_match(): - conversations = [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user2"}]}, - {"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]}, - ] - response = filter_conversations_by_user_ids( - conversations=conversations, - user_ids=["user1", "user2"], - exact_match=True, - ) - assert response == [{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}]}] - - -def test_filter_conversations_by_user_ids_exact_match_empty_response(): - conversations = [ - {"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}, - {"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]}, - ] - response = filter_conversations_by_user_ids( - conversations=conversations, - user_ids=["user1", "user2"], - exact_match=True, - ) - assert response == [] - - -@pytest.mark.parametrize( - "users_by_email, users_by_username, expected_response", - [ - ( - {"users": [{"id": "U1", "name": "user1"}]}, - {"users": [{"id": "U2", "name": "user2"}]}, - [{"id": "U1", "name": "user1"}, {"id": "U2", "name": "user2"}], - ), - ( - {"users": [{"id": "U1", "name": "user1"}]}, - {"users": []}, - [{"id": "U1", "name": "user1"}], - ), - ( - {"users": []}, - {"users": [{"id": "U2", "name": "user2"}]}, - [{"id": "U2", "name": "user2"}], - ), - ( - {"users": []}, - {"users": []}, - [], - ), - ], -) -@pytest.mark.asyncio -async def test_build_multiple_users_retrieval_response_success( - users_by_email, - users_by_username, - expected_response, - mock_context, -): - response = await build_multiple_users_retrieval_response( - context=mock_context, - users_responses=[users_by_email, users_by_username], - ) - assert response == expected_response - - -@pytest.mark.parametrize( - "users_by_email, users_by_username", - [ - # Both emails and usernames not found - ( - { - "users": [{"id": "U1", "name": "user1"}], - "not_found": ["email_not_found"], - }, - { - "users": [{"id": "U2", "name": "user2"}], - "not_found": ["username_not_found"], - "available_users": [{"id": "U3", "name": "user3"}], - }, - ), - # Email not found, usernames found - ( - { - "users": [{"id": "U1", "name": "user1"}], - "not_found": ["email_not_found"], - }, - { - "users": [{"id": "U2", "name": "user2"}], - "not_found": [], - }, - ), - # Email found, username not found - ( - { - "users": [{"id": "U1", "name": "user1"}], - "not_found": [], - }, - { - "users": [{"id": "U2", "name": "user2"}], - "not_found": ["username_not_found"], - "available_users": [{"id": "U3", "name": "user3"}], - }, - ), - ], -) -@pytest.mark.asyncio -async def test_build_multiple_users_retrieval_response_not_found( - users_by_email, - users_by_username, - mock_context, -): - with pytest.raises(RetryableToolError) as error: - await build_multiple_users_retrieval_response( - context=mock_context, - users_responses=[users_by_email, users_by_username], - ) - - emails_not_found = users_by_email.get("not_found", []) - usernames_not_found = users_by_username.get("not_found", []) - available_users = users_by_username.get("available_users", []) - - for email in emails_not_found: - assert email in error.value.message - for username in usernames_not_found: - assert username in error.value.message - for user in available_users: - assert json.dumps(user) in error.value.additional_prompt_content - - -def test_is_valid_email(): - assert is_valid_email("test@example.com") - assert is_valid_email("test+123@example.com") - assert is_valid_email("test-123@example.com") - assert is_valid_email("test_123@example.com") - assert is_valid_email("test.123@example.com") - assert is_valid_email("test123@example.com") - assert is_valid_email("test@example.co") - assert is_valid_email("test@example.com.co") - assert not is_valid_email("test123@example") - assert not is_valid_email("test@example") - assert not is_valid_email("test@example.") - assert not is_valid_email("test@.com") - assert not is_valid_email("test@example.c") - assert not is_valid_email("test@example.com.") - assert not is_valid_email("test@example.com.c") - - -@pytest.mark.asyncio -async def test_gather_with_concurrency_limit(): - mock_func1 = AsyncMock() - mock_func2 = AsyncMock() - - caller1 = ConcurrencySafeCoroutineCaller(mock_func1, "arg1", "arg2", kwarg1="kwarg1") - caller2 = ConcurrencySafeCoroutineCaller(mock_func2, "arg1", "arg2", kwarg1="kwarg1") - - mock_semaphore = AsyncMock(spec=asyncio.Semaphore) - - response = await gather_with_concurrency_limit( - coroutine_callers=[caller1, caller2], - semaphore=mock_semaphore, - ) - - response = tuple(response) - - assert len(response) == 2 - assert response[0] == mock_func1.return_value - assert response[1] == mock_func2.return_value - - mock_func1.assert_awaited_once_with("arg1", "arg2", kwarg1="kwarg1") - mock_func2.assert_awaited_once_with("arg1", "arg2", kwarg1="kwarg1") - - assert mock_semaphore.__aenter__.await_count == 2 - assert mock_semaphore.__aexit__.await_count == 2 - - -@pytest.mark.asyncio -async def test_populate_users_in_messages( - mock_context, - mock_user_retrieval_slack_client, - dummy_message_factory, - dummy_reaction_factory, - dummy_user_factory, -): - user1 = dummy_user_factory(id_="U1", name="user1") - user2 = dummy_user_factory(id_="U2", name="user2") - user3 = dummy_user_factory(id_="U3", name="user3") - user4 = dummy_user_factory(id_="U4", name="user4") - user5 = dummy_user_factory(id_="U5", name="user5") - - user1_short = {"id": user1["id"], "name": user1["name"]} - user2_short = {"id": user2["id"], "name": user2["name"]} - user3_short = {"id": user3["id"], "name": user3["name"]} - user4_short = {"id": user4["id"], "name": user4["name"]} - - user2_mention = f"<@{user2['name']} (id:{user2['id']})>" - user5_mention = f"<@{user5['name']} (id:{user5['id']})>" - - reactions = [ - dummy_reaction_factory(name="thumbsup", user_ids=[user1["id"], user2["id"]]), - dummy_reaction_factory(name="partyparrot", user_ids=[user3["id"], user4["id"]]), - ] - - messages = [ - dummy_message_factory( - user_id=user1["id"], - text=f"Hello <@{user2['id']}>", - ), - dummy_message_factory( - user_id=user2["id"], - text="foobar", - reactions=copy.deepcopy(reactions[:1]), - ), - dummy_message_factory( - user_id=user3["id"], - text=f"Is this @{user5['id']} a user mention?", - ), - dummy_message_factory( - user_id=user4["id"], - text="hello", - reactions=copy.deepcopy(reactions), - ), - ] - - mock_user_retrieval_slack_client.users_list.side_effect = [ - { - "ok": True, - "members": [user1, user2, user3], - "response_metadata": {"next_cursor": "cursor1"}, - }, - { - "ok": True, - "members": [user4, user5], - "response_metadata": {"next_cursor": None}, - }, - ] - - response = await populate_users_in_messages( - auth_token=mock_context.get_auth_token_or_empty(), - messages=messages, - ) - - msg1 = response[0] - msg2 = response[1] - msg3 = response[2] - msg4 = response[3] - - assert msg1["user"] == user1_short - assert msg1["text"] == f"Hello {user2_mention}" - assert "reactions" not in msg1 - - assert msg2["user"] == user2_short - assert msg2["text"] == "foobar" - assert "reactions" in msg2 - assert len(msg2["reactions"]) == 1 - assert msg2["reactions"][0]["name"] == "thumbsup" - assert msg2["reactions"][0]["users"] == [user1_short, user2_short] - - assert msg3["user"] == user3_short - assert msg3["text"] == f"Is this @{user5['id']} a user mention?" - assert "reactions" not in msg3 - assert user5_mention not in msg3["text"] - - assert msg4["user"] == user4_short - assert msg4["text"] == "hello" - assert "reactions" in msg4 - assert len(msg4["reactions"]) == 2 - assert msg4["reactions"][0]["name"] == "thumbsup" - assert msg4["reactions"][0]["users"] == [user1_short, user2_short] - assert msg4["reactions"][1]["name"] == "partyparrot" - assert msg4["reactions"][1]["users"] == [user3_short, user4_short] diff --git a/toolkits/spotify/.pre-commit-config.yaml b/toolkits/spotify/.pre-commit-config.yaml deleted file mode 100644 index 6c54342e..00000000 --- a/toolkits/spotify/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/spotify/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/spotify/.ruff.toml b/toolkits/spotify/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/spotify/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/spotify/LICENSE b/toolkits/spotify/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/spotify/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/spotify/Makefile b/toolkits/spotify/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/spotify/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/spotify/arcade_spotify/__init__.py b/toolkits/spotify/arcade_spotify/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/spotify/arcade_spotify/tools/__init__.py b/toolkits/spotify/arcade_spotify/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/spotify/arcade_spotify/tools/constants.py b/toolkits/spotify/arcade_spotify/tools/constants.py deleted file mode 100644 index e5bb9f92..00000000 --- a/toolkits/spotify/arcade_spotify/tools/constants.py +++ /dev/null @@ -1,35 +0,0 @@ -SPOTIFY_BASE_URL = "https://api.spotify.com/v1" - -ENDPOINTS = { - "player_get_available_devices": "/me/player/devices", - "player_get_playback_state": "/me/player", - "player_get_currently_playing": "/me/player/currently-playing", - "player_modify_playback": "/me/player/play", - "player_pause_playback": "/me/player/pause", - "player_skip_to_next": "/me/player/next", - "player_skip_to_previous": "/me/player/previous", - "player_seek_to_position": "/me/player/seek", - "tracks_get_track": "/tracks/{track_id}", - "search": "/search", -} - -RESPONSE_MSGS = { - "artist_not_found": "Artist '{artist_name}' not found", - "track_not_found": "Track '{track_name}' not found", - "no_track_to_adjust_position": "No track to adjust position", - "playback_position_adjusted": "Playback position adjusted", - "no_track_to_go_back_to": "No track to go back to", - "playback_skipped_to_previous_track": "Playback skipped to previous track", - "no_track_to_skip": "No track to skip", - "playback_skipped_to_next_track": "Playback skipped to next track", - "playback_paused": "Playback paused", - "playback_resumed": "Playback resumed", - "no_track_to_resume": "No track to resume", - "no_track_to_pause": "No track to pause", - "no_track_to_play": "No track to play", - "no_available_devices": "No available devices", - "track_is_already_paused": "Track is already paused", - "track_is_already_playing": "Track is already playing", - "playback_started": "Playback started", - "no_active_device": "Cannot start playback because no active device is available", -} diff --git a/toolkits/spotify/arcade_spotify/tools/models.py b/toolkits/spotify/arcade_spotify/tools/models.py deleted file mode 100644 index 66c809b3..00000000 --- a/toolkits/spotify/arcade_spotify/tools/models.py +++ /dev/null @@ -1,59 +0,0 @@ -from dataclasses import asdict, dataclass, field -from enum import Enum - - -@dataclass -class PlaybackState: - is_playing: bool | None = None - progress_ms: int | None = ( - None # Progress into the currently playing track or episode in milliseconds - ) - device_name: str | None = None - device_id: str | None = None - currently_playing_type: str | None = None - album_id: str | None = None - album_name: str | None = None - album_artists: list[str] = field(default_factory=list) - album_spotify_url: str | None = None - track_id: str | None = None - track_name: str | None = None - track_spotify_url: str | None = None - track_artists: list[str] = field(default_factory=list) - track_artists_ids: list[str] = field(default_factory=list) - show_name: str | None = None - show_id: str | None = None - show_spotify_url: str | None = None - episode_name: str | None = None - episode_id: str | None = None - episode_spotify_url: str | None = None - message: str | None = None - - def to_dict(self) -> dict: - """Convert the PlaybackState instance to a dictionary, excluding None values.""" - return {k: v for k, v in asdict(self).items() if v is not None and v != []} - - -@dataclass -class Device: - id: str - is_active: bool - is_private_session: bool - is_restricted: bool - name: str - type: str - volume_percent: int - supports_volume: bool - - def to_dict(self) -> dict: - """Convert the Device instance to a dictionary.""" - return asdict(self) - - -class SearchType(str, Enum): - ALBUM = "album" - ARTIST = "artist" - PLAYLIST = "playlist" - TRACK = "track" - SHOW = "show" - EPISODE = "episode" - AUDIOBOOK = "audiobook" diff --git a/toolkits/spotify/arcade_spotify/tools/player.py b/toolkits/spotify/arcade_spotify/tools/player.py deleted file mode 100644 index 5ab16c93..00000000 --- a/toolkits/spotify/arcade_spotify/tools/player.py +++ /dev/null @@ -1,306 +0,0 @@ -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Spotify -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_spotify.tools.constants import RESPONSE_MSGS -from arcade_spotify.tools.models import Device, SearchType -from arcade_spotify.tools.search import search -from arcade_spotify.tools.utils import ( - convert_to_playback_state, - get_url, - send_spotify_request, -) - - -# NOTE: This tool only works for Spotify Premium users -@tool(requires_auth=Spotify(scopes=["user-read-playback-state", "user-modify-playback-state"])) -async def adjust_playback_position( - context: ToolContext, - absolute_position_ms: Annotated[ - int | None, "The absolute position in milliseconds to seek to" - ] = None, - relative_position_ms: Annotated[ - int | None, - "The relative position from the current playback position in milliseconds to seek to", - ] = None, -) -> Annotated[str, "Success/failure message"]: - """Adjust the playback position within the currently playing track. - - Knowledge of the current playback state is NOT needed to use this tool as it handles - clamping the position to valid start/end boundaries to prevent overshooting or negative values. - - This tool allows you to seek to a specific position within the currently playing track. - You can either provide an absolute position in milliseconds or a relative position from - the current playback position in milliseconds. - - Note: - Either absolute_position_ms or relative_position_ms must be provided, but not both. - """ - if (absolute_position_ms is None) == (relative_position_ms is None): - raise RetryableToolError( - "Either absolute_position_ms or relative_position_ms must be provided, but not both", - additional_prompt_content=( - "Provide a value for either absolute_position_ms or " - "relative_position_ms, but not both." - ), - retry_after_ms=500, - ) - - if relative_position_ms is not None: - playback_state = await get_playback_state(context) - if playback_state.get("device_id") is None: - return RESPONSE_MSGS["no_track_to_adjust_position"] - - absolute_position_ms = playback_state["progress_ms"] + relative_position_ms - - absolute_position_ms = max(0, absolute_position_ms or 0) - - url = get_url("player_seek_to_position") - params = {"position_ms": absolute_position_ms} - - try: - response = await send_spotify_request(context, "PUT", url, params=params) - except httpx.HTTPStatusError as e: - raise ToolExecutionError(f"Failed to adjust playback position: {e}") from e - - if response.status_code == 404: - return RESPONSE_MSGS["no_track_to_adjust_position"] - - response.raise_for_status() - - return RESPONSE_MSGS["playback_position_adjusted"] - - -# NOTE: This tool only works for Spotify Premium users -@tool(requires_auth=Spotify(scopes=["user-read-playback-state", "user-modify-playback-state"])) -async def skip_to_previous_track( - context: ToolContext, -) -> Annotated[str, "Success/failure message"]: - """Skip to the previous track in the user's queue, if any""" - url = get_url("player_skip_to_previous") - - response = await send_spotify_request(context, "POST", url) - - if response.status_code == 404: - return RESPONSE_MSGS["no_track_to_go_back_to"] - - response.raise_for_status() - - return RESPONSE_MSGS["playback_skipped_to_previous_track"] - - -# NOTE: This tool only works for Spotify Premium users -@tool(requires_auth=Spotify(scopes=["user-read-playback-state", "user-modify-playback-state"])) -async def skip_to_next_track( - context: ToolContext, -) -> Annotated[str, "Success/failure message"]: - """Skip to the next track in the user's queue, if any""" - url = get_url("player_skip_to_next") - - response = await send_spotify_request(context, "POST", url) - - if response.status_code == 404: - return RESPONSE_MSGS["no_track_to_skip"] - - response.raise_for_status() - - return RESPONSE_MSGS["playback_skipped_to_next_track"] - - -# NOTE: This tool only works for Spotify Premium users -@tool(requires_auth=Spotify(scopes=["user-read-playback-state", "user-modify-playback-state"])) -async def pause_playback( - context: ToolContext, -) -> Annotated[str, "Success/failure message"]: - """Pause the currently playing track, if any""" - playback_state = await get_playback_state(context) - - # There is no current state, therefore nothing to pause - if playback_state.get("device_id") is None: - return RESPONSE_MSGS["no_track_to_pause"] - # Track is already paused - if playback_state.get("is_playing") is False: - return RESPONSE_MSGS["track_is_already_paused"] - - url = get_url("player_pause_playback") - - response = await send_spotify_request(context, "PUT", url) - response.raise_for_status() - - return RESPONSE_MSGS["playback_paused"] - - -# NOTE: This tool only works for Spotify Premium users -@tool( - requires_auth=Spotify( - scopes=["user-read-playback-state", "user-modify-playback-state"], - ) -) -async def resume_playback( - context: ToolContext, -) -> Annotated[str, "Success/failure message"]: - """Resume the currently playing track, if any""" - playback_state = await get_playback_state(context) - - # There is no current state, therefore nothing to resume - if playback_state.get("device_id") is None: - return RESPONSE_MSGS["no_track_to_resume"] - # Track is already playing - if playback_state.get("is_playing") is True: - return RESPONSE_MSGS["track_is_already_playing"] - - url = get_url("player_modify_playback") - - response = await send_spotify_request(context, "PUT", url) - response.raise_for_status() - - return RESPONSE_MSGS["playback_resumed"] - - -# NOTE: This tool only works for Spotify Premium users -@tool( - requires_auth=Spotify( - scopes=["user-read-playback-state", "user-modify-playback-state"], - ) -) -async def start_tracks_playback_by_id( - context: ToolContext, - track_ids: Annotated[ - list[str], - "A list of Spotify track (song) IDs to play. Order of execution is not guarenteed.", - ], - position_ms: Annotated[ - int | None, - "The position in milliseconds to start the first track from", - ] = 0, -) -> Annotated[str, "Success/failure message"]: - """Start playback of a list of tracks (songs)""" - - devices = [ - Device(**device) for device in (await get_available_devices(context)).get("devices", []) - ] - - # If no active device is available, pick the first one. - # Otherwise, Spotify defaults to the active device. - device_id = None - if devices and not any(device.is_active for device in devices): - device_id = devices[0].id - - params = {"device_id": device_id} if device_id else {} - - url = get_url("player_modify_playback") - body = { - "uris": [f"spotify:track:{track_id}" for track_id in track_ids], - "position_ms": position_ms, - } - - response = await send_spotify_request(context, "PUT", url, params=params, json_data=body) - - if response.status_code == 404: - return RESPONSE_MSGS["no_active_device"] - - response.raise_for_status() - - return RESPONSE_MSGS["playback_started"] - - -@tool(requires_auth=Spotify(scopes=["user-read-playback-state"])) -async def get_playback_state( - context: ToolContext, -) -> Annotated[dict, "Information about the user's current playback state"]: - """ - Get information about the user's current playback state, - including track or episode, and active device. - This tool does not perform any actions. Use other tools to control playback. - """ - url = get_url("player_get_playback_state") - response = await send_spotify_request(context, "GET", url) - response.raise_for_status() - data = {"is_playing": False} if response.status_code == 204 else response.json() - return convert_to_playback_state(data).to_dict() - - -@tool(requires_auth=Spotify(scopes=["user-read-currently-playing"])) -async def get_currently_playing( - context: ToolContext, -) -> Annotated[dict, "Information about the user's currently playing track"]: - """Get information about the user's currently playing track""" - url = get_url("player_get_currently_playing") - response = await send_spotify_request(context, "GET", url) - response.raise_for_status() - data = {"is_playing": False} if response.status_code == 204 else response.json() - return convert_to_playback_state(data).to_dict() - - -# NOTE: This tool only works for Spotify Premium users -@tool( - requires_auth=Spotify( - scopes=["user-read-playback-state", "user-modify-playback-state"], - ) -) -async def play_artist_by_name( - context: ToolContext, - name: Annotated[str, "The name of the artist to play"], -) -> Annotated[str, "Success/failure message"]: - """Plays a song by an artist and queues four more songs by the same artist""" - q = f"artist:{name}" - search_results = await search(context, q, [SearchType.TRACK], 5) - if not search_results["tracks"]["items"]: - message = RESPONSE_MSGS["artist_not_found"].format(artist_name=name) - raise RetryableToolError( - message, - additional_prompt_content=f"{message} Try a different artist name.", - retry_after_ms=500, - ) - track_ids = [item["id"] for item in search_results["tracks"]["items"]] - response = await start_tracks_playback_by_id(context, track_ids) - - return str(response) - - -# NOTE: This tool only works for Spotify Premium users -@tool( - requires_auth=Spotify( - scopes=["user-read-playback-state", "user-modify-playback-state"], - ) -) -async def play_track_by_name( - context: ToolContext, - track_name: Annotated[str, "The name of the track to play"], - artist_name: Annotated[str | None, "The name of the artist of the track"] = None, -) -> Annotated[str, "Success/failure message"]: - """Plays a song by name""" - q = f"track:{track_name}" - if artist_name: - q += f" artist:{artist_name}" - search_results = await search(context, q, [SearchType.TRACK], 1) - - if not search_results["tracks"]["items"]: - message = RESPONSE_MSGS["track_not_found"].format(track_name=track_name) - if artist_name: - message += f" by '{artist_name}'" - raise RetryableToolError( - message, - additional_prompt_content=f"{message}. Try a different track name or artist name.", - retry_after_ms=500, - ) - - track_id = search_results["tracks"]["items"][0]["id"] - response = await start_tracks_playback_by_id(context, [track_id]) - - return str(response) - - -# NOTE: This tool only works for Spotify Premium users -@tool(requires_auth=Spotify(scopes=["user-read-playback-state"])) -async def get_available_devices( - context: ToolContext, -) -> Annotated[dict, "The available devices"]: - """Get the available devices""" - url = get_url("player_get_available_devices") - response = await send_spotify_request(context, "GET", url) - response.raise_for_status() - return dict(response.json()) diff --git a/toolkits/spotify/arcade_spotify/tools/search.py b/toolkits/spotify/arcade_spotify/tools/search.py deleted file mode 100644 index b668ecba..00000000 --- a/toolkits/spotify/arcade_spotify/tools/search.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Spotify - -from arcade_spotify.tools.models import SearchType -from arcade_spotify.tools.utils import ( - get_url, - send_spotify_request, -) - - -@tool(requires_auth=Spotify()) -async def search( - context: ToolContext, - q: Annotated[str, "The search query"], - types: Annotated[list[SearchType], "The types of results to return"], - limit: Annotated[int, "The maximum number of results to return"] = 1, -) -> Annotated[dict, "A list of artists matching the search query"]: - """Search Spotify catalog information - - Explanation of the q parameter: - You can narrow down your search using field filters. - Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and - genre. Each field filter only applies to certain result types. - - The artist and year filters can be used while searching albums, artists and tracks. - You can filter on a single year or a range (e.g. 1955-1960). - The album filter can be used while searching albums and tracks. - The genre filter can be used while searching artists and tracks. - The isrc and track filters can be used while searching tracks. - The upc, tag:new and tag:hipster filters can only be used while searching albums. - The tag:new filter will return albums released in the past two weeks and tag:hipster - can be used to return only albums with the lowest 10% popularity. - - Example: q="remaster track:Doxy artist:Miles Davis" - """ - - url = get_url("search", q=q) - - response = await send_spotify_request( - context, "GET", url, params={"q": q, "type": ",".join(types), "limit": limit} - ) - response.raise_for_status() - return dict(response.json()) diff --git a/toolkits/spotify/arcade_spotify/tools/tracks.py b/toolkits/spotify/arcade_spotify/tools/tracks.py deleted file mode 100644 index c636629e..00000000 --- a/toolkits/spotify/arcade_spotify/tools/tracks.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Spotify - -from arcade_spotify.tools.utils import ( - get_url, - send_spotify_request, -) - - -@tool(requires_auth=Spotify()) -async def get_track_from_id( - context: ToolContext, - track_id: Annotated[str, "The Spotify ID of the track"], -) -> Annotated[dict, "Information about the track"]: - """Get information about a track""" - url = get_url("tracks_get_track", track_id=track_id) - - response = await send_spotify_request(context, "GET", url) - response.raise_for_status() - return dict(response.json()) diff --git a/toolkits/spotify/arcade_spotify/tools/utils.py b/toolkits/spotify/arcade_spotify/tools/utils.py deleted file mode 100644 index df3d32e5..00000000 --- a/toolkits/spotify/arcade_spotify/tools/utils.py +++ /dev/null @@ -1,94 +0,0 @@ -import httpx -from arcade_tdk import ToolContext - -from arcade_spotify.tools.constants import ENDPOINTS, SPOTIFY_BASE_URL -from arcade_spotify.tools.models import PlaybackState - - -async def send_spotify_request( - context: ToolContext, - method: str, - url: str, - params: dict | None = None, - json_data: dict | None = None, -) -> httpx.Response: - """ - Send an asynchronous request to the Spotify API. - - Args: - context: The tool context containing the authorization token. - method: The HTTP method (GET, POST, PUT, DELETE, etc.). - url: The full URL for the API endpoint. - params: Query parameters to include in the request. - json_data: JSON data to include in the request body. - - Returns: - The response object from the API request. - - Raises: - ToolExecutionError: If the request fails for any reason. - """ - token = ( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - headers = {"Authorization": f"Bearer {token}"} - - async with httpx.AsyncClient() as client: - response = await client.request(method, url, headers=headers, params=params, json=json_data) - - return response - - -def get_url(endpoint: str, **kwargs: object) -> str: - """ - Get the full Spotify URL for a given endpoint. - - :param endpoint: The endpoint key from ENDPOINTS - :param kwargs: The parameters to format the URL with - :return: The full URL - """ - return f"{SPOTIFY_BASE_URL}{ENDPOINTS[endpoint].format(**kwargs)}" - - -def convert_to_playback_state(data: dict) -> PlaybackState: - """ - Convert the Spotify API endpoint "/me/player" response data to a PlaybackState object. - - Args: - data: The response data from the Spotify API endpoint "/me/player". - - Returns: - An instance of PlaybackState populated with the data. - """ - playback_state = PlaybackState( - device_name=data.get("device", {}).get("name"), - device_id=data.get("device", {}).get("id"), - currently_playing_type=data.get("currently_playing_type"), - is_playing=data.get("is_playing"), - progress_ms=data.get("progress_ms"), - message=data.get("message"), - ) - - if data.get("currently_playing_type") == "track": - item = data.get("item") or {} - album = item.get("album", {}) - playback_state.album_name = album.get("name") - playback_state.album_id = album.get("id") - playback_state.album_artists = [artist.get("name") for artist in album.get("artists", [])] - playback_state.album_spotify_url = album.get("external_urls", {}).get("spotify") - playback_state.track_name = item.get("name") - playback_state.track_id = item.get("id") - playback_state.track_spotify_url = item.get("external_urls", {}).get("spotify") - playback_state.track_artists = [artist.get("name") for artist in item.get("artists", [])] - playback_state.track_artists_ids = [artist.get("id") for artist in item.get("artists", [])] - elif data.get("currently_playing_type") == "episode": - item = data.get("item") or {} - show = item.get("show", {}) - playback_state.show_name = show.get("name") - playback_state.show_id = show.get("id") - playback_state.show_spotify_url = show.get("external_urls", {}).get("spotify") - playback_state.episode_name = item.get("name") - playback_state.episode_id = item.get("id") - playback_state.episode_spotify_url = item.get("external_urls", {}).get("spotify") - - return playback_state diff --git a/toolkits/spotify/conftest.py b/toolkits/spotify/conftest.py deleted file mode 100644 index 41812667..00000000 --- a/toolkits/spotify/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest -from arcade_tdk import ToolContext - - -@pytest.fixture -def tool_context(): - """Fixture for the ToolContext with mock authorization.""" - return ToolContext(authorization={"token": "test_token", "user_id": "test_user"}) - - -@pytest.fixture -def mock_httpx_client(mocker): - """Fixture to mock the httpx.AsyncClient.""" - # Mock the AsyncClient context manager - mock_client = mocker.patch("httpx.AsyncClient", autospec=True) - async_mock_client = mock_client.return_value.__aenter__.return_value - return async_mock_client - - -@pytest.fixture -def sample_track(): - """Fixture for a sample track.""" - return { - "album": {"id": "1234567890", "name": "Test Album", "uri": "spotify:album:1234567890"}, - "artists": [{"name": "Test Artist", "type": "artist", "uri": "spotify:artist:1234567890"}], - "available_markets": ["us"], - "duration_ms": 123456, - "id": "1234567890", - "is_playable": True, - "name": "Test Track", - "popularity": 100, - "type": "track", - "uri": "spotify:track:1234567890", - } diff --git a/toolkits/spotify/evals/eval_player.py b/toolkits/spotify/evals/eval_player.py deleted file mode 100644 index 81995651..00000000 --- a/toolkits/spotify/evals/eval_player.py +++ /dev/null @@ -1,174 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NumericCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_spotify.tools.player import ( - adjust_playback_position, - get_currently_playing, - get_playback_state, - pause_playback, - play_artist_by_name, - play_track_by_name, - resume_playback, - skip_to_next_track, - skip_to_previous_track, - start_tracks_playback_by_id, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(adjust_playback_position, "Spotify") -catalog.add_tool(skip_to_next_track, "Spotify") -catalog.add_tool(skip_to_previous_track, "Spotify") -catalog.add_tool(pause_playback, "Spotify") -catalog.add_tool(resume_playback, "Spotify") -catalog.add_tool(start_tracks_playback_by_id, "Spotify") -catalog.add_tool(get_playback_state, "Spotify") -catalog.add_tool(get_currently_playing, "Spotify") -catalog.add_tool(play_artist_by_name, "Spotify") -catalog.add_tool(play_track_by_name, "Spotify") - - -@tool_eval() -def spotify_player_eval_suite() -> EvalSuite: - """Create an evaluation suite for Spotify "player" tools.""" - suite = EvalSuite( - name="Spotify Tools Evaluation", - system_message="You are an AI assistant that can manage Spotify using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Adjust playback position", - user_message="can you skip to the 10th second of the song", - expected_tool_calls=[ - ExpectedToolCall( - func=adjust_playback_position, - args={"absolute_position_ms": 10000}, - ) - ], - critics=[ - NumericCritic( - critic_field="absolute_position_ms", weight=1.0, value_range=(9000, 11000) - ), - ], - ) - - suite.add_case( - name="Adjust playback position relative to current position", - user_message="go back 10 seconds", - expected_tool_calls=[ - ExpectedToolCall( - func=adjust_playback_position, - args={"relative_position_ms": -10000}, - ) - ], - critics=[ - NumericCritic( - critic_field="relative_position_ms", - weight=1.0, - value_range=(-11000, -9000), - ), - ], - ) - - suite.add_case( - name="Skip to previous track", - user_message="oops i didn't mean to skip that song, go back", - expected_tool_calls=[ExpectedToolCall(func=skip_to_previous_track, args={})], - ) - - suite.add_case( - name="Skip to next track", - user_message="skip this song and also the next one", - expected_tool_calls=[ - ExpectedToolCall(func=skip_to_next_track, args={}), - ExpectedToolCall(func=skip_to_next_track, args={}), - ], - ) - - suite.add_case( - name="Pause playback", - user_message="wait im getting a text, stop playing it please", - expected_tool_calls=[ExpectedToolCall(func=pause_playback, args={})], - ) - - suite.add_case( - name="Resume playback", - user_message="ok i'm back, you can press play again", - expected_tool_calls=[ExpectedToolCall(func=resume_playback, args={})], - ) - - suite.add_case( - name="Start playback of a list of tracks", - user_message="Play these two 03gaqN3aWm9TQxuHay0G8R, 03gaqN3aWm9TQxuHay0G8R. But start at the 10th second of the first track", - expected_tool_calls=[ - ExpectedToolCall( - func=start_tracks_playback_by_id, - args={ - "track_ids": ["03gaqN3aWm9TQxuHay0G8R", "03gaqN3aWm9TQxuHay0G8R"], - "position_ms": 10000, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="track_ids", weight=0.5), - NumericCritic(critic_field="position_ms", weight=0.5, value_range=(9000, 11000)), - ], - ) - - suite.add_case( - name="Get playback state", - user_message="what's the name of this song and who plays it?", - expected_tool_calls=[ExpectedToolCall(func=get_currently_playing, args={})], - ) - - suite.add_case( - name="Get playback state", - user_message="what device is playing music rn?", - expected_tool_calls=[ExpectedToolCall(func=get_playback_state, args={})], - ) - - suite.add_case( - name="Play artist by name", - user_message="play pearl jam", - expected_tool_calls=[ - ExpectedToolCall( - func=play_artist_by_name, - args={"name": "Pearl Jam"}, - ) - ], - critics=[ - SimilarityCritic(critic_field="name", weight=1.0), - ], - ) - - suite.add_case( - name="Play track by name", - user_message="it would be really great if I could listen to strobe by deadmau5 right now.", - expected_tool_calls=[ - ExpectedToolCall( - func=play_track_by_name, - args={"track_name": "strobe", "artist_name": "deadmau5"}, - ) - ], - critics=[ - SimilarityCritic(critic_field="track_name", weight=0.5), - SimilarityCritic(critic_field="artist_name", weight=0.5), - ], - ) - - return suite diff --git a/toolkits/spotify/evals/eval_search.py b/toolkits/spotify/evals/eval_search.py deleted file mode 100644 index ad02eff9..00000000 --- a/toolkits/spotify/evals/eval_search.py +++ /dev/null @@ -1,54 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_spotify.tools.models import SearchType -from arcade_spotify.tools.search import search - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(search, "Spotify") - - -@tool_eval() -def spotify_search_eval_suite() -> EvalSuite: - """Create an evaluation suite for Spotify "player" tools.""" - suite = EvalSuite( - name="Spotify Tools Evaluation", - system_message="You are an AI assistant that can manage Spotify using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Search Spotify catalog", - user_message="search for 3 songs in the the album 'American IV: The Man Comes Around' by Johnny Cash", - expected_tool_calls=[ - ExpectedToolCall( - func=search, - args={ - "q": "album:American IV: The Man Comes Around artist:Johnny Cash", - "types": [SearchType.TRACK], - "limit": 3, - }, - ) - ], - critics=[ - SimilarityCritic(critic_field="q", weight=0.5), - BinaryCritic(critic_field="limit", weight=0.25), - BinaryCritic(critic_field="types", weight=0.25), - ], - ) - - return suite diff --git a/toolkits/spotify/evals/eval_tracks.py b/toolkits/spotify/evals/eval_tracks.py deleted file mode 100644 index baf2483f..00000000 --- a/toolkits/spotify/evals/eval_tracks.py +++ /dev/null @@ -1,75 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -from arcade_spotify.tools.tracks import get_track_from_id - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_tool(get_track_from_id, "Spotify") - -# This history is a conversation where the user asks for the currently playing song, and then asks for information about it. -history = [ - {"role": "user", "content": "what song is playing rn"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_fqZHT1DNcil1pbIv6UbOWVq9", - "type": "function", - "function": {"name": "Spotify_GetCurrentlyPlaying", "arguments": "{}"}, - } - ], - }, - { - "role": "tool", - "content": '{"album_artists":["Gajaka"],"album_id":"7b5h7MJ70KC92FrwSvRaQi","album_name":"Aerial","album_spotify_url":"https://open.spotify.com/album/7b5h7MJ70KC92FrwSvRaQi","currently_playing_type":"track","is_playing":true,"progress_ms":96962,"track_artists":["Gajaka"],"track_id":"03gaqN3aWm9TQxuHay0G8R","track_name":"Aerial"}', - "tool_call_id": "call_fqZHT1DNcil1pbIv6UbOWVq9", - "name": "Spotify_GetCurrentlyPlaying", - }, - { - "role": "assistant", - "content": 'The song currently playing is "Aerial" by Gajaka. You can listen to it on Spotify [here](https://open.spotify.com/album/7b5h7MJ70KC92FrwSvRaQi).', - }, -] - - -@tool_eval() -def spotify_tracks_eval_suite() -> EvalSuite: - """Create an evaluation suite for Spotify "track" tools.""" - suite = EvalSuite( - name="Spotify Tools Evaluation", - system_message="You are an AI assistant that can manage Spotify using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Get information about a track", - user_message="can you get more info about that song", - expected_tool_calls=[ - ExpectedToolCall( - func=get_track_from_id, - args={ - "track_id": "03gaqN3aWm9TQxuHay0G8R", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="track_id", weight=1.0), - ], - additional_messages=history, - ) - - return suite diff --git a/toolkits/spotify/pyproject.toml b/toolkits/spotify/pyproject.toml deleted file mode 100644 index c5ccb57c..00000000 --- a/toolkits/spotify/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_spotify" -version = "0.2.4" -description = "Arcade.dev LLM tools for Spotify" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "httpx>=0.27.2,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_spotify/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_spotify",] diff --git a/toolkits/spotify/tests/__init__.py b/toolkits/spotify/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/spotify/tests/test_player.py b/toolkits/spotify/tests/test_player.py deleted file mode 100644 index b876218c..00000000 --- a/toolkits/spotify/tests/test_player.py +++ /dev/null @@ -1,515 +0,0 @@ -from unittest.mock import MagicMock, patch - -import httpx -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_spotify.tools.constants import RESPONSE_MSGS -from arcade_spotify.tools.models import SearchType -from arcade_spotify.tools.player import ( - adjust_playback_position, - get_available_devices, - get_currently_playing, - get_playback_state, - pause_playback, - play_artist_by_name, - play_track_by_name, - resume_playback, - skip_to_next_track, - skip_to_previous_track, - start_tracks_playback_by_id, -) -from arcade_spotify.tools.utils import get_url - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, tool_kwargs", - [ - (adjust_playback_position, {"absolute_position_ms": 10000}), - (get_available_devices, {}), - (get_currently_playing, {}), - (get_playback_state, {}), - (pause_playback, {}), - (resume_playback, {}), - (start_tracks_playback_by_id, {"track_ids": ["1234567890"], "position_ms": 10000}), - (skip_to_previous_track, {}), - (skip_to_next_track, {}), - ], -) -async def test_too_many_requests_http_error( - tool_function, tool_kwargs, tool_context, mock_httpx_client -): - mock_response = MagicMock() - mock_response.status_code = 429 - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429) - ) - mock_httpx_client.request.return_value = mock_response - - with pytest.raises(ToolExecutionError): - await tool_function(context=tool_context, **tool_kwargs) - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_adjust_playback_position_absolute_success( - mock_get_playback_state, tool_context, mock_httpx_client -): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await adjust_playback_position(context=tool_context, absolute_position_ms=10000) - - assert response == RESPONSE_MSGS["playback_position_adjusted"] - - mock_get_playback_state.assert_not_called() - mock_httpx_client.request.assert_called_once_with( - "PUT", - get_url("player_seek_to_position"), - headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, - params={"position_ms": 10000}, - json=None, - ) - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_adjust_playback_position_relative_success( - mock_get_playback_state, tool_context, mock_httpx_client -): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - mock_get_playback_state.return_value = {"device_id": "1234567890", "progress_ms": 10000} - response = await adjust_playback_position(context=tool_context, relative_position_ms=10000) - - assert response == RESPONSE_MSGS["playback_position_adjusted"] - - mock_get_playback_state.assert_called_once_with(tool_context) - mock_httpx_client.request.assert_called_once_with( - "PUT", - get_url("player_seek_to_position"), - headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, - params={"position_ms": 20000}, - json=None, - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, tool_kwargs", - [ - # Both arguments provided - ( - adjust_playback_position, - {"absolute_position_ms": 10000, "relative_position_ms": 10000}, - ), - # No arguments provided - ( - adjust_playback_position, - {}, - ), - ], -) -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_adjust_playback_position_wrong_arguments_error( - mock_get_playback_state, tool_context, mock_httpx_client, tool_function, tool_kwargs -): - with pytest.raises(RetryableToolError): - await tool_function(context=tool_context, **tool_kwargs) - - mock_get_playback_state.assert_not_called() - mock_httpx_client.assert_not_called() - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_adjust_playback_position_no_device_error( - mock_get_playback_state, tool_context, mock_httpx_client -): - mock_get_playback_state.return_value = {"device_id": None} - - response = await adjust_playback_position(context=tool_context, relative_position_ms=10000) - - assert response == RESPONSE_MSGS["no_track_to_adjust_position"] - - mock_get_playback_state.assert_called_once_with(tool_context) - mock_httpx_client.assert_not_called() - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_adjust_playback_position_not_found_error( - mock_get_playback_state, tool_context, mock_httpx_client -): - mock_response = MagicMock() - mock_response.status_code = 404 - mock_httpx_client.request.return_value = mock_response - - response = await adjust_playback_position(context=tool_context, absolute_position_ms=10000) - - assert response == RESPONSE_MSGS["no_track_to_adjust_position"] - - mock_get_playback_state.assert_not_called() - mock_httpx_client.request.assert_called_once_with( - "PUT", - get_url("player_seek_to_position"), - headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, - params={"position_ms": 10000}, - json=None, - ) - - -@pytest.mark.asyncio -async def test_skip_to_previous_track_success(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await skip_to_previous_track(context=tool_context) - - assert response == RESPONSE_MSGS["playback_skipped_to_previous_track"] - - -@pytest.mark.asyncio -async def test_skip_to_previous_track_not_found_error(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response.status_code = 404 - mock_httpx_client.request.return_value = mock_response - - response = await skip_to_previous_track(context=tool_context) - - assert response == RESPONSE_MSGS["no_track_to_go_back_to"] - - -@pytest.mark.asyncio -async def test_skip_to_next_track_success(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await skip_to_next_track(context=tool_context) - - assert response == RESPONSE_MSGS["playback_skipped_to_next_track"] - - -@pytest.mark.asyncio -async def test_skip_to_next_track_not_found_error(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response.status_code = 404 - mock_httpx_client.request.return_value = mock_response - - response = await skip_to_next_track(context=tool_context) - - assert response == RESPONSE_MSGS["no_track_to_skip"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, mock_is_playing, expected_message", - [ - (pause_playback, True, RESPONSE_MSGS["playback_paused"]), - (resume_playback, False, RESPONSE_MSGS["playback_resumed"]), - ], -) -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_change_playback_state_success( - mock_get_playback_state, - tool_context, - tool_function, - mock_is_playing, - expected_message, - mock_httpx_client, -): - mock_get_playback_state.return_value = { - "device_id": "1234567890", - "is_playing": mock_is_playing, - } - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await tool_function(context=tool_context) - assert response == expected_message - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, expected_message", - [ - (pause_playback, RESPONSE_MSGS["no_track_to_pause"]), - (resume_playback, RESPONSE_MSGS["no_track_to_resume"]), - ], -) -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_change_playback_state_no_device_running( - mock_get_playback_state, tool_context, tool_function, expected_message, mock_httpx_client -): - mock_get_playback_state.return_value = {"device_id": None} - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await tool_function(context=tool_context) - assert response == expected_message - mock_httpx_client.assert_not_called() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, mock_is_playing, expected_message", - [ - (pause_playback, False, RESPONSE_MSGS["track_is_already_paused"]), - (resume_playback, True, RESPONSE_MSGS["track_is_already_playing"]), - ], -) -@patch("arcade_spotify.tools.player.get_playback_state") -async def test_change_playback_state_already_set_success( - mock_get_playback_state, - tool_context, - tool_function, - mock_is_playing, - expected_message, - mock_httpx_client, -): - mock_get_playback_state.return_value = { - "device_id": "1234567890", - "is_playing": mock_is_playing, - } - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await tool_function(context=tool_context) - assert response == expected_message - mock_httpx_client.assert_not_called() - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.get_available_devices") -async def test_start_tracks_playback_by_id_success( - mock_get_available_devices, tool_context, mock_httpx_client -): - mock_get_available_devices.return_value = { - "devices": [ - { - "id": "1234567890", - "is_active": True, - "name": "Test Device", - "type": "Computer", - "is_private_session": False, - "is_restricted": False, - "supports_volume": True, - "volume_percent": 100, - } - ] - } - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await start_tracks_playback_by_id( - context=tool_context, track_ids=["1234567890"], position_ms=10000 - ) - assert response == RESPONSE_MSGS["playback_started"] - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.get_available_devices") -async def test_start_tracks_playback_by_id_no_active_device( - mock_get_available_devices, tool_context, mock_httpx_client -): - mock_get_available_devices.return_value = {"devices": []} - mock_response = MagicMock() - mock_response.status_code = 404 - mock_httpx_client.request.return_value = mock_response - - response = await start_tracks_playback_by_id( - context=tool_context, track_ids=["1234567890"], position_ms=10000 - ) - assert response == RESPONSE_MSGS["no_active_device"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, expected_message", - [ - (get_playback_state, RESPONSE_MSGS["playback_started"]), - (get_currently_playing, RESPONSE_MSGS["playback_started"]), - ], -) -async def test_get_state_success( - tool_context, - mock_httpx_client, - tool_function, - expected_message, -): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "device": { - "id": "1234567890", - "is_active": True, - "name": "Test Device", - "type": "Computer", - }, - "currently_playing_type": "track", - "is_playing": True, - "progress_ms": 10000, - "message": "Playback started", - } - mock_httpx_client.request.return_value = mock_response - - response = await tool_function(context=tool_context) - - assert response["device_id"] == "1234567890" - assert response["device_name"] == "Test Device" - assert response["is_playing"] is True - assert response["progress_ms"] == 10000 - assert response["message"] == "Playback started" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function", - [get_playback_state, get_currently_playing], -) -async def test_get_state_playback_not_active(tool_context, mock_httpx_client, tool_function): - mock_response = MagicMock() - mock_response.status_code = 204 - mock_httpx_client.request.return_value = mock_response - - response = await tool_function(context=tool_context) - - assert response["is_playing"] is False - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, tool_kwargs, expected_search_query, expected_limit", - [ - (play_artist_by_name, {"name": "Test Artist"}, "artist:Test Artist", 5), - (play_track_by_name, {"track_name": "Test Track"}, "track:Test Track", 1), - ], -) -@patch("arcade_spotify.tools.player.start_tracks_playback_by_id") -@patch("arcade_spotify.tools.player.search") -async def test_play_by_name_success( - mock_search, - mock_start_tracks_playback_by_id, - tool_context, - tool_function, - tool_kwargs, - expected_search_query, - expected_limit, - mock_httpx_client, -): - track_id = "1234567890" - mock_search.return_value = {"tracks": {"items": [{"id": track_id, "name": "Test Track"}]}} - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - mock_start_tracks_playback_by_id.return_value = RESPONSE_MSGS["playback_started"] - - response = await tool_function(context=tool_context, **tool_kwargs) - - assert response == RESPONSE_MSGS["playback_started"] - - mock_search.assert_called_once_with( - tool_context, - expected_search_query, - [SearchType.TRACK], - expected_limit, - ) - mock_start_tracks_playback_by_id.assert_called_once_with(tool_context, [track_id]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "tool_function, tool_kwargs, expected_search_query, expected_limit, expected_message", - [ - ( - play_artist_by_name, - {"name": "Test Artist"}, - "artist:Test Artist", - 5, - RESPONSE_MSGS["artist_not_found"].format(artist_name="Test Artist"), - ), - ( - play_track_by_name, - {"track_name": "Test Track"}, - "track:Test Track", - 1, - RESPONSE_MSGS["track_not_found"].format(track_name="Test Track"), - ), - ], -) -@patch("arcade_spotify.tools.player.start_tracks_playback_by_id") -@patch("arcade_spotify.tools.player.search") -async def test_play_by_name_no_tracks_found( - mock_search, - mock_start_tracks_playback_by_id, - tool_context, - tool_function, - tool_kwargs, - expected_search_query, - expected_limit, - expected_message, - mock_httpx_client, -): - mock_search.return_value = {"tracks": {"items": []}} - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - mock_start_tracks_playback_by_id.return_value = RESPONSE_MSGS["playback_started"] - - with pytest.raises(RetryableToolError) as e: - await tool_function(context=tool_context, **tool_kwargs) - assert e.value.message == expected_message - - mock_search.assert_called_once_with( - tool_context, expected_search_query, [SearchType.TRACK], expected_limit - ) - mock_start_tracks_playback_by_id.assert_not_called() - - -@pytest.mark.asyncio -@patch("arcade_spotify.tools.player.start_tracks_playback_by_id") -@patch("arcade_spotify.tools.player.search") -async def test_play_track_by_name_with_artist_success( - mock_search, mock_start_tracks_playback_by_id, tool_context, mock_httpx_client -): - track_id = "1234567890" - mock_search.return_value = {"tracks": {"items": [{"id": track_id, "name": "Test Track"}]}} - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await play_track_by_name( - context=tool_context, track_name="Test Track", artist_name="Test Artist" - ) - - assert response == str(mock_start_tracks_playback_by_id.return_value) - - mock_search.assert_called_once_with( - tool_context, "track:Test Track artist:Test Artist", [SearchType.TRACK], 1 - ) - mock_start_tracks_playback_by_id.assert_called_once_with(tool_context, [track_id]) - - -@pytest.mark.asyncio -async def test_get_available_devices_success(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "devices": [{"id": "1234567890", "name": "Test Device", "type": "Computer"}] - } - mock_httpx_client.request.return_value = mock_response - - response = await get_available_devices(context=tool_context) - assert response == dict(mock_response.json()) diff --git a/toolkits/spotify/tests/test_search.py b/toolkits/spotify/tests/test_search.py deleted file mode 100644 index 0af1640e..00000000 --- a/toolkits/spotify/tests/test_search.py +++ /dev/null @@ -1,59 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_spotify.tools.models import SearchType -from arcade_spotify.tools.search import search -from arcade_spotify.tools.utils import get_url - - -@pytest.mark.asyncio -async def test_search_success(tool_context, mock_httpx_client, sample_track): - sample_tracks = [] - for i in range(4): - sample_track = sample_track.copy() - sample_track["id"] = f"{i}" - sample_tracks.append(sample_track) - - search_response = { - "tracks": { - "href": "https://api.spotify.com/v1/me/shows?offset=0&limit=20", - "limit": 20, - "next": "https://api.spotify.com/v1/me/shows?offset=1&limit=1", - "offset": 0, - "previous": "https://api.spotify.com/v1/me/shows?offset=1&limit=1", - "total": 4, - "items": sample_tracks, - }, - } - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = search_response - mock_httpx_client.request.return_value = mock_response - - result = await search(tool_context, "test", [SearchType.TRACK], 4) - - assert result == search_response - - mock_httpx_client.request.assert_called_once_with( - "GET", - get_url("search", q="test"), - headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, - params={"q": "test", "type": SearchType.TRACK.value, "limit": 4}, - json=None, - ) - - -@pytest.mark.asyncio -async def test_search_rate_limit_error(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response = httpx.HTTPStatusError( - "Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429) - ) - mock_httpx_client.request.side_effect = mock_response - - with pytest.raises(ToolExecutionError): - await search(tool_context, "test", [SearchType.TRACK], 4) diff --git a/toolkits/spotify/tests/test_tracks.py b/toolkits/spotify/tests/test_tracks.py deleted file mode 100644 index ef9a9ed7..00000000 --- a/toolkits/spotify/tests/test_tracks.py +++ /dev/null @@ -1,40 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_spotify.tools.tracks import get_track_from_id -from arcade_spotify.tools.utils import get_url - - -@pytest.mark.asyncio -async def test_get_track_from_id_success(tool_context, mock_httpx_client, sample_track): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = sample_track - mock_httpx_client.request.return_value = mock_response - - result = await get_track_from_id(tool_context, "1234567890") - - assert result == sample_track - - mock_httpx_client.request.assert_called_once_with( - "GET", - get_url("tracks_get_track", track_id="1234567890"), - headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, - params=None, - json=None, - ) - - -@pytest.mark.asyncio -async def test_get_track_from_id_rate_limit_error(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response = httpx.HTTPStatusError( - "Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429) - ) - mock_httpx_client.request.side_effect = mock_response - - with pytest.raises(ToolExecutionError): - await get_track_from_id(tool_context, "1234567890") diff --git a/toolkits/spotify/tests/test_utils.py b/toolkits/spotify/tests/test_utils.py deleted file mode 100644 index ae915372..00000000 --- a/toolkits/spotify/tests/test_utils.py +++ /dev/null @@ -1,155 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from arcade_spotify.tools.models import PlaybackState -from arcade_spotify.tools.utils import convert_to_playback_state, send_spotify_request - - -@pytest.mark.asyncio -async def test_send_spotify_request(tool_context, mock_httpx_client): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.request.return_value = mock_response - - response = await send_spotify_request( - tool_context, - "GET", - "https://api.spotify.com/v1/me/player", - params={"param": "value"}, - json_data={"data": "value"}, - ) - assert response == mock_response - mock_httpx_client.request.assert_called_once_with( - "GET", - "https://api.spotify.com/v1/me/player", - headers={"Authorization": "Bearer test_token"}, - params={"param": "value"}, - json={"data": "value"}, - ) - - -def test_convert_to_playback_state(): - player_get_playback_state_response = { - "timestamp": 1734651060828, - "context": { - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1EYkqdzj48dyYq" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1EYkqdzj48dyYq", - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", - }, - "progress_ms": 261652, - "item": { - "album": { - "album_type": "album", - "artists": [ - { - "external_urls": { - "spotify": "https://open.spotify.com/artist/3GBPw9NK25X1Wt2OUvOwY3" - }, - "href": "https://api.spotify.com/v1/artists/3GBPw9NK25X1Wt2OUvOwY3", - "id": "3GBPw9NK25X1Wt2OUvOwY3", - "name": "Jack Johnson", - "type": "artist", - "uri": "spotify:artist:3GBPw9NK25X1Wt2OUvOwY3", - } - ], - "available_markets": [ - "AR", - "XK", - ], - "external_urls": { - "spotify": "https://open.spotify.com/album/23BBbqDGMhloT6f2YBecSr" - }, - "href": "https://api.spotify.com/v1/albums/23BBbqDGMhloT6f2YBecSr", - "id": "23BBbqDGMhloT6f2YBecSr", - "images": [ - { - "height": 640, - "url": "https://i.scdn.co/image/ab67616d0000b2732bd026ab797a3de9605d9cb3", - "width": 640, - }, - { - "height": 300, - "url": "https://i.scdn.co/image/ab67616d00001e022bd026ab797a3de9605d9cb3", - "width": 300, - }, - { - "height": 64, - "url": "https://i.scdn.co/image/ab67616d000048512bd026ab797a3de9605d9cb3", - "width": 64, - }, - ], - "name": "Brushfire Fairytales [Remastered (Bonus Version)]", - "release_date": "2011-04-12", - "release_date_precision": "day", - "total_tracks": 15, - "type": "album", - "uri": "spotify:album:23BBbqDGMhloT6f2YBecSr", - }, - "artists": [ - { - "external_urls": { - "spotify": "https://open.spotify.com/artist/3GBPw9NK25X1Wt2OUvOwY3" - }, - "href": "https://api.spotify.com/v1/artists/3GBPw9NK25X1Wt2OUvOwY3", - "id": "3GBPw9NK25X1Wt2OUvOwY3", - "name": "Jack Johnson", - "type": "artist", - "uri": "spotify:artist:3GBPw9NK25X1Wt2OUvOwY3", - } - ], - "available_markets": [ - "AR", - "XK", - ], - "disc_number": 1, - "duration_ms": 281749, - "explicit": False, - "external_ids": {"isrc": "USER81100105"}, - "external_urls": {"spotify": "https://open.spotify.com/track/54S3uCvfZauNw8lVCHZYYo"}, - "href": "https://api.spotify.com/v1/tracks/54S3uCvfZauNw8lVCHZYYo", - "id": "54S3uCvfZauNw8lVCHZYYo", - "is_local": False, - "name": "Flake", - "popularity": 59, - "preview_url": "https://p.scdn.co/mp3-preview/7094898f5aa76222b06349e4ec26489ca80b5e4f?cid=26913f34d26f4c16a15d5a93e309a1dc", - "track_number": 5, - "type": "track", - "uri": "spotify:track:54S3uCvfZauNw8lVCHZYYo", - }, - "currently_playing_type": "track", - "actions": { - "disallows": { - "resuming": True, - "toggling_repeat_context": True, - "toggling_repeat_track": True, - "toggling_shuffle": True, - } - }, - "is_playing": True, - } - - expected_playback_state = PlaybackState( - device_name=None, - device_id=None, - currently_playing_type="track", - is_playing=True, - progress_ms=261652, - message=None, - album_name="Brushfire Fairytales [Remastered (Bonus Version)]", - album_id="23BBbqDGMhloT6f2YBecSr", - album_artists=["Jack Johnson"], - album_spotify_url="https://open.spotify.com/album/23BBbqDGMhloT6f2YBecSr", - track_name="Flake", - track_id="54S3uCvfZauNw8lVCHZYYo", - track_spotify_url="https://open.spotify.com/track/54S3uCvfZauNw8lVCHZYYo", - track_artists=["Jack Johnson"], - track_artists_ids=["3GBPw9NK25X1Wt2OUvOwY3"], - ) - - playback_state = convert_to_playback_state(player_get_playback_state_response) - - assert playback_state == expected_playback_state diff --git a/toolkits/stripe/.pre-commit-config.yaml b/toolkits/stripe/.pre-commit-config.yaml deleted file mode 100644 index e367e3d9..00000000 --- a/toolkits/stripe/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/stripe/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/stripe/.ruff.toml b/toolkits/stripe/.ruff.toml deleted file mode 100644 index 9519fe6c..00000000 --- a/toolkits/stripe/.ruff.toml +++ /dev/null @@ -1,44 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/stripe/LICENSE b/toolkits/stripe/LICENSE deleted file mode 100644 index 8c2d4f37..00000000 --- a/toolkits/stripe/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/stripe/Makefile b/toolkits/stripe/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/stripe/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/stripe/_generate.py b/toolkits/stripe/_generate.py deleted file mode 100644 index 0a56ea5d..00000000 --- a/toolkits/stripe/_generate.py +++ /dev/null @@ -1,110 +0,0 @@ -import logging -from pathlib import Path -from typing import Union, get_args - -from stripe_agent_toolkit.functions import * # noqa: F403 -from stripe_agent_toolkit.prompts import * # noqa: F403 -from stripe_agent_toolkit.schema import * # noqa: F403 -from stripe_agent_toolkit.tools import tools - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_type_str(arg_type): - """Extract type name, handling Optional/Union types.""" - if hasattr(arg_type, "__origin__") and arg_type.__origin__ is Union: - non_none = [a for a in get_args(arg_type) if a is not type(None)] - if len(non_none) == 1: - return non_none[0].__name__ - return arg_type.__name__ if hasattr(arg_type, "__name__") else str(arg_type) - - -def generate_stripe_tools( - output_file: Path = Path("arcade_stripe") / "tools" / "stripe.py", -) -> None: - """ - Generate the Arcade AI Stripe Toolkit file from the stripe agent toolkit definitions. - """ - logger.info("Generating stripe tools file at %s", output_file) - try: - output_file.touch(exist_ok=True) - with output_file.open("w") as f: - f.write("""import os -from typing import Annotated, Optional - -from stripe_agent_toolkit.api import StripeAPI - -from arcade_tdk import ToolContext, tool - -def run_stripe_tool(context: ToolContext, method_name: str, params: dict) -> str: - \"\"\" - Helper function that retrieves the Stripe secret key, initializes the API, - and executes the specified method with the provided parameters. - \"\"\" - api_key = context.get_secret("STRIPE_SECRET_KEY") - stripe_api = StripeAPI(secret_key=api_key, context=None) - params = {k: v for k, v in params.items() if v is not None} - return stripe_api.run(method_name, **params) # type: ignore[no-any-return] - -""") - # Generate each tool function from the stripe agent toolkit - for tool_info in tools: - method_name = tool_info["method"] - method = globals().get(method_name) - if not method: - logger.warning("Method %s not found.", method_name) - continue - - args_schema = tool_info["args_schema"] - description = tool_info["description"].strip() - - arg_names = list(args_schema.__annotations__.keys()) - arg_types = [args_schema.__annotations__[field] for field in arg_names] - - params_list = [] - for name, arg_type in zip(arg_names, arg_types, strict=False): - field = args_schema.model_fields[name] - # Check if the type annotation already includes Optional (i.e. Union[..., None]) - is_optional_type = ( - hasattr(arg_type, "__origin__") - and arg_type.__origin__ is Union - and type(None) in get_args(arg_type) - ) - if field.is_required: - if is_optional_type: - params_list.append( - f"{name}: Annotated[{get_type_str(arg_type)} | None, " - f'"{field.description}"] = None' - ) - else: - params_list.append( - f"{name}: Annotated[{get_type_str(arg_type)}, " - f'"{field.description}"]' - ) - else: - default_repr = "None" if field.default is None else repr(field.default) - params_list.append( - f"{name}: Annotated[Optional[{get_type_str(arg_type)}], " - f'"{field.description}"] = {default_repr}' - ) - params_str = ", ".join(params_list) - dict_items = ", ".join([f'"{name}": {name}' for name in arg_names]) - arcade_tool_code = ( - f'@tool(requires_secrets=["STRIPE_SECRET_KEY"])\n' - f"def {method_name}(context: ToolContext, {params_str}) -> " - f'Annotated[str, "{description.splitlines()[0]}"]:\n' - f' """{description.splitlines()[0]}"""\n' - f' return run_stripe_tool(context, "{method_name}", ' - + "{" - + dict_items - + "})\n\n" - ) - f.write(arcade_tool_code) - except Exception: - logger.exception("An error occurred while generating stripe tools") - raise - - -if __name__ == "__main__": - generate_stripe_tools() diff --git a/toolkits/stripe/arcade_stripe/__init__.py b/toolkits/stripe/arcade_stripe/__init__.py deleted file mode 100644 index d9fc19a6..00000000 --- a/toolkits/stripe/arcade_stripe/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from arcade_stripe.tools.stripe import ( - create_billing_portal_session, - create_customer, - create_invoice, - create_invoice_item, - create_payment_link, - create_price, - create_product, - create_refund, - finalize_invoice, - list_customers, - list_invoices, - list_payment_intents, - list_prices, - list_products, - retrieve_balance, -) - -__all__ = [ - "create_billing_portal_session", - "create_customer", - "create_invoice", - "create_invoice_item", - "create_payment_link", - "create_price", - "create_product", - "create_refund", - "finalize_invoice", - "list_customers", - "list_invoices", - "list_payment_intents", - "list_prices", - "list_products", - "retrieve_balance", -] diff --git a/toolkits/stripe/arcade_stripe/evals/eval_stripe.py b/toolkits/stripe/arcade_stripe/evals/eval_stripe.py deleted file mode 100644 index 0418dcaa..00000000 --- a/toolkits/stripe/arcade_stripe/evals/eval_stripe.py +++ /dev/null @@ -1,325 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_stripe -from arcade_stripe.tools.stripe import ( - create_billing_portal_session, - create_customer, - create_invoice, - create_invoice_item, - create_payment_link, - create_price, - create_product, - create_refund, - finalize_invoice, - list_customers, - list_invoices, - list_payment_intents, - list_prices, - list_products, - retrieve_balance, -) - -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_stripe) - - -@tool_eval() -def stripe_eval_suite() -> EvalSuite: - """Evaluation suite for Stripe Tools.""" - suite = EvalSuite( - name="Stripe Tools Evaluation Suite", - system_message=( - "You are an AI assistant that helps users " - "interact with Stripe using the provided tools." - ), - catalog=catalog, - rubric=rubric, - ) - - suite.add_case( - name="Create a customer", - user_message=( - "add 'Alice Jenner' to my customers. she has a gmail that is just her first name" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_customer, - args={"name": "Alice Jenner", "email": "alice@gmail.com"}, - ) - ], - critics=[ - BinaryCritic(critic_field="name", weight=0.5), - BinaryCritic(critic_field="email", weight=0.5), - ], - ) - - suite.add_case( - name="List customers with limit", - user_message="get 5 customers", - expected_tool_calls=[ - ExpectedToolCall( - func=list_customers, - args={ - "limit": 5, - "email": None, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="limit", weight=1.0), - ], - ) - - suite.add_case( - name="Create a product", - user_message=( - "Create a product named 'Pro Subscription' that provides: " - "- Higher rate limits" - "- Priority support" - "- Early access to new features" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_product, - args={ - "name": "Pro Subscription", - "description": ( - "Provides higher rate limits, priority support, " - "and early access to new features." - ), - }, - ) - ], - critics=[ - BinaryCritic(critic_field="name", weight=0.6), - SimilarityCritic( - critic_field="description", - weight=0.4, - similarity_threshold=0.75, - ), - ], - ) - - suite.add_case( - name="List products", - user_message="List 10 of my products.", - expected_tool_calls=[ - ExpectedToolCall( - func=list_products, - args={ - "limit": 10, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="limit", weight=1.0), - ], - ) - - suite.add_case( - name="Create a price", - user_message="Create a price of $1298.99 for product 'prod_ABC123' in us currency.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_price, - args={ - "product": "prod_ABC123", - "unit_amount": 129899, - "currency": "usd", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="product", weight=0.4), - BinaryCritic(critic_field="unit_amount", weight=0.3), - SimilarityCritic( - critic_field="currency", - weight=0.3, - similarity_threshold=0.95, - ), - ], - ) - - suite.add_case( - name="Create a payment link", - user_message=( - "Joe needs a link to pay for my product. price is 'price_XYZ789'. create it please" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_payment_link, - args={ - "price": "price_XYZ789", - "quantity": 1, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="price", weight=0.5), - BinaryCritic(critic_field="quantity", weight=0.5), - ], - ) - - suite.add_case( - name="Retrieve balance", - user_message="How much money do i have", - expected_tool_calls=[ - ExpectedToolCall( - func=retrieve_balance, - args={}, - ) - ], - critics=[], - ) - - suite.add_case( - name="Create a refund", - user_message="Refund the payment intent 'pi_789XYZ' for 5 bucks.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_refund, - args={ - "payment_intent": "pi_789XYZ", - "amount": 500, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="payment_intent", weight=0.5), - BinaryCritic(critic_field="amount", weight=0.5), - ], - ) - - suite.add_case( - name="Create billing portal session", - user_message="Create a billing portal session for customer 'cus_test123' with return URL 'https://example.com/return'.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_billing_portal_session, - args={ - "customer": "cus_test123", - "return_url": "https://example.com/return", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="customer", weight=0.6), - BinaryCritic(critic_field="return_url", weight=0.4), - ], - ) - - suite.add_case( - name="List prices for a product", - user_message="what are the prices for my product 'prod_ABC123'", - expected_tool_calls=[ - ExpectedToolCall( - func=list_prices, - args={ - "product": "prod_ABC123", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="product", weight=1.0), - ], - ) - - suite.add_case( - name="List invoices for a customer", - user_message="get invoices for my customer 'cus_456def'", - expected_tool_calls=[ - ExpectedToolCall( - func=list_invoices, - args={ - "customer": "cus_456def", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="customer", weight=1.0), - ], - ) - - suite.add_case( - name="Create an invoice", - user_message="Create an invoice for my customer 'cus_456def' with 15 days until due.", - expected_tool_calls=[ - ExpectedToolCall( - func=create_invoice, - args={ - "customer": "cus_456def", - "days_until_due": 15, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="customer", weight=0.5), - BinaryCritic(critic_field="days_until_due", weight=0.5), - ], - ) - - suite.add_case( - name="Create an invoice item", - user_message=( - "Create an invoice item for my customer 'cus_456def' " - "for price 'price_789ghi' on invoice 'in_123test'." - ), - expected_tool_calls=[ - ExpectedToolCall( - func=create_invoice_item, - args={ - "customer": "cus_456def", - "price": "price_789ghi", - "invoice": "in_123test", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="customer", weight=0.33), - BinaryCritic(critic_field="price", weight=0.33), - BinaryCritic(critic_field="invoice", weight=0.34), - ], - ) - - suite.add_case( - name="Finalize an invoice", - user_message="Make 'in_123test' finalized.", - expected_tool_calls=[ - ExpectedToolCall( - func=finalize_invoice, - args={"invoice": "in_123test"}, - ) - ], - critics=[ - BinaryCritic(critic_field="invoice", weight=1.0), - ], - ) - - suite.add_case( - name="List payment intents for a customer", - user_message="get payment intents for my customer 'cus_456def'", - expected_tool_calls=[ - ExpectedToolCall( - func=list_payment_intents, - args={"customer": "cus_456def"}, - ) - ], - critics=[ - BinaryCritic(critic_field="customer", weight=1.0), - ], - ) - - return suite diff --git a/toolkits/stripe/arcade_stripe/tools/__init__.py b/toolkits/stripe/arcade_stripe/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/stripe/arcade_stripe/tools/stripe.py b/toolkits/stripe/arcade_stripe/tools/stripe.py deleted file mode 100644 index b6db05ef..00000000 --- a/toolkits/stripe/arcade_stripe/tools/stripe.py +++ /dev/null @@ -1,196 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from stripe_agent_toolkit.api import StripeAPI - - -def run_stripe_tool(context: ToolContext, method_name: str, params: dict) -> str: - """ - Helper function that retrieves the Stripe secret key, initializes the API, - and executes the specified method with the provided parameters. - """ - api_key = context.get_secret("STRIPE_SECRET_KEY") - stripe_api = StripeAPI(secret_key=api_key, context=None) - params = {k: v for k, v in params.items() if v is not None} - return stripe_api.run(method_name, **params) # type: ignore[no-any-return] - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_customer( - context: ToolContext, - name: Annotated[str, "The name of the customer."], - email: Annotated[str | None, "The email of the customer."] = None, -) -> Annotated[str, "This tool will create a customer in Stripe."]: - """This tool will create a customer in Stripe.""" - return run_stripe_tool(context, "create_customer", {"name": name, "email": email}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def list_customers( - context: ToolContext, - limit: Annotated[ - int | None, - "A limit on the number of objects to be returned. Limit can range between 1 and 100.", - ] = None, - email: Annotated[ - str | None, - "A case-sensitive filter on the list based on the customer's email field. " - "The value must be a string.", - ] = None, -) -> Annotated[str, "This tool will fetch a list of Customers from Stripe."]: - """This tool will fetch a list of Customers from Stripe.""" - return run_stripe_tool(context, "list_customers", {"limit": limit, "email": email}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_product( - context: ToolContext, - name: Annotated[str, "The name of the product."], - description: Annotated[str | None, "The description of the product."] = None, -) -> Annotated[str, "This tool will create a product in Stripe."]: - """This tool will create a product in Stripe.""" - return run_stripe_tool(context, "create_product", {"name": name, "description": description}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def list_products( - context: ToolContext, - limit: Annotated[ - int | None, - "A limit on the number of objects to be returned. Limit can range between 1 and 100, " - "and the default is 10.", - ] = None, -) -> Annotated[str, "This tool will fetch a list of Products from Stripe."]: - """This tool will fetch a list of Products from Stripe.""" - return run_stripe_tool(context, "list_products", {"limit": limit}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_price( - context: ToolContext, - product: Annotated[str, "The ID of the product to create the price for."], - unit_amount: Annotated[int, "The unit amount of the price in cents."], - currency: Annotated[str, "The currency of the price."], -) -> Annotated[str, "This tool will create a price in Stripe. If a product has not already been"]: - """This tool will create a price in Stripe. If a product has not already been""" - return run_stripe_tool( - context, - "create_price", - {"product": product, "unit_amount": unit_amount, "currency": currency}, - ) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def list_prices( - context: ToolContext, - product: Annotated[str | None, "The ID of the product to list prices for."] = None, - limit: Annotated[ - int | None, - "A limit on the number of objects to be returned. Limit can range between 1 and 100, " - "and the default is 10.", - ] = None, -) -> Annotated[str, "This tool will fetch a list of Prices from Stripe."]: - """This tool will fetch a list of Prices from Stripe.""" - return run_stripe_tool(context, "list_prices", {"product": product, "limit": limit}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_payment_link( - context: ToolContext, - price: Annotated[str, "The ID of the price to create the payment link for."], - quantity: Annotated[int, "The quantity of the product to include."], -) -> Annotated[str, "This tool will create a payment link in Stripe."]: - """This tool will create a payment link in Stripe.""" - return run_stripe_tool(context, "create_payment_link", {"price": price, "quantity": quantity}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def list_invoices( - context: ToolContext, - customer: Annotated[str | None, "The ID of the customer to list invoices for."] = None, - limit: Annotated[ - int | None, - "A limit on the number of objects to be returned. Limit can range between 1 and 100, " - "and the default is 10.", - ] = None, -) -> Annotated[str, "This tool will list invoices in Stripe."]: - """This tool will list invoices in Stripe.""" - return run_stripe_tool(context, "list_invoices", {"customer": customer, "limit": limit}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_invoice( - context: ToolContext, - customer: Annotated[str, "The ID of the customer to create the invoice for."], - days_until_due: Annotated[int | None, "The number of days until the invoice is due."] = None, -) -> Annotated[str, "This tool will create an invoice in Stripe."]: - """This tool will create an invoice in Stripe.""" - return run_stripe_tool( - context, "create_invoice", {"customer": customer, "days_until_due": days_until_due} - ) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_invoice_item( - context: ToolContext, - customer: Annotated[str, "The ID of the customer to create the invoice item for."], - price: Annotated[str, "The ID of the price for the item."], - invoice: Annotated[str, "The ID of the invoice to create the item for."], -) -> Annotated[str, "This tool will create an invoice item in Stripe."]: - """This tool will create an invoice item in Stripe.""" - return run_stripe_tool( - context, "create_invoice_item", {"customer": customer, "price": price, "invoice": invoice} - ) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def finalize_invoice( - context: ToolContext, invoice: Annotated[str, "The ID of the invoice to finalize."] -) -> Annotated[str, "This tool will finalize an invoice in Stripe."]: - """This tool will finalize an invoice in Stripe.""" - return run_stripe_tool(context, "finalize_invoice", {"invoice": invoice}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def retrieve_balance( - context: ToolContext, -) -> Annotated[str, "This tool will retrieve the balance from Stripe. It takes no input."]: - """This tool will retrieve the balance from Stripe. It takes no input.""" - return run_stripe_tool(context, "retrieve_balance", {}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_refund( - context: ToolContext, - payment_intent: Annotated[str, "The ID of the PaymentIntent to refund."], - amount: Annotated[int | None, "The amount to refund in cents."] = None, -) -> Annotated[str, "This tool will refund a payment intent in Stripe."]: - """This tool will refund a payment intent in Stripe.""" - return run_stripe_tool( - context, "create_refund", {"payment_intent": payment_intent, "amount": amount} - ) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def list_payment_intents( - context: ToolContext, - customer: Annotated[str | None, "The ID of the customer to list payment intents for."] = None, - limit: Annotated[ - int | None, - "A limit on the number of objects to be returned. Limit can range between 1 and 100.", - ] = None, -) -> Annotated[str, "This tool will list payment intents in Stripe."]: - """This tool will list payment intents in Stripe.""" - return run_stripe_tool(context, "list_payment_intents", {"customer": customer, "limit": limit}) - - -@tool(requires_secrets=["STRIPE_SECRET_KEY"]) -def create_billing_portal_session( - context: ToolContext, - customer: Annotated[str, "The ID of the customer to create the billing portal session for."], - return_url: Annotated[str | None, "The default URL to return to afterwards."] = None, -) -> Annotated[str, "This tool will create a billing portal session."]: - """This tool will create a billing portal session.""" - return run_stripe_tool( - context, "create_billing_portal_session", {"customer": customer, "return_url": return_url} - ) diff --git a/toolkits/stripe/pyproject.toml b/toolkits/stripe/pyproject.toml deleted file mode 100644 index 9177e27d..00000000 --- a/toolkits/stripe/pyproject.toml +++ /dev/null @@ -1,53 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_stripe" -version = "0.0.4" -description = "Arcade.dev LLM tools for Stripe" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "stripe-agent-toolkit>=0.6.1,<1.0.0", "stripe>=11.0.0,<12.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_stripe/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_stripe",] diff --git a/toolkits/stripe/tests/__init__.py b/toolkits/stripe/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/stripe/tests/test_stripe.py b/toolkits/stripe/tests/test_stripe.py deleted file mode 100644 index 1eff476f..00000000 --- a/toolkits/stripe/tests/test_stripe.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest - -from arcade_stripe.tools.stripe import ( - create_billing_portal_session, - create_customer, - create_invoice, - create_invoice_item, - create_payment_link, - create_price, - create_product, - create_refund, - finalize_invoice, - list_customers, - list_invoices, - list_payment_intents, - list_prices, - list_products, - retrieve_balance, -) - - -class DummyContext: - def get_secret(self, key: str): - return "test_secret_key" - - -class DummyStripeAPI: - def __init__(self, secret_key, context): - self.secret_key = secret_key - - def run(self, method_name, **params): - return {"method": method_name, "params": params} - - -@pytest.mark.parametrize( - ("current_tool", "params"), - [ - (create_customer, {"name": "John Doe"}), - (create_customer, {"name": "John Doe", "email": "john.doe@example.com"}), - (list_customers, {}), - (list_customers, {"limit": 10}), - (list_customers, {"email": "john.doe@example.com"}), - (list_customers, {"limit": 10, "email": "john.doe@example.com"}), - (create_product, {"name": "Product 1"}), - (create_product, {"name": "Product 1", "description": "Description 1"}), - (list_products, {}), - (list_products, {"limit": 10}), - (create_price, {"product": "product_123", "unit_amount": 1000, "currency": "usd"}), - (list_prices, {}), - (list_prices, {"product": "product_123"}), - (list_prices, {"limit": 10}), - (list_prices, {"product": "product_123", "limit": 10}), - (create_payment_link, {"price": "price_123", "quantity": 100}), - (list_invoices, {}), - (list_invoices, {"customer": "customer_123"}), - (list_invoices, {"limit": 10}), - (list_invoices, {"customer": "customer_123", "limit": 10}), - (create_invoice, {"customer": "customer_123"}), - (create_invoice, {"customer": "customer_123", "days_until_due": 30}), - ( - create_invoice_item, - {"customer": "customer_123", "price": "price_123", "invoice": "invoice_123"}, - ), - (finalize_invoice, {"invoice": "invoice_123"}), - (retrieve_balance, {}), - (create_refund, {"payment_intent": "payment_intent_123"}), - (create_refund, {"payment_intent": "payment_intent_123", "amount": 100}), - (list_payment_intents, {}), - (list_payment_intents, {"customer": "customer_123"}), - (list_payment_intents, {"limit": 10}), - (list_payment_intents, {"customer": "customer_123", "limit": 10}), - (create_billing_portal_session, {"customer": "customer_123"}), - ( - create_billing_portal_session, - {"customer": "customer_123", "return_url": "https://example.com"}, - ), - ], -) -def test_stripe_tools(monkeypatch, current_tool, params): - monkeypatch.setattr("arcade_stripe.tools.stripe.StripeAPI", DummyStripeAPI) - - context = DummyContext() - - result = current_tool(context, **params) - expected = {"method": current_tool.__name__, "params": params} - - assert result == expected diff --git a/toolkits/walmart/.pre-commit-config.yaml b/toolkits/walmart/.pre-commit-config.yaml deleted file mode 100644 index 8508a588..00000000 --- a/toolkits/walmart/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/walmart/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/walmart/.ruff.toml b/toolkits/walmart/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/walmart/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/walmart/LICENSE b/toolkits/walmart/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/walmart/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/walmart/Makefile b/toolkits/walmart/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/walmart/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/walmart/arcade_walmart/__init__.py b/toolkits/walmart/arcade_walmart/__init__.py deleted file mode 100644 index 4049a592..00000000 --- a/toolkits/walmart/arcade_walmart/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_walmart.tools import search_products - -__all__ = ["search_products"] diff --git a/toolkits/walmart/arcade_walmart/enums.py b/toolkits/walmart/arcade_walmart/enums.py deleted file mode 100644 index c1d2b600..00000000 --- a/toolkits/walmart/arcade_walmart/enums.py +++ /dev/null @@ -1,21 +0,0 @@ -from enum import Enum - - -class WalmartSortBy(Enum): - RELEVANCE = "relevance_according_to_keywords_searched" - PRICE_LOW_TO_HIGH = "lowest_price_first" - PRICE_HIGH_TO_LOW = "highest_price_first" - BEST_SELLING = "best_selling_products_first" - RATING_HIGH = "highest_rating_first" - NEW_ARRIVALS = "new_arrivals_first" - - def to_api_value(self: "WalmartSortBy") -> str | None: - _map = { - str(self.RELEVANCE): None, - str(self.PRICE_LOW_TO_HIGH): "price_low", - str(self.PRICE_HIGH_TO_LOW): "price_high", - str(self.BEST_SELLING): "best_seller", - str(self.RATING_HIGH): "rating_high", - str(self.NEW_ARRIVALS): "new", - } - return _map[str(self)] diff --git a/toolkits/walmart/arcade_walmart/tools/__init__.py b/toolkits/walmart/arcade_walmart/tools/__init__.py deleted file mode 100644 index 1eeefa91..00000000 --- a/toolkits/walmart/arcade_walmart/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_walmart.tools.walmart import search_products - -__all__ = ["search_products"] diff --git a/toolkits/walmart/arcade_walmart/tools/walmart.py b/toolkits/walmart/arcade_walmart/tools/walmart.py deleted file mode 100644 index 59e4ad0f..00000000 --- a/toolkits/walmart/arcade_walmart/tools/walmart.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from arcade_tdk.tool import tool - -from arcade_walmart.enums import WalmartSortBy -from arcade_walmart.utils import ( - call_serpapi, - extract_walmart_product_details, - extract_walmart_results, - get_walmart_last_page_integer, - prepare_params, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_products( - context: ToolContext, - keywords: Annotated[str, "Keywords to search for. E.g. 'apple iphone' or 'samsung galaxy'"], - sort_by: Annotated[ - WalmartSortBy, - "Sort the results by the specified criteria. " - f"Defaults to '{WalmartSortBy.RELEVANCE.value}'.", - ] = WalmartSortBy.RELEVANCE, - min_price: Annotated[ - float | None, - "Minimum price to filter the results by. E.g. 100.00", - ] = None, - max_price: Annotated[ - float | None, - "Maximum price to filter the results by. E.g. 100.00", - ] = None, - next_day_delivery: Annotated[ - bool, - "Filters products that are eligible for next day delivery. " - "Defaults to False (returns all products, regardless of delivery status).", - ] = False, - page: Annotated[ - int, - "Page number to fetch. Defaults to 1 (first page of results). " - "The maximum page value is 100.", - ] = 1, -) -> Annotated[dict[str, Any], "List of Walmart products matching the search query."]: - """Search Walmart products using SerpAPI.""" - if page > 100: - raise ToolExecutionError(f"The maximum page value for Walmart search is 100, got {page}.") - - sort_by_value = sort_by.to_api_value() - - params = prepare_params( - "walmart", - query=keywords, - sort=sort_by_value, - # When the user selects a sorting option, we have to disable the relevance sorting - # using the soft_sort parameter. - soft_sort=not sort_by_value, - min_price=min_price, - max_price=max_price, - nd_en=next_day_delivery, - page=page, - include_filters=False, - ) - - response = call_serpapi(context, params) - - return { - "products": extract_walmart_results(response.get("organic_results", [])), - "current_page": page, - "last_available_page": get_walmart_last_page_integer(response), - } - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_product_details( - context: ToolContext, - item_id: Annotated[ - str, - "Item ID. E.g. '414600577'. This can be retrieved from the search results of the " - f"{search_products.__tool_name__} tool.", - ], -) -> Annotated[dict[str, Any], "Product details"]: - """Get product details from Walmart.""" - params = prepare_params("walmart_product", product_id=item_id) - response = call_serpapi(context, params) - - product_result = response.get("product_result") - - if not product_result: - return { - "product_details": None, - "message": f"No product details found for item ID '{item_id}'.", - } - - return {"product_details": extract_walmart_product_details(product_result)} diff --git a/toolkits/walmart/arcade_walmart/utils.py b/toolkits/walmart/arcade_walmart/utils.py deleted file mode 100644 index 252e80fc..00000000 --- a/toolkits/walmart/arcade_walmart/utils.py +++ /dev/null @@ -1,120 +0,0 @@ -import re -from typing import Any, cast - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -def extract_walmart_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - { - "item_id": result.get("us_item_id"), - "title": result.get("title"), - "description": result.get("description"), - "rating": result.get("rating"), - "reviews_count": result.get("reviews"), - "seller": { - "id": result.get("seller_id"), - "name": result.get("seller_name"), - }, - "price": { - "value": result.get("primary_offer", {}).get("offer_price"), - "currency": result.get("primary_offer", {}).get("offer_currency"), - }, - "link": result.get("product_page_url"), - } - for result in results - ] - - -def get_walmart_last_page_integer(results: dict[str, Any]) -> int: - try: - return int(list(results["pagination"]["other_pages"].keys())[-1]) - except (KeyError, IndexError, ValueError): - return 1 - - -def extract_walmart_product_details(product: dict[str, Any]) -> dict[str, Any]: - return { - "item_id": product.get("us_item_id"), - "product_type": product.get("product_type"), - "title": product.get("title"), - "description_html": product.get("short_description_html"), - "rating": product.get("rating"), - "reviews_count": product.get("reviews"), - "seller": { - "id": product.get("seller_id"), - "name": product.get("seller_name"), - }, - "manufacturer_name": product.get("manufacturer"), - "price": { - "value": product.get("price_map", {}).get("price"), - "currency": product.get("price_map", {}).get("currency"), - "previous_price": product.get("price_map", {}).get("was_price", {}).get("price"), - }, - "link": product.get("product_page_url"), - "variant_options": extract_walmart_variant_options(product.get("variant_swatches", [])), - } - - -def extract_walmart_variant_options(variant_swatches: list[dict[str, Any]]) -> list[dict[str, Any]]: - variants = [] - - for variant_swatch in variant_swatches: - variant_name = variant_swatch.get("name") - if not variant_name: - continue - - options = [] - - for selection in variant_swatch.get("available_selections", []): - selection_name = selection.get("name") - if selection_name and selection_name not in options: - options.append(selection_name) - - variants.append({variant_name: options}) - - return variants diff --git a/toolkits/walmart/pyproject.toml b/toolkits/walmart/pyproject.toml deleted file mode 100644 index b391d9ea..00000000 --- a/toolkits/walmart/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_walmart" -version = "2.0.0" -description = "Arcade.dev LLM tools for searching for products sold by Walmart" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "serpapi>=0.1.5,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_walmart/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_walmart",] diff --git a/toolkits/web/.pre-commit-config.yaml b/toolkits/web/.pre-commit-config.yaml deleted file mode 100644 index d04ca99c..00000000 --- a/toolkits/web/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/web/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/web/.ruff.toml b/toolkits/web/.ruff.toml deleted file mode 100644 index 19364180..00000000 --- a/toolkits/web/.ruff.toml +++ /dev/null @@ -1,47 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/web/LICENSE b/toolkits/web/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/web/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/web/Makefile b/toolkits/web/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/web/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/web/arcade_web/__init__.py b/toolkits/web/arcade_web/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/web/arcade_web/tools/__init__.py b/toolkits/web/arcade_web/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/web/arcade_web/tools/firecrawl.py b/toolkits/web/arcade_web/tools/firecrawl.py deleted file mode 100644 index fb6aad41..00000000 --- a/toolkits/web/arcade_web/tools/firecrawl.py +++ /dev/null @@ -1,188 +0,0 @@ -from typing import Annotated, Any - -from arcade_tdk import ToolContext, tool -from firecrawl import FirecrawlApp - -from arcade_web.tools.models import Formats - - -# TODO: Support actions. This would enable clicking, scrolling, screenshotting, etc. -# TODO: Support extract. -# TODO: Support headers param? -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def scrape_url( - context: ToolContext, - url: Annotated[str, "URL to scrape"], - formats: Annotated[ - list[Formats] | None, "Formats to retrieve. Defaults to ['markdown']." - ] = None, - only_main_content: Annotated[ - bool | None, - "Only return the main content of the page excluding headers, navs, footers, etc.", - ] = True, - include_tags: Annotated[list[str] | None, "List of tags to include in the output"] = None, - exclude_tags: Annotated[list[str] | None, "List of tags to exclude from the output"] = None, - wait_for: Annotated[ - int | None, - "Specify a delay in milliseconds before fetching the content, allowing the page " - "sufficient time to load.", - ] = 10, - timeout: Annotated[int | None, "Timeout in milliseconds for the request"] = 30000, -) -> Annotated[dict[str, Any], "Scraped data in specified formats"]: - """Scrape a URL using Firecrawl and return the data in specified formats.""" - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - formats = formats or [Formats.MARKDOWN] - - app = FirecrawlApp(api_key=api_key) - params = { - "formats": formats, - "onlyMainContent": only_main_content, - "includeTags": include_tags or [], - "excludeTags": exclude_tags or [], - "waitFor": wait_for, - "timeout": timeout, - } - response = app.scrape_url(url, params=params) - - return dict(response) - - -# TODO: Support scrapeOptions. -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def crawl_website( - context: ToolContext, - url: Annotated[str, "URL to crawl"], - exclude_paths: Annotated[list[str] | None, "URL patterns to exclude from the crawl"] = None, - include_paths: Annotated[list[str] | None, "URL patterns to include in the crawl"] = None, - max_depth: Annotated[int, "Maximum depth to crawl relative to the entered URL"] = 2, - ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True, - limit: Annotated[int, "Limit the number of pages to crawl"] = 10, - allow_backward_links: Annotated[ - bool, - "Enable navigation to previously linked pages and enable crawling " - "sublinks that are not children of the 'url' input parameter.", - ] = False, - allow_external_links: Annotated[bool, "Allow following links to external websites"] = False, - webhook: Annotated[ - str | None, - "The URL to send a POST request to when the crawl is started, updated and completed.", - ] = None, - async_crawl: Annotated[bool, "Run the crawl asynchronously"] = True, -) -> Annotated[dict[str, Any], "Crawl status and data"]: - """ - Crawl a website using Firecrawl. If the crawl is asynchronous, then returns the crawl ID. - If the crawl is synchronous, then returns the crawl data. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - params = { - "limit": limit, - "excludePaths": exclude_paths or [], - "includePaths": include_paths or [], - "maxDepth": max_depth, - "ignoreSitemap": ignore_sitemap, - "allowBackwardLinks": allow_backward_links, - "allowExternalLinks": allow_external_links, - } - if webhook: - params["webhook"] = webhook - - if async_crawl: - response = app.async_crawl_url(url, params=params) - if ( - "url" in response - ): # Url isn't clickable, so removing it since only the ID is needed to check status - del response["url"] - else: - response = app.crawl_url(url, params=params) - - return dict(response) - - -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def get_crawl_status( - context: ToolContext, - crawl_id: Annotated[str, "The ID of the crawl job"], -) -> Annotated[dict[str, Any], "Crawl status information"]: - """ - Get the status of a Firecrawl 'crawl' that is either in progress or recently completed. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - crawl_status = app.check_crawl_status(crawl_id) - - if "data" in crawl_status: - del crawl_status["data"] - - return dict(crawl_status) - - -# TODO: Support responses greater than 10 MB. If the response is greater than 10 MB, -# then the Firecrawl API response will have a next_url field. -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def get_crawl_data( - context: ToolContext, - crawl_id: Annotated[str, "The ID of the crawl job"], -) -> Annotated[dict[str, Any], "Crawl data information"]: - """ - Get the data of a Firecrawl 'crawl' that is either in progress or recently completed. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - crawl_data = app.check_crawl_status(crawl_id) - - return dict(crawl_data) - - -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def cancel_crawl( - context: ToolContext, - crawl_id: Annotated[str, "The ID of the asynchronous crawl job to cancel"], -) -> Annotated[dict[str, Any], "Cancellation status information"]: - """ - Cancel an asynchronous crawl job that is in progress using the Firecrawl API. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - cancellation_status = app.cancel_crawl(crawl_id) - - return dict(cancellation_status) - - -@tool(requires_secrets=["FIRECRAWL_API_KEY"]) -async def map_website( - context: ToolContext, - url: Annotated[str, "The base URL to start crawling from"], - search: Annotated[str | None, "Search query to use for mapping"] = None, - ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True, - include_subdomains: Annotated[bool, "Include subdomains of the website"] = False, - limit: Annotated[int, "Maximum number of links to return"] = 5000, -) -> Annotated[dict[str, Any], "Website map data"]: - """ - Map a website from a single URL to a map of the entire website. - """ - - api_key = context.get_secret("FIRECRAWL_API_KEY") - - app = FirecrawlApp(api_key=api_key) - params: dict[str, Any] = { - "ignoreSitemap": ignore_sitemap, - "includeSubdomains": include_subdomains, - "limit": limit, - } - if search: - params["search"] = search - - map_result = app.map_url(url, params=params) - - return dict(map_result) diff --git a/toolkits/web/arcade_web/tools/models.py b/toolkits/web/arcade_web/tools/models.py deleted file mode 100644 index 2e823940..00000000 --- a/toolkits/web/arcade_web/tools/models.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -# Models and enums for firecrawl web tools -class Formats(str, Enum): - MARKDOWN = "markdown" - HTML = "html" - RAW_HTML = "rawHtml" - LINKS = "links" - SCREENSHOT = "screenshot" - SCREENSHOT_AT_FULL_PAGE = "screenshot@fullPage" diff --git a/toolkits/web/evals/eval_firecrawl.py b/toolkits/web/evals/eval_firecrawl.py deleted file mode 100644 index 0cdeef8e..00000000 --- a/toolkits/web/evals/eval_firecrawl.py +++ /dev/null @@ -1,244 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NumericCritic, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_web -from arcade_web.tools.firecrawl import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, - map_website, - scrape_url, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.9, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -# Register the Firecrawl tools -catalog.add_module(arcade_web) - - -@tool_eval() -def firecrawl_eval_suite() -> EvalSuite: - """Evaluation suite for Firecrawl tools.""" - suite = EvalSuite( - name="Firecrawl Tools Evaluation Suite", - system_message="You are an AI assistant that helps users interact with web scraping and crawling tools using the provided tools.", - catalog=catalog, - rubric=rubric, - ) - - # Scrape URL - suite.add_case( - name="Scrape a URL", - user_message="Scrape https://foobar.com/malicious/malware/that/will/harm/you in markdown format please. Wait for 10 seconds before fetching the content.", - expected_tool_calls=[ - ExpectedToolCall( - func=scrape_url, - args={ - "url": "https://foobar.com/malicious/malware/that/will/harm/you", - "formats": ["markdown"], - "wait_for": 10000, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="url", weight=0.4), - BinaryCritic(critic_field="formats", weight=0.4), - NumericCritic(critic_field="wait_for", weight=0.2, value_range=(9000, 11000)), - ], - ) - - # Crawl Website - suite.add_case( - name="Crawl a website", - user_message="Crawl the website at https://wikipedia.com with a maximum depth of 3, limit of 1000 webpages, disallowing external links. Updates should be sent to http://example.com/crawl-updates. Oh and do it in the background. THanks", - expected_tool_calls=[ - ExpectedToolCall( - func=crawl_website, - args={ - "url": "https://wikipedia.com", - "max_depth": 3, - "limit": 1000, - "allow_external_links": False, - "webhook": "http://example.com/crawl-updates", - "async_crawl": True, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="url", weight=0.2), - BinaryCritic(critic_field="max_depth", weight=0.1), - BinaryCritic(critic_field="limit", weight=0.1), - BinaryCritic(critic_field="allow_external_links", weight=0.1), - BinaryCritic(critic_field="webhook", weight=0.2), - BinaryCritic(critic_field="async_crawl", weight=0.2), - ], - ) - - # Get Crawl Status - suite.add_case( - name="Get crawl status", - user_message="Check the status of my crawl", - expected_tool_calls=[ - ExpectedToolCall( - func=get_crawl_status, - args={ - "crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="crawl_id", weight=1.0), - ], - additional_messages=[ - {"role": "user", "content": "crawl asynchronously https://www.google.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "type": "function", - "function": { - "name": "Web_CrawlWebsite", - "arguments": '{"url":"https://www.google.com","async_crawl":true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}', - "tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "name": "Web_CrawlWebsite", - }, - { - "role": "assistant", - "content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).", - }, - ], - ) - - # # Get Crawl Data - suite.add_case( - name="Get crawl status", - user_message="Ok looks like the crawl is done, can I get the result please?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_crawl_data, - args={ - "crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="crawl_id", weight=1.0), - ], - additional_messages=[ - {"role": "user", "content": "crawl asynchronously https://www.google.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "type": "function", - "function": { - "name": "Web_CrawlWebsite", - "arguments": '{"url":"https://www.google.com","async_crawl":true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}', - "tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "name": "Web_CrawlWebsite", - }, - { - "role": "assistant", - "content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).", - }, - ], - ) - - # Cancel Crawl - suite.add_case( - name="Get crawl status", - user_message="Actually cancel it.", - expected_tool_calls=[ - ExpectedToolCall( - func=cancel_crawl, - args={ - "crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b", - }, - ) - ], - critics=[ - BinaryCritic(critic_field="crawl_id", weight=1.0), - ], - additional_messages=[ - {"role": "user", "content": "crawl asynchronously https://www.google.com"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "type": "function", - "function": { - "name": "Web_CrawlWebsite", - "arguments": '{"url":"https://www.google.com","async_crawl":true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}', - "tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN", - "name": "Web_CrawlWebsite", - }, - { - "role": "assistant", - "content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).", - }, - ], - ) - - # Map Website - suite.add_case( - name="Map a website", - user_message="Map the website at https://wikipedia.com with a limit of 100000 links. Only the links that are about the topic of AI", - expected_tool_calls=[ - ExpectedToolCall( - func=map_website, - args={ - "url": "https://wikipedia.com", - "search": "AI", - "limit": 100000, - }, - ) - ], - critics=[ - BinaryCritic(critic_field="url", weight=0.4), - SimilarityCritic(critic_field="search", weight=0.2), - NumericCritic(critic_field="limit", weight=0.4, value_range=(90000, 110000)), - ], - ) - - return suite diff --git a/toolkits/web/pyproject.toml b/toolkits/web/pyproject.toml deleted file mode 100644 index deda8b0c..00000000 --- a/toolkits/web/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_web" -version = "1.0.4" -description = "Arcade.dev LLM tools for web scraping related tasks" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "firecrawl-py>=1.3.1,<2.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_web/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_web",] diff --git a/toolkits/web/tests/__init__.py b/toolkits/web/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/web/tests/test_firecrawl.py b/toolkits/web/tests/test_firecrawl.py deleted file mode 100644 index 2f447774..00000000 --- a/toolkits/web/tests/test_firecrawl.py +++ /dev/null @@ -1,96 +0,0 @@ -from unittest.mock import patch - -import pytest -from arcade_tdk import ToolContext, ToolSecretItem -from arcade_tdk.errors import ToolExecutionError - -from arcade_web.tools.firecrawl import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, - map_website, - scrape_url, -) - - -@pytest.fixture -def mock_context(): - return ToolContext(secrets=[ToolSecretItem(key="firecrawl_api_key", value="fake_api_key")]) - - -@pytest.fixture -def mock_firecrawl_app(): - with patch("arcade_web.tools.firecrawl.FirecrawlApp") as app: - yield app.return_value - - -@pytest.mark.asyncio -async def test_scrape_url_success(mock_firecrawl_app, mock_context): - mock_firecrawl_app.scrape_url.return_value = {"data": "scraped content"} - - result = await scrape_url(mock_context, "http://example.com") - assert result == {"data": "scraped content"} - - -@pytest.mark.asyncio -async def test_crawl_website_success(mock_firecrawl_app, mock_context): - mock_firecrawl_app.async_crawl_url.return_value = {"crawl_id": "12345"} - - result = await crawl_website(mock_context, "http://example.com") - assert result == {"crawl_id": "12345"} - - -@pytest.mark.asyncio -async def test_get_crawl_status_success(mock_firecrawl_app, mock_context): - mock_firecrawl_app.check_crawl_status.return_value = {"status": "completed"} - - result = await get_crawl_status(mock_context, "12345") - assert result == {"status": "completed"} - - -@pytest.mark.asyncio -async def test_get_crawl_data_success(mock_firecrawl_app, mock_context): - mock_firecrawl_app.check_crawl_status.return_value = {"data": "crawl data"} - - result = await get_crawl_data(mock_context, "12345") - assert result == {"data": "crawl data"} - - -@pytest.mark.asyncio -async def test_cancel_crawl_success(mock_firecrawl_app, mock_context): - mock_firecrawl_app.cancel_crawl.return_value = {"status": "cancelled"} - - result = await cancel_crawl(mock_context, "12345") - assert result == {"status": "cancelled"} - - -@pytest.mark.asyncio -async def test_map_website_success(mock_firecrawl_app, mock_context): - mock_firecrawl_app.map_url.return_value = {"map": "website map"} - - result = await map_website(mock_context, "http://example.com") - assert result == {"map": "website map"} - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "method,params,error_message", - [ - (scrape_url, ("http://example.com",), "Error scraping URL"), - (crawl_website, ("http://example.com",), "Error crawling website"), - (get_crawl_status, ("12345",), "Error getting crawl status"), - (get_crawl_data, ("12345",), "Error getting crawl data"), - (cancel_crawl, ("12345",), "Error cancelling crawl"), - (map_website, ("http://example.com",), "Error mapping website"), - ], -) -async def test_firecrawl_error(mock_firecrawl_app, mock_context, method, params, error_message): - mock_firecrawl_app.scrape_url.side_effect = Exception(error_message) - mock_firecrawl_app.async_crawl_url.side_effect = Exception(error_message) - mock_firecrawl_app.check_crawl_status.side_effect = Exception(error_message) - mock_firecrawl_app.cancel_crawl.side_effect = Exception(error_message) - mock_firecrawl_app.map_url.side_effect = Exception(error_message) - - with pytest.raises(ToolExecutionError): - await method(mock_context, *params) diff --git a/toolkits/x/.pre-commit-config.yaml b/toolkits/x/.pre-commit-config.yaml deleted file mode 100644 index 10d4e7ce..00000000 --- a/toolkits/x/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/x/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/x/.ruff.toml b/toolkits/x/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/x/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/x/LICENSE b/toolkits/x/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/x/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/x/Makefile b/toolkits/x/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/x/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/x/arcade_x/__init__.py b/toolkits/x/arcade_x/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/x/arcade_x/tools/__init__.py b/toolkits/x/arcade_x/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/x/arcade_x/tools/constants.py b/toolkits/x/arcade_x/tools/constants.py deleted file mode 100644 index 12004059..00000000 --- a/toolkits/x/arcade_x/tools/constants.py +++ /dev/null @@ -1 +0,0 @@ -TWEETS_URL = "https://api.x.com/2/tweets" diff --git a/toolkits/x/arcade_x/tools/tweets.py b/toolkits/x/arcade_x/tools/tweets.py deleted file mode 100644 index 6fae9041..00000000 --- a/toolkits/x/arcade_x/tools/tweets.py +++ /dev/null @@ -1,215 +0,0 @@ -from typing import Annotated, Any - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import X -from arcade_tdk.errors import RetryableToolError - -from arcade_x.tools.constants import TWEETS_URL -from arcade_x.tools.utils import ( - expand_attached_media, - expand_long_tweet, - expand_urls_in_tweets, - get_headers_with_token, - get_tweet_url, - parse_search_recent_tweets_response, - remove_none_values, -) - -# Manage Tweets Tools. See developer docs for additional available parameters: -# https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference - - -@tool( - requires_auth=X( - scopes=["tweet.read", "tweet.write", "users.read"], - ) -) -async def post_tweet( - context: ToolContext, - tweet_text: Annotated[str, "The text content of the tweet you want to post"], -) -> Annotated[str, "Success string and the URL of the tweet"]: - """Post a tweet to X (Twitter).""" - - headers = get_headers_with_token(context) - payload = {"text": tweet_text} - - async with httpx.AsyncClient() as client: - response = await client.post(TWEETS_URL, headers=headers, json=payload, timeout=10) - response.raise_for_status() - - tweet_id = response.json()["data"]["id"] - return f"Tweet with id {tweet_id} posted successfully. URL: {get_tweet_url(tweet_id)}" - - -@tool(requires_auth=X(scopes=["tweet.read", "tweet.write", "users.read"])) -async def delete_tweet_by_id( - context: ToolContext, - tweet_id: Annotated[str, "The ID of the tweet you want to delete"], -) -> Annotated[str, "Success string confirming the tweet deletion"]: - """Delete a tweet on X (Twitter).""" - - headers = get_headers_with_token(context) - url = f"{TWEETS_URL}/{tweet_id}" - - async with httpx.AsyncClient() as client: - response = await client.delete(url, headers=headers, timeout=10) - response.raise_for_status() - - return f"Tweet with id {tweet_id} deleted successfully." - - -@tool(requires_auth=X(scopes=["tweet.read", "users.read"])) -async def search_recent_tweets_by_username( - context: ToolContext, - username: Annotated[str, "The username of the X (Twitter) user to look up"], - max_results: Annotated[ - int, "The maximum number of results to return. Must be in range [1, 100] inclusive" - ] = 10, - next_token: Annotated[ - str | None, "The pagination token starting from which to return results" - ] = None, -) -> Annotated[dict[str, Any], "Dictionary containing the search results"]: - """Search for recent tweets (last 7 days) on X (Twitter) by username. - Includes replies and reposts.""" - - headers = get_headers_with_token(context) - params: dict[str, Any] = { - "query": f"from:{username}", - "max_results": min( - max(max_results, 10), 100 - ), # X API does not allow 'max_results' less than 10 or greater than 100 - "next_token": next_token, - "expansions": "author_id", - "user.fields": "id,name,username,entities", - "tweet.fields": "entities,note_tweet", - } - params = expand_attached_media(remove_none_values(params)) - - url = f"{TWEETS_URL}/search/recent" - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params, timeout=10) - response.raise_for_status() - - response_data: dict[str, Any] = response.json() - - for tweet in response_data.get("data", []): - expand_long_tweet(tweet) - - # Expand the URLs that are in the tweets - response_data["data"] = expand_urls_in_tweets( - response_data.get("data", []), delete_entities=True - ) - - # Parse the response data - response_data = parse_search_recent_tweets_response(response_data) - - return response_data - - -@tool(requires_auth=X(scopes=["tweet.read", "users.read"])) -async def search_recent_tweets_by_keywords( - context: ToolContext, - keywords: Annotated[ - list[str] | None, "List of keywords that must be present in the tweet" - ] = None, - phrases: Annotated[ - list[str] | None, "List of phrases that must be present in the tweet" - ] = None, - max_results: Annotated[ - int, "The maximum number of results to return. Must be in range [1, 100] inclusive" - ] = 10, - next_token: Annotated[ - str | None, "The pagination token starting from which to return results" - ] = None, -) -> Annotated[dict[str, Any], "Dictionary containing the search results"]: - """ - Search for recent tweets (last 7 days) on X (Twitter) by required keywords and phrases. - Includes replies and reposts. - One of the following input parameters MUST be provided: keywords, phrases - """ - - if not any([keywords, phrases]): - raise RetryableToolError( - "No keywords or phrases provided", - developer_message="Predicted inputs didn't contain any keywords or phrases", - additional_prompt_content="Please provide at least one keyword or phrase for search", - retry_after_ms=500, # Play nice with X API rate limits - ) - - headers = get_headers_with_token(context) - - query = "".join([f'"{phrase}" ' for phrase in (phrases or [])]) - if keywords: - query += " ".join(keywords or []) - - params: dict[str, Any] = { - "query": query.strip(), - "max_results": min( - max(max_results, 10), 100 - ), # X API does not allow 'max_results' less than 10 or greater than 100 - "next_token": next_token, - "expansions": "author_id", - "user.fields": "id,name,username,entities", - "tweet.fields": "entities,note_tweet", - } - params = expand_attached_media(remove_none_values(params)) - - url = f"{TWEETS_URL}/search/recent" - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params, timeout=10) - response.raise_for_status() - - response_data: dict[str, Any] = response.json() - - for tweet in response_data.get("data", []): - expand_long_tweet(tweet) - - # Expand the URLs that are in the tweets - response_data["data"] = expand_urls_in_tweets( - response_data.get("data", []), delete_entities=True - ) - - # Parse the response data - response_data = parse_search_recent_tweets_response(response_data) - - return response_data - - -@tool(requires_auth=X(scopes=["tweet.read", "users.read"])) -async def lookup_tweet_by_id( - context: ToolContext, - tweet_id: Annotated[str, "The ID of the tweet you want to look up"], -) -> Annotated[dict[str, Any], "Dictionary containing the tweet data"]: - """Look up a tweet on X (Twitter) by tweet ID.""" - - headers = get_headers_with_token(context) - params = { - "expansions": "author_id", - "user.fields": "id,name,username,entities", - "tweet.fields": "entities,note_tweet", - } - params = expand_attached_media(params) - - url = f"{TWEETS_URL}/{tweet_id}" - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, params=params, timeout=10) - response.raise_for_status() - - response_data: dict[str, Any] = response.json() - - # Get the tweet data - tweet_data = response_data.get("data") - if tweet_data: - expand_long_tweet(tweet_data) - - # Expand the URLs that are in the tweet - expanded_tweet_list = expand_urls_in_tweets([tweet_data], delete_entities=True) - response_data["data"] = expanded_tweet_list[0] - else: - response_data["data"] = {} - - return response_data diff --git a/toolkits/x/arcade_x/tools/users.py b/toolkits/x/arcade_x/tools/users.py deleted file mode 100644 index 31624086..00000000 --- a/toolkits/x/arcade_x/tools/users.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import X -from arcade_tdk.errors import RetryableToolError - -from arcade_x.tools.utils import ( - expand_urls_in_user_description, - expand_urls_in_user_url, - get_headers_with_token, -) - -# Users Lookup Tools. See developer docs for additional available query parameters: -# https://developer.x.com/en/docs/x-api/users/lookup/api-reference - - -@tool(requires_auth=X(scopes=["users.read", "tweet.read"])) -async def lookup_single_user_by_username( - context: ToolContext, - username: Annotated[str, "The username of the X (Twitter) user to look up"], -) -> Annotated[dict, "User information including id, name, username, and description"]: - """Look up a user on X (Twitter) by their username.""" - - headers = get_headers_with_token(context) - - user_fields = ",".join([ - "created_at", - "description", - "id", - "location", - "most_recent_tweet_id", - "name", - "pinned_tweet_id", - "profile_image_url", - "protected", - "public_metrics", - "url", - "username", - "verified", - "verified_type", - "withheld", - "entities", - ]) - url = f"https://api.x.com/2/users/by/username/{username}?user.fields={user_fields}" - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers, timeout=10) - if response.status_code == 404: - # User not found - raise RetryableToolError( - "User not found", - developer_message=f"User with username '{username}' not found.", - additional_prompt_content="Please check the username and try again.", - retry_after_ms=500, # Play nice with X API rate limits - ) - response.raise_for_status() - # Parse the response JSON - user_data = response.json()["data"] - - user_data = expand_urls_in_user_description(user_data, delete_entities=False) - user_data = expand_urls_in_user_url(user_data, delete_entities=True) - - return {"data": user_data} diff --git a/toolkits/x/arcade_x/tools/utils.py b/toolkits/x/arcade_x/tools/utils.py deleted file mode 100644 index 086fbcb4..00000000 --- a/toolkits/x/arcade_x/tools/utils.py +++ /dev/null @@ -1,163 +0,0 @@ -from typing import Any - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError - - -def get_tweet_url(tweet_id: str) -> str: - """Get the URL of a tweet given its ID.""" - return f"https://x.com/x/status/{tweet_id}" - - -def get_headers_with_token(context: ToolContext) -> dict[str, str]: - """Get the headers for a request to the X API.""" - if context.authorization is None or context.authorization.token is None: - raise ToolExecutionError( - "Missing Token. Authorization is required to post a tweet.", - developer_message="Token is not set in the ToolContext.", - ) - token = ( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - -def parse_search_recent_tweets_response(response_data: dict[str, Any]) -> dict[str, Any]: - """ - Parses response from the X API search recent tweets endpoint. - Returns the modified response data with added 'tweet_url', 'author_username', and 'author_name'. - """ - if not sanity_check_tweets_data(response_data): - return {"data": [], "next_token": ""} - - # Add 'tweet_url' to each tweet - for tweet in response_data["data"]: - tweet["tweet_url"] = get_tweet_url(tweet["id"]) - - # Add 'author_username' and 'author_name' to each tweet - for tweet_data, user_data in zip( - response_data["data"], response_data["includes"]["users"], strict=False - ): - tweet_data["author_username"] = user_data["username"] - tweet_data["author_name"] = user_data["name"] - - return response_data - - -def sanity_check_tweets_data(tweets_data: dict[str, Any]) -> bool: - """ - Sanity check the tweets data. - Returns True if the tweets data is valid and contains tweets, False otherwise. - """ - if not tweets_data.get("data"): - return False - # prefer clarity over appeasing linter here - if not tweets_data.get("includes", {}).get("users"): # noqa: SIM103 - return False - return True - - -def expand_long_tweet(tweet_data: dict[str, Any]) -> None: - """Expand a long tweet. - - For tweets exceeding 280 characters, - replace the truncated tweet text with the full tweet text. - """ - if tweet_data.get("note_tweet"): - tweet_data["text"] = tweet_data["note_tweet"]["text"] - del tweet_data["note_tweet"] - - -def expand_urls_in_tweets( - tweets_data: list[dict[str, Any]], delete_entities: bool = True -) -> list[dict[str, Any]]: - """ - Returns a new list of tweets with expanded URLs. - """ - new_tweets = [] - for tweet_data in tweets_data: - new_tweet = tweet_data.copy() - if "entities" in new_tweet and "urls" in new_tweet["entities"]: - for url_entity in new_tweet["entities"]["urls"]: - if "url" in url_entity and "expanded_url" in url_entity: - short_url = url_entity["url"] - expanded_url = url_entity["expanded_url"] - new_tweet["text"] = new_tweet["text"].replace(short_url, expanded_url) - - if delete_entities: - new_tweet.pop("entities", None) - new_tweets.append(new_tweet) - return new_tweets - - -def expand_urls_in_user_description(user_data: dict, delete_entities: bool = True) -> dict: - """ - Returns a new user data dict with expanded URLs in the description. - """ - new_user_data = user_data.copy() - description_urls = new_user_data.get("entities", {}).get("description", {}).get("urls", []) - description = new_user_data.get("description", "") - for url_info in description_urls: - t_co_link = url_info["url"] - expanded_url = url_info["expanded_url"] - description = description.replace(t_co_link, expanded_url) - new_user_data["description"] = description - - if delete_entities: - new_user_data.pop("entities", None) - return new_user_data - - -def expand_urls_in_user_url(user_data: dict, delete_entities: bool = True) -> dict: - """ - Returns a new user data dict with expanded URLs in the URL field. - """ - new_user_data = user_data.copy() - url_urls = new_user_data.get("entities", {}).get("url", {}).get("urls", []) - url = new_user_data.get("url", "") - for url_info in url_urls: - t_co_link = url_info["url"] - expanded_url = url_info["expanded_url"] - url = url.replace(t_co_link, expanded_url) - new_user_data["url"] = url - - if delete_entities: - new_user_data.pop("entities", None) - return new_user_data - - -def remove_none_values(params: dict) -> dict: - """ - Remove key/value pairs with None values from a dictionary. - - Args: - params: The dictionary to clean - - Returns: - A new dictionary with None values removed - """ - return {k: v for k, v in params.items() if v is not None} - - -def expand_attached_media(params: dict) -> dict: - """ - Include attached media metadata in the request parameters. - """ - params["expansions"] += ",attachments.media_keys" - params["tweet.fields"] += ",attachments" - params["media.fields"] = ",".join([ - # media_key, url and type are returned by default, added here for clarity - "media_key", - "url", - "type", - "duration_ms", - "height", - "width", - "preview_image_url", - "alt_text", - "public_metrics", - ]) - return params diff --git a/toolkits/x/conftest.py b/toolkits/x/conftest.py deleted file mode 100644 index 55ca1e5e..00000000 --- a/toolkits/x/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -from arcade_tdk import ToolContext - - -@pytest.fixture -def tool_context(): - """Fixture for the ToolContext with mock authorization.""" - return ToolContext(authorization={"token": "test_token", "user_id": "test_user"}) - - -@pytest.fixture -def mock_httpx_client(mocker): - """Fixture to mock the httpx.AsyncClient.""" - # Mock the AsyncClient context manager - mock_client = mocker.patch("httpx.AsyncClient", autospec=True) - async_mock_client = mock_client.return_value.__aenter__.return_value - return async_mock_client diff --git a/toolkits/x/evals/eval_x_tools.py b/toolkits/x/evals/eval_x_tools.py deleted file mode 100644 index 19914737..00000000 --- a/toolkits/x/evals/eval_x_tools.py +++ /dev/null @@ -1,219 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_x -from arcade_x.tools.tweets import ( - delete_tweet_by_id, - lookup_tweet_by_id, - post_tweet, - search_recent_tweets_by_keywords, - search_recent_tweets_by_username, -) -from arcade_x.tools.users import lookup_single_user_by_username - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.7, - warn_threshold=0.9, -) - -catalog = ToolCatalog() -# Register the X tools -catalog.add_module(arcade_x) - -search_recent_tweets_by_username_history = [ - {"role": "user", "content": "list 1 tweet from elonmusk"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_kineaPbYCAof3n6qCwnYSKBb", - "type": "function", - "function": { - "name": "X_SearchRecentTweetsByUsername", - "arguments": '{"max_results":1,"username":"elonmusk"}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"data":[{"author_id":"44196397","author_name":"Elon Musk","author_username":"elonmusk","edit_history_tweet_ids":["1866572304320466985"],"id":"1866572304320466985","text":"RT @chamath: Meanwhile the State of California is going to spend almost double this ($35B) to build a 171 mile stretch of rail between Merc…","tweet_url":"https://x.com/x/status/1866572304320466985"},{"author_id":"44196397","edit_history_tweet_ids":["1866571568266219998"],"id":"1866571568266219998","text":"This is awesome 🚀🇺🇸 https://twitter.com/cb_doge/status/1866565984502550905","tweet_url":"https://x.com/x/status/1866571568266219998"},{"author_id":"44196397","edit_history_tweet_ids":["1866571416969285954"],"id":"1866571416969285954","text":"@ajtourville @Tesla I’ve always felt that the climate predictions were too pessimistic and bound to backfire. \\n\\nExtreme environmentalists can’t say ridiculous things like the world is doomed in 5 years, because 5 years goes by, the world is ok and they lose credibility. \\n\\nIf we transition to… https://x.com/i/web/status/1866571416969285954","tweet_url":"https://x.com/x/status/1866571416969285954"},{"author_id":"44196397","edit_history_tweet_ids":["1866569957309603946"],"id":"1866569957309603946","text":"@shaunmmaguire Yes, please. This is gone on for too long. Enough.","tweet_url":"https://x.com/x/status/1866569957309603946"},{"author_id":"44196397","edit_history_tweet_ids":["1866569078539948491"],"id":"1866569078539948491","text":"@FatEmperor 😂","tweet_url":"https://x.com/x/status/1866569078539948491"},{"author_id":"44196397","edit_history_tweet_ids":["1866554579925577793"],"id":"1866554579925577793","text":"@cb_doge I’m not buying or building a house anywhere","tweet_url":"https://x.com/x/status/1866554579925577793"},{"author_id":"44196397","edit_history_tweet_ids":["1866536009833361915"],"id":"1866536009833361915","text":"RT @amuse: http://x.com/i/article/1866500805211123713","tweet_url":"https://x.com/x/status/1866536009833361915"},{"author_id":"44196397","edit_history_tweet_ids":["1866535704924483739"],"id":"1866535704924483739","text":"@benshapiro 😂","tweet_url":"https://x.com/x/status/1866535704924483739"},{"author_id":"44196397","edit_history_tweet_ids":["1866535550632550854"],"id":"1866535550632550854","text":"@AutismCapital 😂","tweet_url":"https://x.com/x/status/1866535550632550854"},{"author_id":"44196397","edit_history_tweet_ids":["1866535352024043804"],"id":"1866535352024043804","text":"@JDVance Yes","tweet_url":"https://x.com/x/status/1866535352024043804"}],"includes":{"users":[{"id":"44196397","name":"Elon Musk","username":"elonmusk"}]},"meta":{"newest_id":"1866572304320466985","next_token":"b26v89c19zqg8o3frr3tekall7a7ooom3sctaw30rz62l","oldest_id":"1866535352024043804","result_count":10}}', # noqa: RUF001 - "tool_call_id": "call_kineaPbYCAof3n6qCwnYSKBb", - "name": "X_SearchRecentTweetsByUsername", - }, - { - "role": "assistant", - "content": 'Here is a recent tweet from Elon Musk: \n\n"This is awesome 🚀🇺🇸" - [Tweet link](https://x.com/x/status/1866571568266219998)', - }, -] - - -@tool_eval() -def x_eval_suite() -> EvalSuite: - """Evaluation suite for X (Twitter) tools.""" - - suite = EvalSuite( - name="X Tools Evaluation Suite", - system_message=( - "You are an AI assistant with access to the X (Twitter) tools. Use them to " - "help answer the user's X-related tasks/questions." - ), - catalog=catalog, - rubric=rubric, - ) - - # Add cases - suite.add_case( - name="Post a tweet", - user_message=( - "Send out a tweet that says 'Hello World! Exciting stuff is happening over " - "at Arcade AI!'" - ), - expected_tool_calls=[ - ExpectedToolCall( - func=post_tweet, - args={"tweet_text": "Hello World! Exciting stuff is happening over at Arcade AI!"}, - ) - ], - critics=[ - BinaryCritic( - critic_field="tweet_text", - weight=1.0, - ), - ], - ) - - suite.add_case( - name="Delete a tweet by ID", - user_message="Please delete the tweet with ID '148975632'.", - expected_tool_calls=[ - ExpectedToolCall( - func=delete_tweet_by_id, - args={"tweet_id": "148975632"}, - ) - ], - critics=[ - BinaryCritic( - critic_field="tweet_id", - weight=1.0, - ), - ], - ) - - suite.add_case( - name="Search recent tweets by username", - user_message="Show me the recent tweets from 'elonmusk'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_recent_tweets_by_username, - args={"username": "elonmusk", "max_results": 10}, - ) - ], - critics=[ - BinaryCritic( - critic_field="username", - weight=1.0, - ), - ], - ) - - suite.add_case( - name="Search recent tweets by username with history", - user_message="Get the next 42", - additional_messages=search_recent_tweets_by_username_history, - expected_tool_calls=[ - ExpectedToolCall( - func=search_recent_tweets_by_username, - args={ - "username": "elonmusk", - "max_results": 42, - "next_token": "b26v89c19zqg8o3frr3tekall7a7ooom3sctaw30rz62l", - }, - ), - ], - critics=[ - BinaryCritic( - critic_field="username", - weight=0.2, - ), - BinaryCritic( - critic_field="max_results", - weight=0.2, - ), - BinaryCritic( - critic_field="next_token", - weight=0.6, - ), - ], - ) - - suite.add_case( - name="Lookup user by username", - user_message="Can you get information about the user '@jack'?", - expected_tool_calls=[ - ExpectedToolCall( - func=lookup_single_user_by_username, - args={"username": "jack"}, - ), - ], - critics=[ - BinaryCritic( - critic_field="username", - weight=1.0, - ), - ], - ) - - # Add a case for searching recent tweets by keywords - suite.add_case( - name="Search recent tweets by keywords", - user_message="Find recent tweets containing 'Arcade AI'.", - expected_tool_calls=[ - ExpectedToolCall( - func=search_recent_tweets_by_keywords, - args={ - "keywords": None, - "phrases": ["Arcade AI"], - "max_results": 10, - }, - ), - ], - critics=[ - BinaryCritic( - critic_field="keywords", - weight=0.1, - ), - BinaryCritic( - critic_field="phrases", - weight=0.9, - ), - ], - ) - - # Extend the case to test lookup_tweet_by_id - suite.extend_case( - name="Lookup tweet by ID", - user_message="Can you provide details about the tweet with ID '123456789'?", - expected_tool_calls=[ - ExpectedToolCall( - func=lookup_tweet_by_id, - args={"tweet_id": "123456789"}, - ), - ], - critics=[ - BinaryCritic( - critic_field="tweet_id", - weight=1.0, - ), - ], - ) - - return suite diff --git a/toolkits/x/pyproject.toml b/toolkits/x/pyproject.toml deleted file mode 100644 index c04652ae..00000000 --- a/toolkits/x/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_x" -version = "0.1.16" -description = "Arcade.dev LLM tools for X (Twitter)" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "httpx>=0.27.2,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_x/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_x",] diff --git a/toolkits/x/tests/__init__.py b/toolkits/x/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/x/tests/test_tweets.py b/toolkits/x/tests/test_tweets.py deleted file mode 100644 index f534630d..00000000 --- a/toolkits/x/tests/test_tweets.py +++ /dev/null @@ -1,298 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_x.tools.tweets import ( - delete_tweet_by_id, - lookup_tweet_by_id, - post_tweet, - search_recent_tweets_by_keywords, - search_recent_tweets_by_username, -) -from arcade_x.tools.utils import get_tweet_url - -full_tweet_text = ( - "This is a super long tweet that exceeds 280 characters and I want to see if the tool will " - "successfully handle long tweets so I will continue to write this tweet until I have " - "exceeded the 280 character count. So far I have typed 'e' 28 times! Now its 29! Did you " - "know that the oldest tree in the world is... wait I actually don't know this fact." -) -truncated_tweet_text = ( - "This is a super long tweet that exceeds 280 characters and I want to see if the tool will " - "successfully handle long tweets so I will continue to write this tweet until I have " - "exceeded the 280 character count. So far I have typed 'e' 28 times! Now its 29! Did you " - "know that the..." -) - - -@pytest.mark.asyncio -async def test_post_tweet_success(tool_context, mock_httpx_client): - """Test successful posting of a tweet.""" - # Mock response for a successful tweet post - mock_response = MagicMock() - mock_response.status_code = 201 - mock_response.json.return_value = {"data": {"id": "1234567890"}} - mock_httpx_client.post.return_value = mock_response - - tweet_text = "Hello, world!" - result = await post_tweet(tool_context, tweet_text) - - expected_url = get_tweet_url("1234567890") - assert result == f"Tweet with id 1234567890 posted successfully. URL: {expected_url}" - mock_httpx_client.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_post_tweet_failure(tool_context, mock_httpx_client): - """Test failure when posting a tweet due to API error.""" - # Mock response for a failed tweet post - mock_response = httpx.HTTPStatusError( - "Bad Request", request=MagicMock(), response=MagicMock(status_code=400) - ) - mock_httpx_client.post.side_effect = mock_response - - tweet_text = "Hello, world!" - with pytest.raises(ToolExecutionError): - await post_tweet(tool_context, tweet_text) - - mock_httpx_client.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_delete_tweet_by_id_success(tool_context, mock_httpx_client): - """Test successful deletion of a tweet by ID.""" - # Mock response for a successful tweet deletion - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_client.delete.return_value = mock_response - - tweet_id = "1234567890" - result = await delete_tweet_by_id(tool_context, tweet_id) - - assert result == f"Tweet with id {tweet_id} deleted successfully." - mock_httpx_client.delete.assert_called_once() - - -@pytest.mark.asyncio -async def test_delete_tweet_by_id_failure(tool_context, mock_httpx_client): - """Test failure when deleting a tweet due to API error.""" - # Mock response for a failed tweet deletion - mock_response = httpx.HTTPStatusError( - "Internal Server Error", request=MagicMock(), response=MagicMock(status_code=404) - ) - mock_httpx_client.delete.side_effect = mock_response - - tweet_id = "1234567890" - with pytest.raises(ToolExecutionError): - await delete_tweet_by_id(tool_context, tweet_id) - - mock_httpx_client.delete.assert_called_once() - - -@pytest.mark.asyncio -async def test_search_recent_tweets_by_username_success(tool_context, mock_httpx_client): - """Test successful search of recent tweets by username.""" - # Mock response for a successful tweet search - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": [ - { - "id": "1234567890", - "note_tweet": { - "entities": { - "mentions": [ - {"end": 19, "id": "00000000", "start": 4, "username": "aUsername"} - ] - }, - "text": full_tweet_text, - }, - "text": truncated_tweet_text, - "entities": { - "urls": [ - {"url": "https://t.co/short", "expanded_url": "https://example.com/long"} - ] - }, - } - ], - "includes": { - "users": [{"id": "0987654321", "name": "Test User", "username": "testuser"}], - "media": [ - {"media_key": "1234567890", "type": "photo", "url": "https://example.com/photo.jpg"} - ], - }, - } - mock_httpx_client.get.return_value = mock_response - - username = "testuser" - result = await search_recent_tweets_by_username(tool_context, username) - - assert "data" in result - assert len(result["data"]) == 1 - assert result["data"][0]["text"] == full_tweet_text - - assert "includes" in result - assert "media" in result["includes"] - assert len(result["includes"]["media"]) == 1 - assert result["includes"]["media"][0]["url"] == "https://example.com/photo.jpg" - - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_search_recent_tweets_by_username_no_tweets_found(tool_context, mock_httpx_client): - """Test that the tool returns an empty list when no tweets are found.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"next_token": ""} - mock_httpx_client.get.return_value = mock_response - - username = "not_a_user_41" - result = await search_recent_tweets_by_username(tool_context, username) - - assert "data" in result - assert len(result["data"]) == 0 - - -@pytest.mark.asyncio -async def test_search_recent_tweets_by_username_failure(tool_context, mock_httpx_client): - """Test failure when searching tweets due to API error.""" - # Mock response for a failed tweet search - mock_response = httpx.HTTPStatusError( - "Internal Server Error", request=MagicMock(), response=MagicMock(status_code=500) - ) - mock_httpx_client.get.side_effect = mock_response - - username = "testuser" - with pytest.raises(ToolExecutionError): - await search_recent_tweets_by_username(tool_context, username) - - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_search_recent_tweets_by_keywords_success(tool_context, mock_httpx_client): - """Test successful search of recent tweets by keywords.""" - # Mock response for a successful keyword search - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": [ - { - "id": "1234567890", - "note_tweet": { - "entities": { - "mentions": [ - {"end": 19, "id": "00000000", "start": 4, "username": "aUsername"} - ] - }, - "text": full_tweet_text, - }, - "text": truncated_tweet_text, - "entities": {}, - } - ], - "includes": { - "users": [{"id": "0987654321", "name": "Test User", "username": "testuser"}], - "media": [ - {"media_key": "1234567890", "type": "photo", "url": "https://example.com/photo.jpg"} - ], - }, - } - mock_httpx_client.get.return_value = mock_response - - keywords = ["test", "keyword"] - result = await search_recent_tweets_by_keywords(context=tool_context, keywords=keywords) - - assert "data" in result - assert len(result["data"]) == 1 - assert result["data"][0]["text"] == full_tweet_text - - assert "includes" in result - assert "media" in result["includes"] - assert len(result["includes"]["media"]) == 1 - assert result["includes"]["media"][0]["url"] == "https://example.com/photo.jpg" - - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_search_recent_tweets_by_keywords_no_tweets_found(tool_context, mock_httpx_client): - """Test that the tool returns an empty list when no tweets are found.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"next_token": ""} - mock_httpx_client.get.return_value = mock_response - - keywords = ["test", "keyword"] - result = await search_recent_tweets_by_keywords(context=tool_context, keywords=keywords) - - assert "data" in result - assert len(result["data"]) == 0 - - -@pytest.mark.asyncio -async def test_search_recent_tweets_by_keywords_no_input(tool_context): - """Test error when no keywords or phrases are provided.""" - with pytest.raises(RetryableToolError) as exc_info: - await search_recent_tweets_by_keywords(tool_context) - - assert "No keywords or phrases provided" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_lookup_tweet_by_id_success(tool_context, mock_httpx_client): - """Test successful lookup of a tweet by ID.""" - # Use MagicMock for the response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": { - "id": "1234567890", - "note_tweet": { - "entities": { - "mentions": [{"end": 19, "id": "00000000", "start": 4, "username": "aUsername"}] - }, - "text": full_tweet_text, - }, - "text": truncated_tweet_text, - "entities": {}, - }, - "includes": { - "media": [ - {"media_key": "1234567890", "type": "photo", "url": "https://example.com/photo.jpg"} - ] - }, - } - mock_httpx_client.get.return_value = mock_response - - tweet_id = "1234567890" - result = await lookup_tweet_by_id(tool_context, tweet_id) - - assert "data" in result - assert result["data"]["text"] == full_tweet_text - - assert "includes" in result - assert "media" in result["includes"] - assert len(result["includes"]["media"]) == 1 - assert result["includes"]["media"][0]["url"] == "https://example.com/photo.jpg" - - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_lookup_tweet_by_id_failure(tool_context, mock_httpx_client): - """Test failure when looking up a tweet due to API error.""" - # Mock response for a failed tweet lookup - mock_response = httpx.HTTPStatusError( - "Not Found", request=MagicMock(), response=MagicMock(status_code=404) - ) - mock_httpx_client.get.side_effect = mock_response - - tweet_id = "1234567890" - with pytest.raises(ToolExecutionError): - await lookup_tweet_by_id(tool_context, tweet_id) - - mock_httpx_client.get.assert_called_once() diff --git a/toolkits/x/tests/test_users.py b/toolkits/x/tests/test_users.py deleted file mode 100644 index faee0191..00000000 --- a/toolkits/x/tests/test_users.py +++ /dev/null @@ -1,78 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_x.tools.users import lookup_single_user_by_username - - -@pytest.mark.asyncio -async def test_lookup_single_user_by_username_success(tool_context, mock_httpx_client): - """Test successful lookup of a user by username.""" - # Mock response for a successful user lookup - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": { - "id": "1234567890", - "name": "Test User", - "username": "testuser", - "description": "This is a test user", - # Additional fields can be added here as needed - } - } - mock_httpx_client.get.return_value = mock_response - - username = "testuser" - result = await lookup_single_user_by_username(tool_context, username) - - assert "data" in result - assert result["data"]["username"] == "testuser" - assert result["data"]["name"] == "Test User" - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_lookup_single_user_by_username_user_not_found(tool_context, mock_httpx_client): - """Test behavior when looking up user fails due to API error""" - # Mock response for user not found - mock_response = httpx.HTTPStatusError( - "Not Found", request=MagicMock(), response=MagicMock(status_code=404) - ) - mock_httpx_client.get.side_effect = mock_response - - username = "nonexistentuser" - with pytest.raises(ToolExecutionError): - await lookup_single_user_by_username(tool_context, username) - - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_lookup_single_user_by_username_api_error(tool_context, mock_httpx_client): - """Test behavior when API returns an error other than 404.""" - # Mock response for API error - mock_response = httpx.HTTPStatusError( - "Internal Server Error", request=MagicMock(), response=MagicMock(status_code=500) - ) - mock_httpx_client.get.side_effect = mock_response - - username = "testuser" - with pytest.raises(ToolExecutionError): - await lookup_single_user_by_username(tool_context, username) - - mock_httpx_client.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_lookup_single_user_by_username_network_error(tool_context, mock_httpx_client): - """Test behavior when there is a network error during the request.""" - # Mock client.get to raise an HTTPError - mock_httpx_client.get.side_effect = httpx.HTTPError("Network Error") - - username = "testuser" - with pytest.raises(ToolExecutionError): - await lookup_single_user_by_username(tool_context, username) - - mock_httpx_client.get.assert_called_once() diff --git a/toolkits/youtube/.pre-commit-config.yaml b/toolkits/youtube/.pre-commit-config.yaml deleted file mode 100644 index 52c11167..00000000 --- a/toolkits/youtube/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/youtube/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/youtube/.ruff.toml b/toolkits/youtube/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/youtube/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/youtube/LICENSE b/toolkits/youtube/LICENSE deleted file mode 100644 index 45f53e20..00000000 --- a/toolkits/youtube/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Arcade - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/youtube/Makefile b/toolkits/youtube/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/youtube/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/youtube/arcade_youtube/__init__.py b/toolkits/youtube/arcade_youtube/__init__.py deleted file mode 100644 index 5ef6eeeb..00000000 --- a/toolkits/youtube/arcade_youtube/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_youtube.tools import get_youtube_video_details, search_for_videos - -__all__ = ["get_youtube_video_details", "search_for_videos"] diff --git a/toolkits/youtube/arcade_youtube/constants.py b/toolkits/youtube/arcade_youtube/constants.py deleted file mode 100644 index db8b6670..00000000 --- a/toolkits/youtube/arcade_youtube/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -YOUTUBE_MAX_DESCRIPTION_LENGTH = 500 -DEFAULT_YOUTUBE_SEARCH_LANGUAGE = os.getenv("ARCADE_YOUTUBE_SEARCH_LANGUAGE") -DEFAULT_YOUTUBE_SEARCH_COUNTRY = os.getenv("ARCADE_YOUTUBE_SEARCH_COUNTRY") -DEFAULT_GOOGLE_LANGUAGE = os.getenv("ARCADE_GOOGLE_LANGUAGE", "en") -DEFAULT_GOOGLE_COUNTRY = os.getenv("ARCADE_GOOGLE_COUNTRY") diff --git a/toolkits/youtube/arcade_youtube/exceptions.py b/toolkits/youtube/arcade_youtube/exceptions.py deleted file mode 100644 index 51d3f809..00000000 --- a/toolkits/youtube/arcade_youtube/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -from arcade_tdk.errors import RetryableToolError - -from arcade_youtube.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -class GoogleRetryableError(RetryableToolError): - pass - - -class CountryNotFoundError(GoogleRetryableError): - def __init__(self, country: str | None) -> None: - valid_countries = json.dumps(COUNTRY_CODES, default=str) - message = f"Country not found: '{country}'." - additional_message = f"Valid countries are: {valid_countries}" - super().__init__(message, additional_prompt_content=additional_message) - - -class LanguageNotFoundError(GoogleRetryableError): - def __init__(self, language: str | None) -> None: - valid_languages = json.dumps(LANGUAGE_CODES, default=str) - message = f"Language not found: '{language}'." - additional_message = f"Valid languages are: {valid_languages}" - super().__init__(message, additional_prompt_content=additional_message) diff --git a/toolkits/youtube/arcade_youtube/google_data.py b/toolkits/youtube/arcade_youtube/google_data.py deleted file mode 100644 index 789e3183..00000000 --- a/toolkits/youtube/arcade_youtube/google_data.py +++ /dev/null @@ -1,281 +0,0 @@ -COUNTRY_CODES = { - "af": "Afghanistan", - "al": "Albania", - "dz": "Algeria", - "as": "American Samoa", - "ad": "Andorra", - "ao": "Angola", - "ai": "Anguilla", - "aq": "Antarctica", - "ag": "Antigua and Barbuda", - "ar": "Argentina", - "am": "Armenia", - "aw": "Aruba", - "au": "Australia", - "at": "Austria", - "az": "Azerbaijan", - "bs": "Bahamas", - "bh": "Bahrain", - "bd": "Bangladesh", - "bb": "Barbados", - "by": "Belarus", - "be": "Belgium", - "bz": "Belize", - "bj": "Benin", - "bm": "Bermuda", - "bt": "Bhutan", - "bo": "Bolivia", - "ba": "Bosnia and Herzegovina", - "bw": "Botswana", - "bv": "Bouvet Island", - "br": "Brazil", - "io": "British Indian Ocean Territory", - "bn": "Brunei Darussalam", - "bg": "Bulgaria", - "bf": "Burkina Faso", - "bi": "Burundi", - "kh": "Cambodia", - "cm": "Cameroon", - "ca": "Canada", - "cv": "Cape Verde", - "ky": "Cayman Islands", - "cf": "Central African Republic", - "td": "Chad", - "cl": "Chile", - "cn": "China", - "cx": "Christmas Island", - "cc": "Cocos (Keeling) Islands", - "co": "Colombia", - "km": "Comoros", - "cg": "Congo", - "cd": "Congo, the Democratic Republic of the", - "ck": "Cook Islands", - "cr": "Costa Rica", - "ci": "Cote D'ivoire", - "hr": "Croatia", - "cu": "Cuba", - "cy": "Cyprus", - "cz": "Czech Republic", - "dk": "Denmark", - "dj": "Djibouti", - "dm": "Dominica", - "do": "Dominican Republic", - "ec": "Ecuador", - "eg": "Egypt", - "sv": "El Salvador", - "gq": "Equatorial Guinea", - "er": "Eritrea", - "ee": "Estonia", - "et": "Ethiopia", - "fk": "Falkland Islands (Malvinas)", - "fo": "Faroe Islands", - "fj": "Fiji", - "fi": "Finland", - "fr": "France", - "gf": "French Guiana", - "pf": "French Polynesia", - "tf": "French Southern Territories", - "ga": "Gabon", - "gm": "Gambia", - "ge": "Georgia", - "de": "Germany", - "gh": "Ghana", - "gi": "Gibraltar", - "gr": "Greece", - "gl": "Greenland", - "gd": "Grenada", - "gp": "Guadeloupe", - "gu": "Guam", - "gt": "Guatemala", - "gg": "Guernsey", - "gn": "Guinea", - "gw": "Guinea-Bissau", - "gy": "Guyana", - "ht": "Haiti", - "hm": "Heard Island and Mcdonald Islands", - "va": "Holy See (Vatican City State)", - "hn": "Honduras", - "hk": "Hong Kong", - "hu": "Hungary", - "is": "Iceland", - "in": "India", - "id": "Indonesia", - "ir": "Iran, Islamic Republic of", - "iq": "Iraq", - "ie": "Ireland", - "im": "Isle of Man", - "il": "Israel", - "it": "Italy", - "je": "Jersey", - "jm": "Jamaica", - "jp": "Japan", - "jo": "Jordan", - "kz": "Kazakhstan", - "ke": "Kenya", - "ki": "Kiribati", - "kp": "Korea, Democratic People's Republic of", - "kr": "Korea, Republic of", - "kw": "Kuwait", - "kg": "Kyrgyzstan", - "la": "Lao People's Democratic Republic", - "lv": "Latvia", - "lb": "Lebanon", - "ls": "Lesotho", - "lr": "Liberia", - "ly": "Libyan Arab Jamahiriya", - "li": "Liechtenstein", - "lt": "Lithuania", - "lu": "Luxembourg", - "mo": "Macao", - "mk": "Macedonia, the Former Yugosalv Republic of", - "mg": "Madagascar", - "mw": "Malawi", - "my": "Malaysia", - "mv": "Maldives", - "ml": "Mali", - "mt": "Malta", - "mh": "Marshall Islands", - "mq": "Martinique", - "mr": "Mauritania", - "mu": "Mauritius", - "yt": "Mayotte", - "mx": "Mexico", - "fm": "Micronesia, Federated States of", - "md": "Moldova, Republic of", - "mc": "Monaco", - "mn": "Mongolia", - "me": "Montenegro", - "ms": "Montserrat", - "ma": "Morocco", - "mz": "Mozambique", - "mm": "Myanmar", - "na": "Namibia", - "nr": "Nauru", - "np": "Nepal", - "nl": "Netherlands", - "an": "Netherlands Antilles", - "nc": "New Caledonia", - "nz": "New Zealand", - "ni": "Nicaragua", - "ne": "Niger", - "ng": "Nigeria", - "nu": "Niue", - "nf": "Norfolk Island", - "mp": "Northern Mariana Islands", - "no": "Norway", - "om": "Oman", - "pk": "Pakistan", - "pw": "Palau", - "ps": "Palestinian Territory, Occupied", - "pa": "Panama", - "pg": "Papua New Guinea", - "py": "Paraguay", - "pe": "Peru", - "ph": "Philippines", - "pn": "Pitcairn", - "pl": "Poland", - "pt": "Portugal", - "pr": "Puerto Rico", - "qa": "Qatar", - "re": "Reunion", - "ro": "Romania", - "ru": "Russian Federation", - "rw": "Rwanda", - "sh": "Saint Helena", - "kn": "Saint Kitts and Nevis", - "lc": "Saint Lucia", - "pm": "Saint Pierre and Miquelon", - "vc": "Saint Vincent and the Grenadines", - "ws": "Samoa", - "sm": "San Marino", - "st": "Sao Tome and Principe", - "sa": "Saudi Arabia", - "sn": "Senegal", - "rs": "Serbia", - "sc": "Seychelles", - "sl": "Sierra Leone", - "sg": "Singapore", - "sk": "Slovakia", - "si": "Slovenia", - "sb": "Solomon Islands", - "so": "Somalia", - "za": "South Africa", - "gs": "South Georgia and the South Sandwich Islands", - "es": "Spain", - "lk": "Sri Lanka", - "sd": "Sudan", - "sr": "Suriname", - "sj": "Svalbard and Jan Mayen", - "sz": "Swaziland", - "se": "Sweden", - "ch": "Switzerland", - "sy": "Syrian Arab Republic", - "tw": "Taiwan, Province of China", - "tj": "Tajikistan", - "tz": "Tanzania, United Republic of", - "th": "Thailand", - "tl": "Timor-Leste", - "tg": "Togo", - "tk": "Tokelau", - "to": "Tonga", - "tt": "Trinidad and Tobago", - "tn": "Tunisia", - "tr": "Turkiye", - "tm": "Turkmenistan", - "tc": "Turks and Caicos Islands", - "tv": "Tuvalu", - "ug": "Uganda", - "ua": "Ukraine", - "ae": "United Arab Emirates", - "uk": "United Kingdom", - "gb": "United Kingdom", - "us": "United States", - "um": "United States Minor Outlying Islands", - "uy": "Uruguay", - "uz": "Uzbekistan", - "vu": "Vanuatu", - "ve": "Venezuela", - "vn": "Viet Nam", - "vg": "Virgin Islands, British", - "vi": "Virgin Islands, U.S.", - "wf": "Wallis and Futuna", - "eh": "Western Sahara", - "ye": "Yemen", - "zm": "Zambia", - "zw": "Zimbabwe", -} - - -LANGUAGE_CODES = { - "ar": "Arabic", - "bn": "Bengali", - "da": "Danish", - "de": "German", - "el": "Greek", - "en": "English", - "es": "Spanish", - "fi": "Finnish", - "fr": "French", - "hi": "Hindi", - "hu": "Hungarian", - "id": "Indonesian", - "it": "Italian", - "ja": "Japanese", - "ko": "Korean", - "nl": "Dutch", - "ms": "Malay", - "no": "Norwegian", - "pcm": "Nigerian Pidgin", - "pl": "Polish", - "pt": "Portuguese", - "pt-br": "Portuguese (Brazil)", - "pt-pt": "Portuguese (Portugal)", - "ru": "Russian", - "sv": "Swedish", - "tl": "Filipino", - "tr": "Turkish", - "uk": "Ukrainian", - "zh": "Chinese", - "zh-cn": "Chinese (Simplified)", - "zh-tw": "Chinese (Traditional)", -} diff --git a/toolkits/youtube/arcade_youtube/tools/__init__.py b/toolkits/youtube/arcade_youtube/tools/__init__.py deleted file mode 100644 index d4ce19c4..00000000 --- a/toolkits/youtube/arcade_youtube/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arcade_youtube.tools.youtube import get_youtube_video_details, search_for_videos - -__all__ = ["get_youtube_video_details", "search_for_videos"] diff --git a/toolkits/youtube/arcade_youtube/tools/youtube.py b/toolkits/youtube/arcade_youtube/tools/youtube.py deleted file mode 100644 index 5f049790..00000000 --- a/toolkits/youtube/arcade_youtube/tools/youtube.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import Annotated, Any, cast - -from arcade_tdk import ToolContext, tool -from arcade_tdk.errors import ToolExecutionError - -from arcade_youtube.constants import DEFAULT_YOUTUBE_SEARCH_COUNTRY, DEFAULT_YOUTUBE_SEARCH_LANGUAGE -from arcade_youtube.utils import ( - call_serpapi, - default_country_code, - default_language_code, - extract_video_details, - extract_video_results, - prepare_params, - resolve_country_code, - resolve_language_code, -) - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def search_for_videos( - context: ToolContext, - keywords: Annotated[ - str, - "The keywords to search for. E.g. 'Python tutorial'.", - ], - language_code: Annotated[ - str | None, - "2-character language code to search for. E.g. 'en' for English. " - f"Defaults to '{default_language_code(DEFAULT_YOUTUBE_SEARCH_LANGUAGE)}'.", - ] = None, - country_code: Annotated[ - str | None, - "2-character country code to search for. E.g. 'us' for United States. " - f"Defaults to '{default_country_code(DEFAULT_YOUTUBE_SEARCH_COUNTRY)}'.", - ] = None, - next_page_token: Annotated[ - str | None, - "The next page token to use for pagination. " - "Defaults to `None` (start from the first page).", - ] = None, -) -> Annotated[dict[str, Any], "List of YouTube videos related to the query."]: - """Search for YouTube videos related to the query.""" - language_code = resolve_language_code(language_code, DEFAULT_YOUTUBE_SEARCH_LANGUAGE) - country_code = resolve_country_code(country_code, DEFAULT_YOUTUBE_SEARCH_COUNTRY) - - params = prepare_params( - "youtube", - search_query=keywords, - hl=language_code, - gl=country_code, - sp=next_page_token, - ) - results = call_serpapi(context, params) - - if results.get("error"): - error_msg = cast(str, results.get("error")) - raise ToolExecutionError(error_msg) - - return { - "videos": extract_video_results(results), - "next_page_token": results.get("serpapi_pagination", {}).get("next_page_token"), - } - - -@tool(requires_secrets=["SERP_API_KEY"]) -async def get_youtube_video_details( - context: ToolContext, - video_id: Annotated[ - str, - "The ID of the YouTube video to get details about. E.g. 'dQw4w9WgXcQ'.", - ], - language_code: Annotated[ - str | None, - "2-character language code to search for. E.g. 'en' for English. " - f"Defaults to '{default_language_code(DEFAULT_YOUTUBE_SEARCH_LANGUAGE)}'.", - ] = None, - country_code: Annotated[ - str | None, - "2-character country code to search for. E.g. 'us' for United States. " - f"Defaults to '{default_country_code(DEFAULT_YOUTUBE_SEARCH_COUNTRY)}'.", - ] = None, -) -> Annotated[dict[str, Any], "Details about a YouTube video."]: - """Get details about a YouTube video.""" - language_code = resolve_language_code(language_code, DEFAULT_YOUTUBE_SEARCH_LANGUAGE) - country_code = resolve_country_code(country_code, DEFAULT_YOUTUBE_SEARCH_COUNTRY) - - params = prepare_params( - "youtube_video", - v=video_id, - hl=language_code, - gl=country_code, - ) - results = call_serpapi(context, params) - - if results.get("error"): - error_msg = cast(str, results.get("error")) - raise ToolExecutionError(error_msg) - - return { - "video": extract_video_details(results), - } diff --git a/toolkits/youtube/arcade_youtube/utils.py b/toolkits/youtube/arcade_youtube/utils.py deleted file mode 100644 index a16c52cc..00000000 --- a/toolkits/youtube/arcade_youtube/utils.py +++ /dev/null @@ -1,169 +0,0 @@ -import re -from typing import Any, cast -from urllib.parse import parse_qs, urlparse - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from serpapi import Client as SerpClient - -from arcade_youtube.constants import ( - DEFAULT_GOOGLE_COUNTRY, - DEFAULT_GOOGLE_LANGUAGE, - YOUTUBE_MAX_DESCRIPTION_LENGTH, -) -from arcade_youtube.exceptions import CountryNotFoundError, LanguageNotFoundError -from arcade_youtube.google_data import COUNTRY_CODES, LANGUAGE_CODES - - -def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: - """ - Prepares a parameters dictionary for the SerpAPI call. - - Parameters: - engine: The engine name (e.g., "google", "google_finance"). - kwargs: Any additional parameters to include. - - Returns: - A dictionary containing the base parameters plus any extras, - excluding any parameters whose value is None. - """ - params = {"engine": engine} - params.update({k: v for k, v in kwargs.items() if v is not None}) - return params - - -def call_serpapi(context: ToolContext, params: dict) -> dict: - """ - Execute a search query using the SerpAPI client and return the results as a dictionary. - - Args: - context: The tool context containing required secrets. - params: A dictionary of parameters for the SerpAPI search. - - Returns: - The search results as a dictionary. - """ - api_key = context.get_secret("SERP_API_KEY") - client = SerpClient(api_key=api_key) - try: - search = client.search(params) - return cast(dict[str, Any], search.as_dict()) - except Exception as e: - # SerpAPI error messages sometimes contain the API key, so we need to sanitize it - sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) - raise ToolExecutionError( - message="Failed to fetch search results", - developer_message=sanitized_e, - ) - - -def default_language_code(default_service_language_code: str | None = None) -> str | None: - if isinstance(default_service_language_code, str): - return default_service_language_code.lower() - elif isinstance(DEFAULT_GOOGLE_LANGUAGE, str): - return DEFAULT_GOOGLE_LANGUAGE.lower() - return None - - -def default_country_code(default_service_country_code: str | None = None) -> str | None: - if isinstance(default_service_country_code, str): - return default_service_country_code.lower() - elif isinstance(DEFAULT_GOOGLE_COUNTRY, str): - return DEFAULT_GOOGLE_COUNTRY.lower() - return None - - -def resolve_language_code( - language_code: str | None = None, - default_service_language_code: str | None = None, -) -> str | None: - language_code = language_code or default_language_code(default_service_language_code) - - if isinstance(language_code, str): - language_code = language_code.lower() - if language_code not in LANGUAGE_CODES: - raise LanguageNotFoundError(language_code) - - return language_code - - -def resolve_country_code( - country_code: str | None = None, - default_service_country_code: str | None = None, -) -> str | None: - country_code = country_code or default_country_code(default_service_country_code) - - if isinstance(country_code, str): - country_code = country_code.lower() - if country_code not in COUNTRY_CODES: - raise CountryNotFoundError(country_code) - - return country_code - - -def extract_video_id_from_link(link: str | None) -> str | None: - if not isinstance(link, str): - return None - - parsed_url = urlparse(link) - query_params = parse_qs(parsed_url.query) - return query_params.get("v", [""])[0] - - -def extract_video_description( - video: dict[str, Any], - max_description_length: int = YOUTUBE_MAX_DESCRIPTION_LENGTH, -) -> str | None: - description = video.get("description", "") - - if isinstance(description, dict): - description = description.get("content", "") - - if isinstance(description, str): - too_long = len(description) > max_description_length - if too_long: - description = description[:max_description_length] + " [truncated]" - - if description is not None: - description = str(description).strip() - - return cast(str | None, description) - - -def extract_video_results( - results: dict[str, Any], - max_description_length: int = YOUTUBE_MAX_DESCRIPTION_LENGTH, -) -> list[dict[str, Any]]: - videos = [] - - for video in results.get("video_results", []): - videos.append({ - "id": extract_video_id_from_link(video.get("link")), - "title": video.get("title"), - "description": extract_video_description(video, max_description_length), - "link": video.get("link"), - "published_date": video.get("published_date"), - "duration": video.get("duration"), - "channel": { - "name": video.get("channel", {}).get("name"), - "link": video.get("channel", {}).get("link"), - }, - }) - - return videos - - -def extract_video_details(video: dict[str, Any]) -> dict[str, Any]: - return { - "id": extract_video_id_from_link(video.get("link")), - "title": video.get("title"), - "description": extract_video_description(video, YOUTUBE_MAX_DESCRIPTION_LENGTH), - "published_date": video.get("published_date"), - "channel": { - "name": video.get("channel", {}).get("name"), - "link": video.get("channel", {}).get("link"), - }, - "like_count": video.get("extracted_likes"), - "view_count": video.get("extracted_views"), - "live": video.get("live", False), - } diff --git a/toolkits/youtube/pyproject.toml b/toolkits/youtube/pyproject.toml deleted file mode 100644 index fb552c8d..00000000 --- a/toolkits/youtube/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_youtube" -version = "2.0.0" -description = "Arcade.dev LLM tools for searching for YouTube videos"" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "serpapi>=0.1.5,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_youtube/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_youtube",] diff --git a/toolkits/zendesk/.pre-commit-config.yaml b/toolkits/zendesk/.pre-commit-config.yaml deleted file mode 100644 index 6a345ba4..00000000 --- a/toolkits/zendesk/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/zendesk/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/zendesk/.ruff.toml b/toolkits/zendesk/.ruff.toml deleted file mode 100644 index 2315c4aa..00000000 --- a/toolkits/zendesk/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -ignore = ["C901"] - -[lint.per-file-ignores] -"**/tests/*" = ["S101"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/zendesk/Makefile b/toolkits/zendesk/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/zendesk/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/zendesk/arcade_zendesk/__init__.py b/toolkits/zendesk/arcade_zendesk/__init__.py deleted file mode 100644 index 373988de..00000000 --- a/toolkits/zendesk/arcade_zendesk/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from arcade_zendesk.tools import ( - add_ticket_comment, - get_ticket_comments, - list_tickets, - mark_ticket_solved, - search_articles, -) - -__all__ = [ - "list_tickets", - "add_ticket_comment", - "get_ticket_comments", - "mark_ticket_solved", - "search_articles", -] diff --git a/toolkits/zendesk/arcade_zendesk/enums.py b/toolkits/zendesk/arcade_zendesk/enums.py deleted file mode 100644 index f135bf44..00000000 --- a/toolkits/zendesk/arcade_zendesk/enums.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Enums for the Zendesk toolkit.""" - -from enum import Enum - - -class ArticleSortBy(Enum): - """Sort fields for article search results.""" - - CREATED_AT = "created_at" - RELEVANCE = "relevance" - - -class SortOrder(Enum): - """Sort order direction.""" - - ASC = "asc" - DESC = "desc" - - -class TicketStatus(Enum): - """Valid ticket statuses.""" - - NEW = "new" - OPEN = "open" - PENDING = "pending" - SOLVED = "solved" - CLOSED = "closed" diff --git a/toolkits/zendesk/arcade_zendesk/tools/__init__.py b/toolkits/zendesk/arcade_zendesk/tools/__init__.py deleted file mode 100644 index 99699a9b..00000000 --- a/toolkits/zendesk/arcade_zendesk/tools/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from arcade_zendesk.tools.search_articles import search_articles -from arcade_zendesk.tools.tickets import ( - add_ticket_comment, - get_ticket_comments, - list_tickets, - mark_ticket_solved, -) - -__all__ = [ - "list_tickets", - "add_ticket_comment", - "get_ticket_comments", - "mark_ticket_solved", - "search_articles", -] diff --git a/toolkits/zendesk/arcade_zendesk/tools/search_articles.py b/toolkits/zendesk/arcade_zendesk/tools/search_articles.py deleted file mode 100644 index b47115be..00000000 --- a/toolkits/zendesk/arcade_zendesk/tools/search_articles.py +++ /dev/null @@ -1,227 +0,0 @@ -import logging -from typing import Annotated, Any - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import OAuth2 -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_zendesk.enums import ArticleSortBy, SortOrder -from arcade_zendesk.utils import ( - fetch_paginated_results, - get_zendesk_subdomain, - process_search_results, - validate_date_format, -) - -logger = logging.getLogger(__name__) - - -@tool( - requires_auth=OAuth2(id="zendesk", scopes=["read"]), - requires_secrets=["ZENDESK_SUBDOMAIN"], -) -async def search_articles( - context: ToolContext, - query: Annotated[ - str | None, - "Search text to match against articles. Supports quoted expressions for exact matching", - ] = None, - label_names: Annotated[ - list[str] | None, - "List of label names to filter by (case-insensitive). Article must have at least " - "one matching label. Available on Professional/Enterprise plans only", - ] = None, - created_after: Annotated[ - str | None, - "Filter articles created after this date (format: YYYY-MM-DD)", - ] = None, - created_before: Annotated[ - str | None, - "Filter articles created before this date (format: YYYY-MM-DD)", - ] = None, - created_at: Annotated[ - str | None, - "Filter articles created on this exact date (format: YYYY-MM-DD)", - ] = None, - sort_by: Annotated[ - ArticleSortBy | None, - "Field to sort articles by. Defaults to relevance according to the search query", - ] = None, - sort_order: Annotated[ - SortOrder | None, - "Sort order direction. Defaults to descending", - ] = None, - limit: Annotated[ - int, - "Number of articles to return. Defaults to 30", - ] = 30, - offset: Annotated[ - int, - "Number of articles to skip before returning results. Defaults to 0", - ] = 0, - include_body: Annotated[ - bool, - "Include article body content in results. Bodies will be cleaned of HTML and truncated", - ] = True, - max_article_length: Annotated[ - int | None, - "Maximum length for article body content in characters. " - "Set to None for no limit. Defaults to 500", - ] = 500, -) -> Annotated[ - dict[str, Any], - "Article search results with pagination metadata. Includes 'next_offset' when more " - "results are available. Simply use this value as the 'offset' parameter in your next " - "call to fetch the next batch", -]: - """ - Search for Help Center articles in your Zendesk knowledge base. - - This tool searches specifically for published knowledge base articles that provide - solutions and guidance to users. At least one search parameter (query or label_names) - must be provided. - - PAGINATION: - - The response includes 'next_offset' when more results are available - - To fetch the next batch, simply pass the 'next_offset' value as the 'offset' parameter - - If 'next_offset' is not present, you've reached the end of available results - - The tool automatically handles fetching from the correct page based on your offset - - IMPORTANT: ALL FILTERS CAN BE COMBINED IN A SINGLE CALL - You can combine multiple filters (query, labels, dates) in one search request. - Do NOT make separate tool calls - combine all relevant filters together. - """ - - # Validate date parameters - date_params = { - "created_after": created_after, - "created_before": created_before, - "created_at": created_at, - } - - for param_name, param_value in date_params.items(): - if param_value and not validate_date_format(param_value): - raise RetryableToolError( - message=( - f"Invalid date format for {param_name}: '{param_value}'. " - "Please use YYYY-MM-DD format." - ), - developer_message=( - f"Date validation failed for parameter '{param_name}' " - f"with value '{param_value}'" - ), - retry_after_ms=500, - additional_prompt_content="Use format YYYY-MM-DD.", - ) - - # Validate limit and offset parameters - if limit < 1: - raise RetryableToolError( - message="limit must be at least 1.", - developer_message=f"Invalid limit value: {limit}", - retry_after_ms=100, - additional_prompt_content="Provide a positive limit value", - ) - - if offset < 0: - raise RetryableToolError( - message="offset cannot be negative.", - developer_message=f"Invalid offset value: {offset}", - retry_after_ms=100, - additional_prompt_content="Provide a non-negative offset value", - ) - - # Validate that at least one search parameter is provided - if not any([query, label_names]): - raise RetryableToolError( - message="At least one search parameter must be provided.", - developer_message="No search parameters were provided", - retry_after_ms=100, - additional_prompt_content=( - "Provide at least one of: query text or a list of label names" - ), - ) - - auth_token = context.get_auth_token_or_empty() - subdomain = get_zendesk_subdomain(context) - - url = f"https://{subdomain}.zendesk.com/api/v2/help_center/articles/search" - - # Base parameters for the search - base_params: dict[str, Any] = { - "per_page": 100, # Max allowed per page - } - - if query: - base_params["query"] = query - - if label_names: - base_params["label_names"] = ",".join(label_names) - - if created_after: - base_params["created_after"] = created_after - - if created_before: - base_params["created_before"] = created_before - - if created_at: - base_params["created_at"] = created_at - - if sort_by: - base_params["sort_by"] = sort_by.value - - if sort_order: - base_params["sort_order"] = sort_order.value - - async with httpx.AsyncClient() as client: - try: - headers = { - "Authorization": f"Bearer {auth_token}", - "Content-Type": "application/json", - "Accept": "application/json", - } - - data = await fetch_paginated_results( - client=client, - url=url, - headers=headers, - params=base_params, - offset=offset, - limit=limit, - ) - - if "results" in data: - data["results"] = process_search_results( - data["results"], include_body=include_body, max_body_length=max_article_length - ) - - logger.info(f"Article search returned {data.get('count', 0)} results") - - except httpx.HTTPStatusError as e: - logger.exception(f"HTTP error during article search: {e.response.status_code}") - raise ToolExecutionError( - message=f"Failed to search articles: HTTP {e.response.status_code}", - developer_message=( - f"HTTP {e.response.status_code} error: {e.response.text}. " - f"URL: {url}, base_params: {base_params}" - ), - ) from e - except httpx.TimeoutException as e: - logger.exception("Timeout during article search") - raise RetryableToolError( - message="Request timed out while searching articles.", - developer_message=f"Timeout occurred. URL: {url}, base_params: {base_params}", - retry_after_ms=5000, - ) from e - except Exception as e: - logger.exception("Unexpected error during article search") - raise ToolExecutionError( - message=f"Failed to search articles: {e!s}", - developer_message=( - f"Unexpected error: {type(e).__name__}: {e!s}. " - f"URL: {url}, base_params: {base_params}" - ), - ) from e - else: - return data diff --git a/toolkits/zendesk/arcade_zendesk/tools/tickets.py b/toolkits/zendesk/arcade_zendesk/tools/tickets.py deleted file mode 100644 index 814917f0..00000000 --- a/toolkits/zendesk/arcade_zendesk/tools/tickets.py +++ /dev/null @@ -1,414 +0,0 @@ -from typing import Annotated, Any - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import OAuth2 -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_zendesk.enums import SortOrder, TicketStatus -from arcade_zendesk.utils import fetch_paginated_results, get_zendesk_subdomain - - -def _handle_ticket_not_found(response: httpx.Response, ticket_id: int) -> None: - """Handle 404 responses for ticket operations.""" - if response.status_code == 404: - raise RetryableToolError( - message=f"Ticket #{ticket_id} not found.", - developer_message=f"Ticket with ID {ticket_id} does not exist", - retry_after_ms=500, - additional_prompt_content="Please verify the ticket ID and try again", - ) - - -@tool( - requires_auth=OAuth2(id="zendesk", scopes=["read"]), - requires_secrets=["ZENDESK_SUBDOMAIN"], -) -async def list_tickets( - context: ToolContext, - status: Annotated[ - TicketStatus, - "The status of tickets to filter by. Defaults to 'open'", - ] = TicketStatus.OPEN, - limit: Annotated[ - int, - "Number of tickets to return. Defaults to 30", - ] = 30, - offset: Annotated[ - int, - "Number of tickets to skip before returning results. Defaults to 0", - ] = 0, - sort_order: Annotated[ - SortOrder, - "Sort order for tickets by ID. 'asc' returns oldest first, 'desc' returns newest first. " - "Defaults to 'desc'", - ] = SortOrder.DESC, -) -> Annotated[ - dict[str, Any], - "A dictionary containing tickets list (each with html_url), count, and pagination metadata. " - "Includes 'next_offset' when more results are available", -]: - """List tickets from your Zendesk account with offset-based pagination. - - By default, returns tickets sorted by ID with newest tickets first (desc). - - Each ticket in the response includes an 'html_url' field with the direct link - to view the ticket in Zendesk. - - PAGINATION: - - The response includes 'next_offset' when more results are available - - To fetch the next batch, simply pass the 'next_offset' value as the 'offset' parameter - - If 'next_offset' is not present, you've reached the end of available results - """ - - # Validate limit and offset parameters - if limit < 1: - raise RetryableToolError( - message="limit must be at least 1.", - developer_message=f"Invalid limit value: {limit}", - retry_after_ms=100, - additional_prompt_content="Provide a positive limit value", - ) - - if offset < 0: - raise RetryableToolError( - message="offset cannot be negative.", - developer_message=f"Invalid offset value: {offset}", - retry_after_ms=100, - additional_prompt_content="Provide a non-negative offset value", - ) - - # Get the authorization token - token = context.get_auth_token_or_empty() - subdomain = get_zendesk_subdomain(context) - - # Build the API URL - url = f"https://{subdomain}.zendesk.com/api/v2/tickets.json" - - # Base parameters for the request - base_params: dict[str, Any] = { - "status": status.value, - "per_page": 100, # Max allowed per page - "sort_order": sort_order.value, - } - - # Make the API request - async with httpx.AsyncClient() as client: - try: - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - # Use the fetch_paginated_results utility - data = await fetch_paginated_results( - client=client, - url=url, - headers=headers, - params=base_params, - offset=offset, - limit=limit, - ) - - # Process tickets to add html_url and remove api url - tickets = data.get("results", []) - for ticket in tickets: - if "id" in ticket: - ticket["html_url"] = ( - f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}" - ) - # Remove API url to avoid confusion - if "url" in ticket: - del ticket["url"] - - # Build the result with consistent structure - result = { - "tickets": tickets, - "count": data.get("count", len(tickets)), - } - - # Add next_offset if present - if "next_offset" in data: - result["next_offset"] = data["next_offset"] - - except httpx.HTTPStatusError as e: - raise ToolExecutionError( - message=f"Failed to list tickets: HTTP {e.response.status_code}", - developer_message=( - f"HTTP {e.response.status_code} error: {e.response.text}. " - f"URL: {url}, params: {base_params}" - ), - ) from e - except httpx.TimeoutException as e: - raise RetryableToolError( - message="Request timed out while listing tickets.", - developer_message=f"Timeout occurred. URL: {url}, params: {base_params}", - retry_after_ms=5000, - additional_prompt_content="Try reducing limit or using more specific filters.", - ) from e - except Exception as e: - raise ToolExecutionError( - message=f"Failed to list tickets: {e!s}", - developer_message=( - f"Unexpected error: {type(e).__name__}: {e!s}. " - f"URL: {url}, params: {base_params}" - ), - ) from e - else: - return result - - -@tool( - requires_auth=OAuth2(id="zendesk", scopes=["read"]), - requires_secrets=["ZENDESK_SUBDOMAIN"], -) -async def get_ticket_comments( - context: ToolContext, - ticket_id: Annotated[int, "The ID of the ticket to get comments for"], -) -> Annotated[ - dict[str, Any], "A dictionary containing the ticket comments, metadata, and ticket URL" -]: - """Get all comments for a specific Zendesk ticket, including the original description. - - The first comment is always the ticket's original description/content. - Subsequent comments show the conversation history. - - Each comment includes: - - author_id: ID of the comment author - - body: The comment text - - created_at: Timestamp when comment was created - - public: Whether the comment is public or internal - - attachments: List of file attachments (if any) with file_name, content_url, size, etc. - """ - - # Get the authorization token - token = context.get_auth_token_or_empty() - subdomain = get_zendesk_subdomain(context) - - # Zendesk API endpoint for ticket comments - url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}/comments.json" - - # Make the API request - async with httpx.AsyncClient() as client: - try: - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - response = await client.get(url, headers=headers) - _handle_ticket_not_found(response, ticket_id) - response.raise_for_status() - - data = response.json() - comments = data.get("comments", []) - - return { - "ticket_id": ticket_id, - "comments": comments, - "count": len(comments), - } - - except RetryableToolError: - # Re-raise our custom errors - raise - except httpx.HTTPStatusError as e: - raise ToolExecutionError( - message=f"Failed to get ticket comments: HTTP {e.response.status_code}", - developer_message=( - f"HTTP {e.response.status_code} error: {e.response.text}. URL: {url}" - ), - ) from e - except httpx.TimeoutException as e: - raise RetryableToolError( - message="Request timed out while getting ticket comments.", - developer_message=f"Timeout occurred. URL: {url}", - retry_after_ms=5000, - additional_prompt_content="Try again in a few moments.", - ) from e - except Exception as e: - raise ToolExecutionError( - message=f"Failed to get ticket comments: {e!s}", - developer_message=f"Unexpected error: {type(e).__name__}: {e!s}. URL: {url}", - ) from e - - -@tool( - requires_auth=OAuth2(id="zendesk", scopes=["tickets:write"]), - requires_secrets=["ZENDESK_SUBDOMAIN"], -) -async def add_ticket_comment( - context: ToolContext, - ticket_id: Annotated[int, "The ID of the ticket to comment on"], - comment_body: Annotated[str, "The text of the comment"], - public: Annotated[ - bool, "Whether the comment is public (visible to requester) or internal. Defaults to True" - ] = True, -) -> Annotated[ - dict[str, Any], "A dictionary containing the result of the comment operation and ticket URL" -]: - """Add a comment to an existing Zendesk ticket. - - The returned ticket object includes an 'html_url' field with the direct link - to view the ticket in Zendesk. - """ - - # Get the authorization token - token = context.get_auth_token_or_empty() - subdomain = get_zendesk_subdomain(context) - - # Zendesk API endpoint for updating ticket - url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json" - - # Prepare the request body - request_body = {"ticket": {"comment": {"body": comment_body, "public": public}}} - - # Make the API request - async with httpx.AsyncClient() as client: - try: - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - response = await client.put(url, headers=headers, json=request_body) - _handle_ticket_not_found(response, ticket_id) - response.raise_for_status() - - data = response.json() - ticket = data.get("ticket", {}) - - # Add web interface URL if not present - if "id" in ticket and "html_url" not in ticket: - ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}" - # Remove API url to avoid confusion - if "url" in ticket: - del ticket["url"] - - except RetryableToolError: - # Re-raise our custom errors - raise - except httpx.HTTPStatusError as e: - raise ToolExecutionError( - message=f"Failed to add ticket comment: HTTP {e.response.status_code}", - developer_message=( - f"HTTP {e.response.status_code} error: {e.response.text}. " - f"URL: {url}, body: {request_body}" - ), - ) from e - except httpx.TimeoutException as e: - raise RetryableToolError( - message="Request timed out while adding ticket comment.", - developer_message=f"Timeout occurred. URL: {url}", - retry_after_ms=5000, - additional_prompt_content="Try again in a few moments.", - ) from e - except Exception as e: - raise ToolExecutionError( - message=f"Failed to add ticket comment: {e!s}", - developer_message=f"Unexpected error: {type(e).__name__}: {e!s}. URL: {url}", - ) from e - else: - return { - "success": True, - "ticket_id": ticket_id, - "comment_type": "public" if public else "internal", - "ticket": ticket, - } - - -@tool( - requires_auth=OAuth2(id="zendesk", scopes=["tickets:write"]), - requires_secrets=["ZENDESK_SUBDOMAIN"], -) -async def mark_ticket_solved( - context: ToolContext, - ticket_id: Annotated[int, "The ID of the ticket to mark as solved"], - comment_body: Annotated[ - str | None, - "Optional final comment to add when solving the ticket", - ] = None, - comment_public: Annotated[ - bool, "Whether the comment is visible to the requester. Defaults to False" - ] = False, -) -> Annotated[dict[str, Any], "A dictionary containing the result of the solve operation"]: - """Mark a Zendesk ticket as solved, optionally with a final comment. - - The returned ticket object includes an 'html_url' field with the direct link - to view the ticket in Zendesk. - """ - - # Get the authorization token - token = context.get_auth_token_or_empty() - subdomain = get_zendesk_subdomain(context) - - # Zendesk API endpoint for updating ticket - url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json" - - # Prepare the request body - request_body: dict[str, Any] = {"ticket": {"status": "solved"}} - - # Add resolution comment if provided - if comment_body: - request_body["ticket"]["comment"] = { - "body": comment_body, - "public": comment_public, - } - - # Make the API request - async with httpx.AsyncClient() as client: - try: - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - response = await client.put(url, headers=headers, json=request_body) - _handle_ticket_not_found(response, ticket_id) - response.raise_for_status() - - data = response.json() - ticket = data.get("ticket", {}) - - # Add web interface URL if not present - if "id" in ticket and "html_url" not in ticket: - ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}" - # Remove API url to avoid confusion - if "url" in ticket: - del ticket["url"] - - result = { - "success": True, - "ticket_id": ticket_id, - "status": "solved", - "ticket": ticket, - } - if comment_body: - result["comment_added"] = True - result["comment_type"] = "public" if comment_public else "internal" - - except RetryableToolError: - # Re-raise our custom errors - raise - except httpx.HTTPStatusError as e: - raise ToolExecutionError( - message=f"Failed to mark ticket as solved: HTTP {e.response.status_code}", - developer_message=( - f"HTTP {e.response.status_code} error: {e.response.text}. " - f"URL: {url}, body: {request_body}" - ), - ) from e - except httpx.TimeoutException as e: - raise RetryableToolError( - message="Request timed out while marking ticket as solved.", - developer_message=f"Timeout occurred. URL: {url}", - retry_after_ms=5000, - additional_prompt_content="Try again in a few moments.", - ) from e - except Exception as e: - raise ToolExecutionError( - message=f"Failed to mark ticket as solved: {e!s}", - developer_message=f"Unexpected error: {type(e).__name__}: {e!s}. URL: {url}", - ) from e - else: - return result diff --git a/toolkits/zendesk/arcade_zendesk/utils.py b/toolkits/zendesk/arcade_zendesk/utils.py deleted file mode 100644 index 9ac405a3..00000000 --- a/toolkits/zendesk/arcade_zendesk/utils.py +++ /dev/null @@ -1,216 +0,0 @@ -import logging -import re -from typing import Any - -import httpx -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError -from bs4 import BeautifulSoup - -logger = logging.getLogger(__name__) - -DEFAULT_MAX_BODY_LENGTH = 500 # Default max length for article body content - - -async def fetch_paginated_results( - client: httpx.AsyncClient, - url: str, - headers: dict[str, str], - params: dict[str, Any], - offset: int, - limit: int, -) -> dict[str, Any]: - """ - Fetch paginated results using offset and limit pattern. - - This function internally manages pagination to fulfill the requested offset and limit, - fetching multiple pages as needed. - - Args: - client: The HTTP client to use - url: The API endpoint URL - headers: Request headers including authorization - params: Base query parameters (without pagination params) - offset: Number of items to skip - limit: Number of items to return - - Returns: - Dict containing: - - results: List of fetched items - - count: Number of items returned - - next_offset: Present only if more results are available - """ - # Calculate pagination parameters - # Most Zendesk APIs use 1-based page numbering - items_per_page = params.get("per_page", 100) # Use per_page from params or default to 100 - start_page = (offset // items_per_page) + 1 - start_index = offset % items_per_page - - # Collect results across multiple pages if needed - all_results = [] - current_page = start_page - items_collected = 0 - has_more = False - last_page_had_more_items = False - - while items_collected < limit: - # Set the current page - page_params = params.copy() - page_params["page"] = current_page - - response = await client.get(url, headers=headers, params=page_params, timeout=30.0) - response.raise_for_status() - page_data = response.json() - - # Extract results from current page (handle both "results" and "tickets" keys) - page_results = page_data.get("results", page_data.get("tickets", [])) - - # If this is the first page, skip to the start index - if current_page == start_page: - page_results = page_results[start_index:] - - # Take only what we need to reach the limit - items_needed = limit - items_collected - results_to_add = page_results[:items_needed] - all_results.extend(results_to_add) - items_collected += len(results_to_add) - - # Check if we left items on this page - if len(page_results) > items_needed: - last_page_had_more_items = True - - # Check if there are more pages - has_more = page_data.get("next_page") is not None - - # Stop if we've collected enough or no more pages - if items_collected >= limit or not has_more: - break - - current_page += 1 - - # Build the response - result = { - "results": all_results, - "count": len(all_results), - } - - # Add next_offset if there might be more results - # This happens when: - # 1. We got exactly the limit requested AND (there are more pages OR we left items on the page) - # 2. We didn't get the full limit but there are more pages available - if (len(all_results) == limit and (has_more or last_page_had_more_items)) or ( - len(all_results) < limit and has_more - ): - result["next_offset"] = offset + len(all_results) - - return result - - -def clean_html_text(text: str | None) -> str: - """Remove HTML tags and clean up text.""" - if not text: - return "" - - soup = BeautifulSoup(text, "html.parser") - clean_text: str = soup.get_text(separator=" ") - - clean_text = re.sub(r"\n+", "\n", clean_text) - - clean_text = re.sub(r"\s+", " ", clean_text) - - clean_text = "\n".join(line.strip() for line in clean_text.split("\n")) - - return clean_text.strip() - - -def truncate_text( - text: str | None, max_length: int, suffix: str = " ... [truncated]" -) -> str | None: - """Truncate text to a maximum length with a suffix.""" - if not text or len(text) <= max_length: - return text - - truncate_at = max_length - len(suffix) - if truncate_at <= 0: - return suffix - - return text[:truncate_at] + suffix - - -def process_article_body(body: str | None, max_length: int | None = None) -> str | None: - """Process article body by cleaning HTML and optionally truncating.""" - if not body: - return None - - cleaned_text: str = clean_html_text(body) - - if max_length and len(cleaned_text) > max_length: - result = truncate_text(cleaned_text, max_length) - return result - - return cleaned_text - - -def process_search_results( - results: list[dict[str, Any]], - include_body: bool = False, - max_body_length: int | None = DEFAULT_MAX_BODY_LENGTH, -) -> list[dict[str, Any]]: - """Process search results to clean up data and restructure with content and metadata.""" - processed_results = [] - - for result in results: - body_content = result.get("body", "") - cleaned_content = None - - if include_body and body_content: - cleaned_content = process_article_body(body_content, max_body_length) - - processed_result: dict[str, Any] = {"content": cleaned_content, "metadata": {}} - - for key, value in result.items(): - if key != "body": - processed_result["metadata"][key] = value - - processed_results.append(processed_result) - - return processed_results - - -def validate_date_format(date_string: str) -> bool: - """Validate that a date string matches YYYY-MM-DD format and is a valid date.""" - from datetime import datetime - - try: - parsed_date = datetime.strptime(date_string, "%Y-%m-%d") - # Ensure the input matches the expected format exactly - return parsed_date.strftime("%Y-%m-%d") == date_string - except ValueError: - return False - - -def get_zendesk_subdomain(context: ToolContext) -> str: - """ - Get the Zendesk subdomain from secrets with proper error handling. - - Args: - context: The tool context containing secrets - - Returns: - The Zendesk subdomain - - Raises: - ToolExecutionError: If the subdomain secret is not configured - """ - try: - subdomain = context.get_secret("ZENDESK_SUBDOMAIN") - except ValueError: - raise ToolExecutionError( - message="Zendesk subdomain is not set.", - developer_message=( - "Zendesk subdomain is not set. Make sure to set the " - "'ZENDESK_SUBDOMAIN' secret in the Arcade Dashboard." - ), - ) from None - else: - return subdomain diff --git a/toolkits/zendesk/evals/eval_articles.py b/toolkits/zendesk/evals/eval_articles.py deleted file mode 100644 index c96d84d6..00000000 --- a/toolkits/zendesk/evals/eval_articles.py +++ /dev/null @@ -1,360 +0,0 @@ -from datetime import timedelta - -from arcade_evals import ( - DatetimeCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) -from arcade_evals.critic import BinaryCritic, SimilarityCritic -from arcade_tdk import ToolCatalog - -import arcade_zendesk -from arcade_zendesk.enums import ArticleSortBy, SortOrder -from arcade_zendesk.tools.search_articles import search_articles - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - - -catalog = ToolCatalog() -catalog.add_module(arcade_zendesk) - - -@tool_eval() -def zendesk_search_articles_eval_suite() -> EvalSuite: - suite = EvalSuite( - name="Zendesk Search Articles Evaluation", - system_message=( - "You are an AI assistant with access to Zendesk Search Articles tool. " - "Use it to help users search for knowledge base articles and documentation." - ), - catalog=catalog, - rubric=rubric, - ) - - # Basic search scenarios - suite.add_case( - name="Basic search with query only", - user_message="Find articles about password reset", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={ - "query": "password reset", - }, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=1.0), - ], - ) - - suite.add_case( - name="Search with specific result count", - user_message="Show me 25 articles about API documentation", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={"query": "API documentation", "limit": 25}, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - BinaryCritic(critic_field="limit", weight=0.3), - ], - ) - - # Date filtering scenarios - suite.add_case( - name="Search with created after date filter", - user_message="Find articles about security updates created after January 15, 2024", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={"query": "security updates", "created_after": "2024-01-15"}, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.6), - DatetimeCritic(critic_field="created_after", weight=0.4, tolerance=timedelta(days=1)), - ], - ) - - suite.add_case( - name="Search with date range filter", - user_message="Show me articles about new features created between January and June 2024", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={ - "query": "new features", - "created_after": "2024-01-01", - "created_before": "2024-06-30", - }, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.4), - DatetimeCritic(critic_field="created_after", weight=0.3, tolerance=timedelta(days=1)), - DatetimeCritic(critic_field="created_before", weight=0.3, tolerance=timedelta(days=1)), - ], - ) - - # Label filtering (Professional/Enterprise) - suite.add_case( - name="Search by labels only", - user_message="Show me articles tagged with windows and setup labels", - expected_tool_calls=[ - ExpectedToolCall(func=search_articles, args={"label_names": ["windows", "setup"]}) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="label_names", weight=1.0), - ], - ) - - suite.add_case( - name="Search with query and labels", - user_message="Find installation guides with labels: macos, quickstart", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={"query": "installation guide", "label_names": ["macos", "quickstart"]}, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.5), - SimilarityCritic(critic_field="label_names", weight=0.5), - ], - ) - - # Sorting scenarios - suite.add_case( - name="Search sorted by creation date ascending", - user_message="Find onboarding articles sorted by oldest first", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={ - "query": "onboarding", - "sort_by": ArticleSortBy.CREATED_AT, - "sort_order": SortOrder.ASC, - }, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.4), - BinaryCritic(critic_field="sort_by", weight=0.3), - BinaryCritic(critic_field="sort_order", weight=0.3), - ], - ) - - suite.add_case( - name="Search sorted by most recently created", - user_message="Show me troubleshooting guides sorted by latest creation", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={ - "query": "troubleshooting guide", - "sort_by": ArticleSortBy.CREATED_AT, - "sort_order": SortOrder.DESC, - }, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.4), - BinaryCritic(critic_field="sort_by", weight=0.3), - BinaryCritic(critic_field="sort_order", weight=0.3), - ], - ) - - # Pagination scenarios - suite.add_case( - name="Search with higher limit", - user_message="Show me 100 installation guides", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={"query": "installation guide", "limit": 100}, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - BinaryCritic(critic_field="limit", weight=0.3), - ], - ) - - suite.add_case( - name="Search with offset pagination", - user_message="Find API documentation, skip the first 50 results and show me the next 50", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={"query": "API documentation", "offset": 50, "limit": 50}, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.4), - BinaryCritic(critic_field="offset", weight=0.3), - BinaryCritic(critic_field="limit", weight=0.3), - ], - ) - - # Complex search scenarios - suite.add_case( - name="Complex search with multiple filters", - user_message="Find recent troubleshooting articles about login issues " - "created after March 31, 2024, sorted by newest first", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={ - "query": "login issues troubleshooting", - "created_after": "2024-03-31", - "sort_by": ArticleSortBy.CREATED_AT, - "sort_order": SortOrder.DESC, - }, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.4), - DatetimeCritic(critic_field="created_after", weight=0.3, tolerance=timedelta(days=1)), - BinaryCritic(critic_field="sort_by", weight=0.15), - BinaryCritic(critic_field="sort_order", weight=0.15), - ], - ) - - # Content control - suite.add_case( - name="Search without article body content", - user_message="List article titles about billing without the full content", - expected_tool_calls=[ - ExpectedToolCall(func=search_articles, args={"query": "billing", "include_body": False}) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.7), - BinaryCritic(critic_field="include_body", weight=0.3), - ], - ) - - # Edge cases - suite.add_case( - name="Search with exact phrase matching", - user_message='Find articles with the exact phrase "password reset procedure"', - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={ - "query": '"password reset procedure"', - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="query", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def zendesk_search_articles_pagination_eval_suite() -> EvalSuite: - """Separate suite for pagination scenarios with context.""" - suite = EvalSuite( - name="Zendesk Pagination Evaluation", - system_message=( - "You are an AI assistant with access to Zendesk Help Center tools. " - "Use them to help users search for knowledge base articles. " - "When users ask for more results, use appropriate pagination parameters." - ), - catalog=catalog, - rubric=rubric, - ) - - # Pagination with context - suite.add_case( - name="Initial search with pagination context", - user_message="I need to find all troubleshooting articles. " - "Start by showing me the first 20.", - expected_tool_calls=[ - ExpectedToolCall(func=search_articles, args={"query": "troubleshooting", "limit": 20}) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.6), - BinaryCritic(critic_field="limit", weight=0.4), - ], - ) - - suite.add_case( - name="Request for more results after initial search", - user_message="Show me the next 20 troubleshooting articles", - expected_tool_calls=[ - ExpectedToolCall( - func=search_articles, - args={"query": "troubleshooting", "offset": 20, "limit": 20}, - ) - ], - rubric=rubric, - critics=[ - SimilarityCritic(critic_field="query", weight=0.5), - BinaryCritic(critic_field="offset", weight=0.25), - BinaryCritic(critic_field="limit", weight=0.25), - ], - additional_messages=[ - { - "role": "user", - "content": "I need to find all troubleshooting articles. " - "Start by showing me the first 20.", - }, - { - "role": "assistant", - "content": "I'll search for troubleshooting articles and " - "show you the first 20 results.", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "search_articles", - "arguments": '{"query": "troubleshooting", "limit": 20}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"results": [{"content": "Troubleshooting guide 1", ' - '"metadata": {"id": 1, "title": "How to troubleshoot login issues"}}], ' - '"count": 20, "next_offset": 20}', - "tool_call_id": "call_1", - "name": "search_articles", - }, - { - "role": "assistant", - "content": "I found 20 troubleshooting articles, and there are more available. " - "The first one is 'How to troubleshoot login issues'. " - "Would you like to see more results?", - }, - ], - ) - - return suite diff --git a/toolkits/zendesk/evals/eval_tickets.py b/toolkits/zendesk/evals/eval_tickets.py deleted file mode 100644 index 050407e0..00000000 --- a/toolkits/zendesk/evals/eval_tickets.py +++ /dev/null @@ -1,631 +0,0 @@ -from arcade_evals import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) -from arcade_tdk import ToolCatalog - -import arcade_zendesk -from arcade_zendesk.enums import SortOrder, TicketStatus -from arcade_zendesk.tools.tickets import ( - add_ticket_comment, - get_ticket_comments, - list_tickets, - mark_ticket_solved, -) - -# Evaluation rubric -rubric = EvalRubric( - fail_threshold=0.85, - warn_threshold=0.95, -) - -catalog = ToolCatalog() -catalog.add_module(arcade_zendesk) - - -@tool_eval() -def zendesk_tickets_read_eval_suite() -> EvalSuite: - """Evaluation suite for ticket reading operations.""" - suite = EvalSuite( - name="Zendesk Tickets Read Operations", - system_message=( - "You are an AI assistant with access to Zendesk ticket tools. " - "Use them to help users view and manage support tickets." - ), - catalog=catalog, - rubric=rubric, - ) - - # Basic ticket listing - suite.add_case( - name="List all open tickets", - user_message="Show me all open tickets", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tickets, - args={}, - ) - ], - rubric=rubric, - critics=[], # No args to validate - ) - - suite.add_case( - name="List tickets with explicit status request", - user_message="Can you list the open support tickets?", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tickets, - args={}, - ) - ], - rubric=rubric, - critics=[], - ) - - suite.add_case( - name="Request for ticket overview", - user_message="I need to see what tickets are currently open", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tickets, - args={}, - ) - ], - rubric=rubric, - critics=[], - ) - - # Test pagination - suite.add_case( - name="List tickets with limit", - user_message="Show me the first 5 open tickets", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tickets, - args={"limit": 5}, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="limit", weight=1.0), - ], - ) - - # Test status filter - suite.add_case( - name="List tickets with specific status", - user_message="Show me all pending tickets", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tickets, - args={"status": TicketStatus.PENDING}, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="status", weight=1.0), - ], - ) - - # Test sort order - suite.add_case( - name="List tickets oldest first", - user_message="Show me tickets sorted from oldest to newest", - expected_tool_calls=[ - ExpectedToolCall( - func=list_tickets, - args={"sort_order": SortOrder.ASC}, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="sort_order", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def zendesk_get_ticket_comments_eval_suite() -> EvalSuite: - """Evaluation suite for getting ticket comments.""" - suite = EvalSuite( - name="Zendesk Get Ticket Comments", - system_message=( - "You are an AI assistant with access to Zendesk ticket tools. " - "Use them to help users view ticket comments and conversation history." - ), - catalog=catalog, - rubric=rubric, - ) - - # Get comments for a ticket - suite.add_case( - name="Get comments for specific ticket", - user_message="Show me the comments for ticket 123", - expected_tool_calls=[ - ExpectedToolCall( - func=get_ticket_comments, - args={"ticket_id": 123}, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=1.0), - ], - ) - - suite.add_case( - name="View ticket conversation", - user_message="Can you show me the conversation history for ticket #456?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_ticket_comments, - args={"ticket_id": 456}, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=1.0), - ], - ) - - suite.add_case( - name="Get ticket description", - user_message="What is the original description of ticket 789?", - expected_tool_calls=[ - ExpectedToolCall( - func=get_ticket_comments, - args={"ticket_id": 789}, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=1.0), - ], - ) - - return suite - - -@tool_eval() -def zendesk_ticket_comments_eval_suite() -> EvalSuite: - """Evaluation suite for ticket comment operations.""" - suite = EvalSuite( - name="Zendesk Ticket Comments", - system_message=( - "You are an AI assistant with access to Zendesk ticket tools. " - "Use them to help users add comments to support tickets." - ), - catalog=catalog, - rubric=rubric, - ) - - # Public comments - suite.add_case( - name="Add public comment to ticket", - user_message="Add a comment to ticket 123 saying 'We are investigating this issue'", - expected_tool_calls=[ - ExpectedToolCall( - func=add_ticket_comment, - args={ - "ticket_id": 123, - "comment_body": "We are investigating this issue", - "public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="public", weight=0.2), - ], - ) - - suite.add_case( - name="Add public comment without specifying visibility", - user_message="Please comment on ticket #456: " - "The issue has been escalated to our engineering team", - expected_tool_calls=[ - ExpectedToolCall( - func=add_ticket_comment, - args={ - "ticket_id": 456, - "comment_body": "The issue has been escalated to our engineering team", - "public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="public", weight=0.2), - ], - ) - - # Internal comments - suite.add_case( - name="Add internal comment to ticket", - user_message="Add an internal note to ticket 789: Customer is VIP, prioritize this issue", - expected_tool_calls=[ - ExpectedToolCall( - func=add_ticket_comment, - args={ - "ticket_id": 789, - "comment_body": "Customer is VIP, prioritize this issue", - "public": False, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="public", weight=0.2), - ], - ) - - suite.add_case( - name="Add private comment to ticket", - user_message="Add a private comment to ticket 321 for agents only: " - "Check with backend team about API limits", - expected_tool_calls=[ - ExpectedToolCall( - func=add_ticket_comment, - args={ - "ticket_id": 321, - "comment_body": "Check with backend team about API limits", - "public": False, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="public", weight=0.2), - ], - ) - - # Complex comment scenarios - suite.add_case( - name="Add detailed public update", - user_message="Update ticket 555 with: 'We've identified the root cause. " - "A fix will be deployed within 24 hours. We apologize for the inconvenience.'", - expected_tool_calls=[ - ExpectedToolCall( - func=add_ticket_comment, - args={ - "ticket_id": 555, - "comment_body": "We've identified the root cause. " - "A fix will be deployed within 24 hours. We apologize for the inconvenience.", - "public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.6), - BinaryCritic(critic_field="public", weight=0.1), - ], - ) - - return suite - - -@tool_eval() -def zendesk_ticket_resolution_eval_suite() -> EvalSuite: - """Evaluation suite for ticket resolution operations.""" - suite = EvalSuite( - name="Zendesk Ticket Resolution", - system_message=( - "You are an AI assistant with access to Zendesk ticket tools. " - "Use them to help users resolve support tickets." - "Consider that closing a ticket is the same as marking it as solved." - ), - catalog=catalog, - rubric=rubric, - ) - - # Simple resolution - suite.add_case( - name="Mark ticket as solved without comment", - user_message="Mark ticket 100 as solved", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 100, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=1.0), - ], - ) - - suite.add_case( - name="Close ticket", - user_message="Please close ticket #200", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 200, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=1.0), - ], - ) - - # Resolution with public comment - suite.add_case( - name="Solve ticket with public resolution comment", - user_message="Resolve ticket 300 with comment: " - "'Issue resolved by updating your account settings'", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 300, - "comment_body": "Issue resolved by updating your account settings", - "comment_public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="comment_public", weight=0.2), - ], - ) - - suite.add_case( - name="Close ticket with customer-facing message", - user_message="Close ticket 400 and tell the customer: " - "Your refund has been processed successfully", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 400, - "comment_body": "Your refund has been processed successfully", - "comment_public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="comment_public", weight=0.2), - ], - ) - - # Resolution with internal comment - suite.add_case( - name="Solve ticket with internal note", - user_message="Mark ticket 500 as solved with internal note: " - "'Resolved via backend database fix'", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 500, - "comment_body": "Resolved via backend database fix", - "comment_public": False, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="comment_public", weight=0.2), - ], - ) - - # Default internal comment behavior - suite.add_case( - name="Solve ticket with comment defaults to internal", - user_message="Mark ticket 550 as solved with comment: 'Fixed by applying patch #2345'", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 550, - "comment_body": "Fixed by applying patch #2345", - # comment_public should default to False if not specified - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.4), - SimilarityCritic(critic_field="comment_body", weight=0.6), - ], - ) - - suite.add_case( - name="Close ticket with private resolution details", - user_message="Close ticket 600 with a private note for agents: " - "'Customer account had duplicate entries, merged successfully'", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 600, - "comment_body": "Customer account had duplicate entries, merged successfully", - "comment_public": False, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="comment_public", weight=0.2), - ], - ) - - return suite - - -@tool_eval() -def zendesk_ticket_workflow_eval_suite() -> EvalSuite: - """Evaluation suite for ticket workflow scenarios with context.""" - suite = EvalSuite( - name="Zendesk Ticket Workflows", - system_message=( - "You are an AI assistant with access to Zendesk ticket tools. " - "Use them to help users manage support ticket workflows." - ), - catalog=catalog, - rubric=rubric, - ) - - # Workflow: View then comment - suite.add_case( - name="Comment on specific ticket after viewing", - user_message="Add a comment to the login issue ticket saying we're working on it", - expected_tool_calls=[ - ExpectedToolCall( - func=add_ticket_comment, - args={ - "ticket_id": 1, - "comment_body": "We're currently working on resolving your login issue.", - "public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="public", weight=0.2), - ], - additional_messages=[ - { - "role": "user", - "content": "Show me all open tickets", - }, - { - "role": "assistant", - "content": "I'll list all open tickets for you.", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "list_tickets", - "arguments": "{}", - }, - } - ], - }, - { - "role": "tool", - "content": '{"tickets": [{"id": 1, "subject": "Login issue", "status": "open", ' - '"html_url": "https://example.zendesk.com/agent/tickets/1"}, ' - '{"id": 2, "subject": "Password reset request", "status": "open", ' - '"html_url": "https://example.zendesk.com/agent/tickets/2"}], "count": 2}', - "tool_call_id": "call_1", - "name": "list_tickets", - }, - { - "role": "assistant", - "content": "I found 2 open tickets:\n" - "1. Ticket #1: Login issue\n2. Ticket #2: Password reset request", - }, - ], - ) - - # Workflow: Comment then resolve - suite.add_case( - name="Resolve ticket after adding solution", - user_message="Now mark that ticket as solved", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 789, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=1.0), - ], - additional_messages=[ - { - "role": "user", - "content": "Add a comment to ticket 789: " - "'Reset your password using the forgot password link on the login page'", - }, - { - "role": "assistant", - "content": "I'll add that comment to ticket 789.", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "add_ticket_comment", - "arguments": '{"ticket_id": 789, "comment_body": ' - '"Reset your password using the forgot password link on the login ' - 'page", "public": true}', - }, - } - ], - }, - { - "role": "tool", - "content": '{"success": true, "ticket_id": 789, "comment_type": "public", ' - '"ticket": {"id": 789, "html_url": "https://example.zendesk.com/agent/tickets/789"}}', - "tool_call_id": "call_1", - "name": "add_ticket_comment", - }, - { - "role": "assistant", - "content": "I've added the comment with password reset instructions " - "to ticket #789.", - }, - ], - ) - - # Workflow: Multiple updates - suite.add_case( - name="Add final comment and close ticket", - user_message="Add 'This issue has been fully resolved' and close ticket 999", - expected_tool_calls=[ - ExpectedToolCall( - func=mark_ticket_solved, - args={ - "ticket_id": 999, - "comment_body": "This issue has been fully resolved", - "comment_public": True, - }, - ) - ], - rubric=rubric, - critics=[ - BinaryCritic(critic_field="ticket_id", weight=0.3), - SimilarityCritic(critic_field="comment_body", weight=0.5), - BinaryCritic(critic_field="comment_public", weight=0.2), - ], - ) - - return suite diff --git a/toolkits/zendesk/pyproject.toml b/toolkits/zendesk/pyproject.toml deleted file mode 100644 index e5162bde..00000000 --- a/toolkits/zendesk/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_zendesk" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.25.0,<1.0.0", - "beautifulsoup4>=4.0.0,<5" -] - - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - - -[tool.mypy] -files = [ "arcade_zendesk/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.ruff.lint] -ignore = ["C901"] - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_zendesk",] diff --git a/toolkits/zendesk/tests/__init__.py b/toolkits/zendesk/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/zendesk/tests/conftest.py b/toolkits/zendesk/tests/conftest.py deleted file mode 100644 index ad98f5cf..00000000 --- a/toolkits/zendesk/tests/conftest.py +++ /dev/null @@ -1,84 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest -from arcade_tdk import ToolContext - - -@pytest.fixture -def mock_context(): - """Standard mock context fixture used across all arcade toolkits.""" - context = MagicMock(spec=ToolContext) - - context.get_auth_token_or_empty = MagicMock(return_value="fake-token") - context.get_secret = MagicMock() - - return context - - -@pytest.fixture -def mock_httpx_client(mocker): - """Mock httpx.AsyncClient for API calls.""" - mock_client_class = mocker.patch("httpx.AsyncClient", autospec=True) - mock_client = AsyncMock() - mock_client_class.return_value.__aenter__.return_value = mock_client - return mock_client - - -@pytest.fixture -def sample_article_response(): - """Sample article data for testing.""" - return { - "id": 123456, - "title": "How to reset your password", - "body": "

To reset your password, follow these steps:

" - "
  1. Click forgot password
  2. Enter your email
", - "url": "https://support.example.com/hc/en-us/articles/123456", - "created_at": "2024-01-15T10:00:00Z", - "updated_at": "2024-06-01T15:30:00Z", - "section_id": 789, - "category_id": 456, - "label_names": ["password", "security", "account"], - } - - -@pytest.fixture -def build_search_response(sample_article_response): - """Builder for search API responses.""" - - def builder(articles=None, next_page=None, count=None): - if articles is None: - articles = [sample_article_response] - - response = { - "results": articles, - "next_page": next_page, - "page": 1, - "per_page": len(articles), - "page_count": 1, - } - - if count is not None: - response["count"] = count - - return response - - return builder - - -@pytest.fixture -def mock_http_response(): - """Factory for creating mock HTTP responses.""" - - def create_response(json_data=None, status_code=200, raise_for_status=True): - response = MagicMock() - response.json.return_value = json_data - response.status_code = status_code - - if raise_for_status and status_code >= 400: - response.raise_for_status.side_effect = Exception(f"HTTP {status_code}") - else: - response.raise_for_status.return_value = None - - return response - - return create_response diff --git a/toolkits/zendesk/tests/test_search_articles.py b/toolkits/zendesk/tests/test_search_articles.py deleted file mode 100644 index bb357cd2..00000000 --- a/toolkits/zendesk/tests/test_search_articles.py +++ /dev/null @@ -1,423 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_tdk.errors import RetryableToolError, ToolExecutionError - -from arcade_zendesk.enums import ArticleSortBy, SortOrder -from arcade_zendesk.tools.search_articles import search_articles - - -class TestSearchArticlesValidation: - """Test input validation for search_articles.""" - - @pytest.mark.asyncio - async def test_missing_subdomain(self, mock_context): - """Test error when subdomain is not configured.""" - mock_context.get_secret.side_effect = ValueError("Secret not found") - - with pytest.raises(ToolExecutionError) as exc_info: - await search_articles(context=mock_context, query="test") - - assert "subdomain is not set" in str(exc_info.value.message) - - @pytest.mark.asyncio - async def test_missing_search_params(self, mock_context): - """Test error when no search parameters provided.""" - mock_context.get_secret.return_value = "test-subdomain" - - with pytest.raises(RetryableToolError) as exc_info: - await search_articles(context=mock_context) - - assert "At least one search parameter" in str(exc_info.value.message) - - @pytest.mark.parametrize( - "date_param,date_value", - [ - ("created_after", "2024/01/01"), - ("created_before", "01-15-2024"), - ("created_at", "2024-1-15"), - ("created_after", "2024-01-1"), - ("created_before", "20240115"), - ("created_at", "not-a-date"), - ], - ) - @pytest.mark.asyncio - async def test_invalid_date_format(self, mock_context, date_param, date_value): - """Test validation of date format parameters.""" - mock_context.get_secret.return_value = "test-subdomain" - - with pytest.raises(RetryableToolError) as exc_info: - await search_articles(context=mock_context, query="test", **{date_param: date_value}) - - assert "Invalid date format" in str(exc_info.value.message) - assert "YYYY-MM-DD" in str(exc_info.value.message) - assert date_param in str(exc_info.value.message) - - @pytest.mark.parametrize("limit", [0, -1, -10]) - @pytest.mark.asyncio - async def test_invalid_limit(self, mock_context, limit): - """Test validation of limit parameter.""" - mock_context.get_secret.return_value = "test-subdomain" - - with pytest.raises(RetryableToolError) as exc_info: - await search_articles(context=mock_context, query="test", limit=limit) - - assert "at least 1" in str(exc_info.value.message) - - @pytest.mark.parametrize("offset", [-1, -10]) - @pytest.mark.asyncio - async def test_invalid_offset(self, mock_context, offset): - """Test validation of offset parameter.""" - mock_context.get_secret.return_value = "test-subdomain" - - with pytest.raises(RetryableToolError) as exc_info: - await search_articles(context=mock_context, query="test", offset=offset) - - assert "cannot be negative" in str(exc_info.value.message) - - -class TestSearchArticlesSuccess: - """Test successful search scenarios.""" - - @pytest.mark.asyncio - async def test_basic_search( - self, mock_context, mock_httpx_client, build_search_response, mock_http_response - ): - """Test basic search with query parameter.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Setup mock response - search_response = build_search_response() - mock_httpx_client.get.return_value = mock_http_response(search_response) - - result = await search_articles(context=mock_context, query="password reset") - - assert "results" in result - assert len(result["results"]) == 1 - assert result["results"][0]["metadata"]["title"] == "How to reset your password" - - mock_httpx_client.get.assert_called_once() - call_args = mock_httpx_client.get.call_args - assert ( - call_args[0][0] - == "https://test-subdomain.zendesk.com/api/v2/help_center/articles/search" - ) - assert call_args[1]["params"]["query"] == "password reset" - # Check that pagination params were set correctly - assert call_args[1]["params"]["page"] == 1 - assert call_args[1]["params"]["per_page"] == 100 - - @pytest.mark.asyncio - async def test_search_with_filters( - self, mock_context, mock_httpx_client, build_search_response, mock_http_response - ): - """Test search with multiple filter parameters.""" - mock_context.get_secret.return_value = "test-subdomain" - - search_response = build_search_response() - mock_httpx_client.get.return_value = mock_http_response(search_response) - - result = await search_articles( - context=mock_context, - query="API", - created_after="2024-01-01", - sort_by=ArticleSortBy.CREATED_AT, - sort_order=SortOrder.DESC, - limit=25, - ) - - assert "results" in result - - # Verify all parameters were passed - call_params = mock_httpx_client.get.call_args[1]["params"] - assert call_params["query"] == "API" - assert call_params["created_after"] == "2024-01-01" - assert call_params["sort_by"] == "created_at" - assert call_params["sort_order"] == "desc" - # Should fetch first page with 100 items per page - assert call_params["page"] == 1 - assert call_params["per_page"] == 100 - - @pytest.mark.asyncio - async def test_search_without_body( - self, - mock_context, - mock_httpx_client, - sample_article_response, - mock_http_response, - ): - """Test search with include_body=False.""" - mock_context.get_secret.return_value = "test-subdomain" - - search_response = {"results": [sample_article_response], "next_page": None} - mock_httpx_client.get.return_value = mock_http_response(search_response) - - result = await search_articles(context=mock_context, query="test", include_body=False) - - assert result["results"][0]["content"] is None - assert result["results"][0]["metadata"]["title"] == sample_article_response["title"] - - @pytest.mark.asyncio - async def test_search_by_labels( - self, mock_context, mock_httpx_client, build_search_response, mock_http_response - ): - """Test search by label names.""" - mock_context.get_secret.return_value = "test-subdomain" - - search_response = build_search_response() - mock_httpx_client.get.return_value = mock_http_response(search_response) - - result = await search_articles(context=mock_context, label_names=["password", "security"]) - - assert "results" in result - assert mock_httpx_client.get.call_args[1]["params"]["label_names"] == "password,security" - - -class TestSearchArticlesPagination: - """Test pagination scenarios.""" - - @pytest.mark.asyncio - async def test_single_page_default( - self, mock_context, mock_httpx_client, build_search_response, mock_http_response - ): - """Test default behavior returns single page.""" - mock_context.get_secret.return_value = "test-subdomain" - - search_response = build_search_response(count=100) - mock_httpx_client.get.return_value = mock_http_response(search_response) - - result = await search_articles(context=mock_context, query="test") - - assert len(result["results"]) == 1 - assert mock_httpx_client.get.call_count == 1 - - @pytest.mark.asyncio - async def test_fetch_with_limit_across_pages( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test fetching results across multiple pages with limit.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Setup pagination responses - 100 items per page - articles_page1 = [ - {"id": i, "title": f"Article {i}", "body": f"Content {i}"} for i in range(1, 101) - ] - articles_page2 = [ - {"id": i, "title": f"Article {i}", "body": f"Content {i}"} for i in range(101, 201) - ] - - page1 = {"results": articles_page1, "next_page": "page2"} - page2 = {"results": articles_page2, "next_page": "page3"} - - mock_httpx_client.get.side_effect = [ - mock_http_response(page1), - mock_http_response(page2), - ] - - # Request 150 items starting from offset 0 - result = await search_articles(context=mock_context, query="test", limit=150) - - assert result["count"] == 150 - assert "next_offset" in result # More results available - assert result["next_offset"] == 150 - assert mock_httpx_client.get.call_count == 2 # Fetched 2 pages - - @pytest.mark.asyncio - async def test_fetch_with_offset(self, mock_context, mock_httpx_client, mock_http_response): - """Test fetching with offset parameter.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Setup response - page 2 would have items 101-200 - # We want items starting from offset 150 (which is item 151, at index 50 on page 2) - articles_page2 = [ - {"id": i, "title": f"Article {i}", "body": f"Content {i}"} for i in range(101, 201) - ] - response = {"results": articles_page2, "next_page": "page3"} - - mock_httpx_client.get.return_value = mock_http_response(response) - - # Request 30 items starting from offset 150 - result = await search_articles(context=mock_context, query="test", offset=150, limit=30) - - assert result["count"] == 30 - assert "next_offset" in result - assert result["next_offset"] == 180 - - # Should request page 2 (offset 150 = page 2, starting at index 50) - call_params = mock_httpx_client.get.call_args[1]["params"] - assert call_params["page"] == 2 - - @pytest.mark.asyncio - async def test_no_next_offset_when_no_more_results( - self, mock_context, mock_httpx_client, build_search_response, mock_http_response - ): - """Test that next_offset is not included when no more results.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Setup response with no next page - articles = [ - {"id": i, "title": f"Article {i}", "body": f"Content {i}"} for i in range(1, 21) - ] - response = {"results": articles, "next_page": None} - - mock_httpx_client.get.return_value = mock_http_response(response) - - result = await search_articles(context=mock_context, query="test", limit=20) - - assert result["count"] == 20 - assert "next_offset" not in result # No more results - - @pytest.mark.asyncio - async def test_partial_page_with_more_items( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test that next_offset is included when there are more items on the current page.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Setup response with 50 items on a page, but we only request 30 - articles = [ - {"id": i, "title": f"Article {i}", "body": f"Content {i}"} for i in range(1, 51) - ] - response = {"results": articles, "next_page": None} - - mock_httpx_client.get.return_value = mock_http_response(response) - - # Request only 30 items when page has 50 - result = await search_articles(context=mock_context, query="test", limit=30) - - assert result["count"] == 30 - assert "next_offset" in result # More items available on current page - assert result["next_offset"] == 30 - - @pytest.mark.asyncio - async def test_request_more_than_available( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test when requesting more items than are available returns only what's available.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Setup response with only 15 items total - articles = [ - {"id": i, "title": f"Article {i}", "body": f"Content {i}"} for i in range(1, 16) - ] - response = {"results": articles, "next_page": None} - - mock_httpx_client.get.return_value = mock_http_response(response) - - # Request 30 items when only 15 are available - result = await search_articles(context=mock_context, query="test", limit=30) - - assert result["count"] == 15 # Only returns what's available - assert "next_offset" not in result # No more results - - -class TestSearchArticlesErrors: - """Test error handling scenarios.""" - - @pytest.mark.parametrize( - "status_code,error_key", - [ - (400, "HTTP 400"), - (401, "HTTP 401"), - (403, "HTTP 403"), - (404, "HTTP 404"), - (500, "HTTP 500"), - ], - ) - @pytest.mark.asyncio - async def test_http_errors(self, mock_context, mock_httpx_client, status_code, error_key): - """Test handling of HTTP errors.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Create mock error response - error_response = MagicMock() - error_response.status_code = status_code - error_response.text = f"Error message for {status_code}" - error_response.raise_for_status.side_effect = httpx.HTTPStatusError( - message=f"HTTP {status_code}", request=MagicMock(), response=error_response - ) - - mock_httpx_client.get.return_value = error_response - - with pytest.raises(ToolExecutionError) as exc_info: - await search_articles(context=mock_context, query="test") - - assert "Failed to search articles" in str(exc_info.value.message) - assert f"HTTP {status_code}" in str(exc_info.value.message) - - @pytest.mark.asyncio - async def test_timeout_error(self, mock_context, mock_httpx_client): - """Test handling of timeout errors.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.get.side_effect = httpx.TimeoutException("Request timed out") - - with pytest.raises(RetryableToolError) as exc_info: - await search_articles(context=mock_context, query="test") - - assert "timed out" in str(exc_info.value.message) - assert exc_info.value.retry_after_ms == 5000 - - @pytest.mark.asyncio - async def test_unexpected_error(self, mock_context, mock_httpx_client): - """Test handling of unexpected errors.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.get.side_effect = Exception("Unexpected error occurred") - - with pytest.raises(ToolExecutionError) as exc_info: - await search_articles(context=mock_context, query="test") - - assert "Unexpected error occurred" in str(exc_info.value.message) - - -class TestSearchArticlesContentProcessing: - """Test content processing and formatting.""" - - @pytest.mark.asyncio - async def test_html_cleaning(self, mock_context, mock_httpx_client, mock_http_response): - """Test HTML content is properly cleaned.""" - mock_context.get_secret.return_value = "test-subdomain" - - article_with_html = { - "id": 1, - "title": "Test Article", - "body": "

Header

Paragraph with bold and " - "italic.


Div content
", - "url": "https://example.com/article/1", - } - - search_response = {"results": [article_with_html], "next_page": None} - mock_httpx_client.get.return_value = mock_http_response(search_response) - - result = await search_articles(context=mock_context, query="test", include_body=True) - - content = result["results"][0]["content"] - assert content == "Header Paragraph with bold and italic . Div content" - - @pytest.mark.asyncio - async def test_max_article_length(self, mock_context, mock_httpx_client, mock_http_response): - """Test article length limiting.""" - mock_context.get_secret.return_value = "test-subdomain" - - long_article = { - "id": 1, - "title": "Long Article", - "body": "A" * 1000, # 1000 character body - } - - search_response = {"results": [long_article], "next_page": None} - mock_httpx_client.get.return_value = mock_http_response(search_response) - - # Test with default 500 char limit - result = await search_articles(context=mock_context, query="test") - assert len(result["results"][0]["content"]) < 520 # 500 + truncation suffix - - # Test with custom limit - result = await search_articles(context=mock_context, query="test", max_article_length=100) - assert len(result["results"][0]["content"]) < 120 # 100 + truncation suffix - - # Test with no limit - result = await search_articles(context=mock_context, query="test", max_article_length=None) - assert len(result["results"][0]["content"]) == 1000 diff --git a/toolkits/zendesk/tests/test_tickets.py b/toolkits/zendesk/tests/test_tickets.py deleted file mode 100644 index 1f10174d..00000000 --- a/toolkits/zendesk/tests/test_tickets.py +++ /dev/null @@ -1,526 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_core.errors import ToolExecutionError - -from arcade_zendesk.enums import SortOrder, TicketStatus -from arcade_zendesk.tools.tickets import ( - add_ticket_comment, - get_ticket_comments, - list_tickets, - mark_ticket_solved, -) - - -class TestListTickets: - """Test list_tickets functionality.""" - - @pytest.mark.asyncio - async def test_list_tickets_success(self, mock_context, mock_httpx_client, mock_http_response): - """Test successful listing of open tickets.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Mock response data - includes url field that should be removed - tickets_response = { - "tickets": [ - { - "id": 1, - "subject": "Login issue", - "status": "open", - "url": "https://test-subdomain.zendesk.com/api/v2/tickets/1.json", - }, - { - "id": 2, - "subject": "Password reset request", - "status": "open", - "url": "https://test-subdomain.zendesk.com/api/v2/tickets/2.json", - }, - ] - } - - mock_httpx_client.get.return_value = mock_http_response(tickets_response) - - result = await list_tickets(mock_context, status=TicketStatus.OPEN) - - # Verify the result is structured data - assert isinstance(result, dict) - assert "tickets" in result - assert "count" in result - assert result["count"] == 2 - - # Verify tickets have html_url but not url - for ticket in result["tickets"]: - assert "url" not in ticket - assert "html_url" in ticket - assert ticket["html_url"].startswith( - "https://test-subdomain.zendesk.com/agent/tickets/" - ) - - # Verify the API call with default parameters - mock_httpx_client.get.assert_called() - # The fetch_paginated_results makes the actual call - call_args = mock_httpx_client.get.call_args - assert "https://test-subdomain.zendesk.com/api/v2/tickets.json" in call_args[0][0] - assert call_args[1]["params"]["status"] == "open" - assert call_args[1]["params"]["per_page"] == 100 - assert call_args[1]["params"]["sort_order"] == "desc" - assert call_args[1]["params"]["page"] == 1 # First page - - @pytest.mark.asyncio - async def test_list_tickets_with_offset_limit( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test listing tickets with offset and limit.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Mock response for page 2 (offset 10, limit 5) - tickets_response = { - "tickets": [ - {"id": 11, "subject": "Test 11", "status": "open"}, - {"id": 12, "subject": "Test 12", "status": "open"}, - {"id": 13, "subject": "Test 13", "status": "open"}, - {"id": 14, "subject": "Test 14", "status": "open"}, - {"id": 15, "subject": "Test 15", "status": "open"}, - ], - "next_page": "https://test.zendesk.com/api/v2/tickets.json?page=3", - } - - mock_httpx_client.get.return_value = mock_http_response(tickets_response) - - result = await list_tickets( - mock_context, status=TicketStatus.OPEN, limit=5, offset=10, sort_order=SortOrder.ASC - ) - - # Verify response structure - assert result["count"] == 5 - assert len(result["tickets"]) == 5 - assert "next_offset" in result - assert result["next_offset"] == 15 # offset + limit - - # Verify API call parameters - call_args = mock_httpx_client.get.call_args - assert ( - call_args[1]["params"]["page"] == 2 - ) # offset 10 / per_page 100 = page 2 (but adjusted for limit) - assert call_args[1]["params"]["per_page"] == 100 - assert call_args[1]["params"]["sort_order"] == "asc" - - @pytest.mark.asyncio - async def test_list_tickets_no_more_results( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test listing tickets when no more results are available.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Mock response with no next_page - simulating the last page - tickets_response = { - "tickets": [{"id": 21, "subject": "Test", "status": "pending"}], - # No next_page means no more results - } - - mock_httpx_client.get.return_value = mock_http_response(tickets_response) - - result = await list_tickets( - mock_context, - status=TicketStatus.PENDING, - limit=10, - offset=0, # Start from beginning - ) - - # Verify no next_offset when no more results - assert "next_offset" not in result - assert result["count"] == 1 - - # Verify API call parameters - call_args = mock_httpx_client.get.call_args - assert call_args[1]["params"]["status"] == "pending" - assert call_args[1]["params"]["page"] == 1 - - @pytest.mark.asyncio - async def test_list_tickets_no_tickets( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test when no tickets are found.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.get.return_value = mock_http_response({"tickets": []}) - - result = await list_tickets(mock_context, status=TicketStatus.OPEN) - - assert result["tickets"] == [] - assert result["count"] == 0 - - @pytest.mark.asyncio - async def test_list_tickets_error(self, mock_context, mock_httpx_client): - """Test error handling for failed API call.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Mock error response that raise_for_status will catch - error_response = MagicMock() - error_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Unauthorized", request=MagicMock(), response=MagicMock(status_code=401) - ) - mock_httpx_client.get.return_value = error_response - - with pytest.raises(ToolExecutionError): - await list_tickets(mock_context) - - @pytest.mark.asyncio - async def test_list_tickets_no_subdomain(self, mock_context): - """Test when subdomain is not configured.""" - mock_context.get_secret.return_value = None - - with pytest.raises(ToolExecutionError): - await list_tickets(mock_context) - - -class TestGetTicketComments: - """Test get_ticket_comments functionality.""" - - @pytest.mark.asyncio - async def test_get_ticket_comments_success( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test successfully getting ticket comments.""" - mock_context.get_secret.return_value = "test-subdomain" - - # Mock response data - comments_response = { - "comments": [ - { - "id": 1, - "body": "I cannot access my account. Please help!", - "author_id": 12345, - "created_at": "2024-01-15T10:00:00Z", - "public": True, - "attachments": [], - }, - { - "id": 2, - "body": "I'll help you reset your password.", - "author_id": 67890, - "created_at": "2024-01-15T10:30:00Z", - "public": True, - "attachments": [ - { - "file_name": "screenshot.png", - "content_url": "https://example.com/screenshot.png", - "size": 12345, - } - ], - }, - ] - } - - mock_httpx_client.get.return_value = mock_http_response(comments_response) - - result = await get_ticket_comments(mock_context, ticket_id=123) - - # Verify the result is structured data - assert isinstance(result, dict) - assert result["ticket_id"] == 123 - assert result["count"] == 2 - assert len(result["comments"]) == 2 - - # Verify attachments are included - assert result["comments"][1]["attachments"][0]["file_name"] == "screenshot.png" - - # Verify the API call - mock_httpx_client.get.assert_called_once_with( - "https://test-subdomain.zendesk.com/api/v2/tickets/123/comments.json", - headers={ - "Authorization": "Bearer fake-token", - "Content-Type": "application/json", - }, - ) - - @pytest.mark.asyncio - async def test_get_ticket_comments_no_comments( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test when no comments are found.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.get.return_value = mock_http_response({"comments": []}) - - result = await get_ticket_comments(mock_context, ticket_id=123) - - assert result["comments"] == [] - assert result["count"] == 0 - - @pytest.mark.asyncio - async def test_get_ticket_comments_not_found( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test when ticket is not found.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.get.return_value = mock_http_response({}, status_code=404) - - with pytest.raises(ToolExecutionError): - await get_ticket_comments(mock_context, ticket_id=999) - - @pytest.mark.asyncio - async def test_get_ticket_comments_error(self, mock_context, mock_httpx_client): - """Test error handling when API fails.""" - mock_context.get_secret.return_value = "test-subdomain" - - error_response = MagicMock() - error_response.status_code = 500 - error_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Server Error", request=MagicMock(), response=MagicMock(status_code=500) - ) - - mock_httpx_client.get.return_value = error_response - - with pytest.raises(ToolExecutionError): - await get_ticket_comments(mock_context, ticket_id=123) - - -class TestAddTicketComment: - """Test add_ticket_comment functionality.""" - - @pytest.mark.asyncio - async def test_add_public_comment_success( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test successfully adding a public comment.""" - mock_context.get_secret.return_value = "test-subdomain" - - ticket_response = { - "ticket": { - "id": 123, - "subject": "Test ticket", - "url": "https://test-subdomain.zendesk.com/api/v2/tickets/123.json", - } - } - - mock_httpx_client.put.return_value = mock_http_response(ticket_response) - - result = await add_ticket_comment( - mock_context, - ticket_id=123, - comment_body="This is a test comment", - public=True, - ) - - # Verify structured response - assert isinstance(result, dict) - assert result["success"] is True - assert result["ticket_id"] == 123 - assert result["comment_type"] == "public" - assert "ticket" in result - - # Verify ticket has html_url but not url - assert "url" not in result["ticket"] - assert "html_url" in result["ticket"] - - # Verify the API call - mock_httpx_client.put.assert_called_once_with( - "https://test-subdomain.zendesk.com/api/v2/tickets/123.json", - headers={ - "Authorization": "Bearer fake-token", - "Content-Type": "application/json", - }, - json={"ticket": {"comment": {"body": "This is a test comment", "public": True}}}, - ) - - @pytest.mark.asyncio - async def test_add_comment_default_public( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test that comment defaults to public when not specified.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 123}}) - - result = await add_ticket_comment( - mock_context, - ticket_id=123, - comment_body="Test comment", - # Not specifying public parameter - ) - - assert result["comment_type"] == "public" - - # Verify the API call has public=True - call_args = mock_httpx_client.put.call_args - assert call_args[1]["json"]["ticket"]["comment"]["public"] is True - - @pytest.mark.asyncio - async def test_add_internal_comment_success( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test successfully adding an internal comment.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 456}}) - - result = await add_ticket_comment( - mock_context, - ticket_id=456, - comment_body="Internal note for agents", - public=False, - ) - - assert result["comment_type"] == "internal" - - @pytest.mark.asyncio - async def test_add_comment_error(self, mock_context, mock_httpx_client): - """Test error handling when adding comment fails.""" - mock_context.get_secret.return_value = "test-subdomain" - - error_response = MagicMock() - error_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Not Found", request=MagicMock(), response=MagicMock(status_code=404) - ) - mock_httpx_client.put.return_value = error_response - - with pytest.raises(ToolExecutionError): - await add_ticket_comment(mock_context, ticket_id=999, comment_body="Test comment") - - -class TestMarkTicketSolved: - """Test mark_ticket_solved functionality.""" - - @pytest.mark.asyncio - async def test_mark_solved_without_comment( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test marking ticket as solved without a comment.""" - mock_context.get_secret.return_value = "test-subdomain" - - ticket_response = { - "ticket": { - "id": 789, - "status": "solved", - "url": "https://test-subdomain.zendesk.com/api/v2/tickets/789.json", - } - } - - mock_httpx_client.put.return_value = mock_http_response(ticket_response) - - result = await mark_ticket_solved(mock_context, ticket_id=789) - - # Verify structured response - assert isinstance(result, dict) - assert result["success"] is True - assert result["ticket_id"] == 789 - assert result["status"] == "solved" - assert "comment_added" not in result - - # Verify ticket has html_url - assert "html_url" in result["ticket"] - assert "url" not in result["ticket"] - - # Verify the API call - mock_httpx_client.put.assert_called_once_with( - "https://test-subdomain.zendesk.com/api/v2/tickets/789.json", - headers={ - "Authorization": "Bearer fake-token", - "Content-Type": "application/json", - }, - json={"ticket": {"status": "solved"}}, - ) - - @pytest.mark.asyncio - async def test_mark_solved_with_public_comment( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test marking ticket as solved with a public comment.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 123}}) - - result = await mark_ticket_solved( - mock_context, - ticket_id=123, - comment_body="Issue resolved by resetting password", - comment_public=True, - ) - - assert result["comment_added"] is True - assert result["comment_type"] == "public" - - # Verify the request body includes the comment - call_args = mock_httpx_client.put.call_args - request_body = call_args[1]["json"] - assert request_body["ticket"]["status"] == "solved" - assert request_body["ticket"]["comment"]["body"] == "Issue resolved by resetting password" - assert request_body["ticket"]["comment"]["public"] is True - - @pytest.mark.asyncio - async def test_mark_solved_with_comment_default_internal( - self, mock_context, mock_httpx_client, mock_http_response - ): - """Test marking ticket as solved with comment defaults to internal.""" - mock_context.get_secret.return_value = "test-subdomain" - - mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 555}}) - - result = await mark_ticket_solved( - mock_context, - ticket_id=555, - comment_body="Internal resolution note", - # Not specifying comment_public, should default to False - ) - - assert result["comment_added"] is True - assert result["comment_type"] == "internal" - - # Verify the comment is internal by default - call_args = mock_httpx_client.put.call_args - request_body = call_args[1]["json"] - assert request_body["ticket"]["comment"]["public"] is False - - @pytest.mark.asyncio - async def test_mark_solved_error(self, mock_context, mock_httpx_client): - """Test error handling when marking ticket as solved fails.""" - mock_context.get_secret.return_value = "test-subdomain" - - error_response = MagicMock() - error_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Forbidden", request=MagicMock(), response=MagicMock(status_code=403) - ) - mock_httpx_client.put.return_value = error_response - - with pytest.raises(ToolExecutionError): - await mark_ticket_solved(mock_context, ticket_id=999) - - -class TestAuthenticationAndSecrets: - """Test authentication and secrets handling.""" - - @pytest.mark.asyncio - async def test_no_auth_token(self, mock_context, mock_httpx_client): - """Test behavior when auth token is empty.""" - mock_context.get_auth_token_or_empty.return_value = "" - mock_context.get_secret.return_value = "test-subdomain" - - # The tools should still attempt the API call with empty token - error_response = MagicMock() - error_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Unauthorized", request=MagicMock(), response=MagicMock(status_code=401) - ) - mock_httpx_client.get.return_value = error_response - - with pytest.raises(ToolExecutionError): - await list_tickets(mock_context) - - # Should be called with empty Bearer token - call_args = mock_httpx_client.get.call_args - assert call_args[1]["headers"]["Authorization"] == "Bearer " - - @pytest.mark.asyncio - async def test_subdomain_from_secret(self, mock_context, mock_httpx_client, mock_http_response): - """Test that subdomain is correctly retrieved from secrets.""" - mock_context.get_secret.return_value = "my-company" - - mock_httpx_client.get.return_value = mock_http_response({"tickets": []}) - - await list_tickets(mock_context, status=TicketStatus.OPEN) - - # Verify the correct subdomain was used - call_args = mock_httpx_client.get.call_args - assert "https://my-company.zendesk.com" in call_args[0][0] diff --git a/toolkits/zendesk/tests/test_utils.py b/toolkits/zendesk/tests/test_utils.py deleted file mode 100644 index 995fa52c..00000000 --- a/toolkits/zendesk/tests/test_utils.py +++ /dev/null @@ -1,291 +0,0 @@ -import pytest - -from arcade_zendesk.utils import ( - clean_html_text, - process_article_body, - process_search_results, - truncate_text, - validate_date_format, -) - - -class TestCleanHtmlText: - """Test HTML cleaning functionality.""" - - def test_clean_simple_html(self): - """Test cleaning basic HTML tags.""" - html = "

Hello World

" - assert clean_html_text(html) == "Hello World" - - def test_clean_complex_html(self): - """Test cleaning complex HTML with multiple tags.""" - html = """ -

Title

-

Paragraph with emphasis and bold.

-
    -
  • Item 1
  • -
  • Item 2
  • -
- - """ - cleaned = clean_html_text(html) - assert "Title" in cleaned - assert "Paragraph with emphasis and bold" in cleaned - assert "Item 1" in cleaned - assert "Item 2" in cleaned - assert "Footer content" in cleaned - assert "

" not in cleaned - assert "
  • " not in cleaned - - def test_clean_html_with_special_chars(self): - """Test cleaning HTML with special characters.""" - html = "

    Price: £100 & €120

    " - cleaned = clean_html_text(html) - assert "£100" in cleaned or "100" in cleaned # Depends on BeautifulSoup version - assert "&" in cleaned - assert "€120" in cleaned or "120" in cleaned - - @pytest.mark.parametrize( - "input_value,expected", - [ - (None, ""), - ("", ""), - (" ", ""), - ("

    ", ""), - ("

    ", ""), - ], - ) - def test_clean_html_edge_cases(self, input_value, expected): - """Test edge cases for HTML cleaning.""" - assert clean_html_text(input_value) == expected - - def test_clean_html_preserves_line_breaks(self): - """Test that meaningful line breaks are preserved.""" - html = "

    Line 1

    Line 2

    " - cleaned = clean_html_text(html) - # Should have text from both lines - assert "Line 1" in cleaned - assert "Line 2" in cleaned - - -class TestTruncateText: - """Test text truncation functionality.""" - - def test_truncate_long_text(self): - """Test truncating text longer than max length.""" - text = "This is a very long text that needs to be truncated" - result = truncate_text(text, 20) - assert result == "This ... [truncated]" - assert len(result) == 20 - - def test_no_truncation_needed(self): - """Test text shorter than max length is not truncated.""" - text = "Short text" - assert truncate_text(text, 20) == text - - def test_truncate_with_custom_suffix(self): - """Test truncation with custom suffix.""" - text = "This is a long text for testing" - result = truncate_text(text, 15, "...") - assert result == "This is a lo..." - assert len(result) == 15 - - @pytest.mark.parametrize( - "text,max_length,expected", - [ - (None, 10, None), - ("", 10, ""), - ("Hello", 5, "Hello"), - ("Hello World", 5, " ... [truncated]"), # Suffix is longer than allowed - ], - ) - def test_truncate_edge_cases(self, text, max_length, expected): - """Test edge cases for truncation.""" - result = truncate_text(text, max_length) - if expected == " ... [truncated]": - # When suffix is longer than max_length, only suffix is returned - assert result == expected - else: - assert result == expected - - def test_truncate_at_word_boundary(self): - """Test that truncation happens cleanly.""" - text = "The quick brown fox jumps over the lazy dog" - result = truncate_text(text, 25) - assert result == "The quick ... [truncated]" - assert len(result) == 25 - - -class TestProcessArticleBody: - """Test article body processing.""" - - def test_process_html_body(self): - """Test processing HTML body content.""" - body = "

    Article Title

    Article content with formatting.

    " - result = process_article_body(body) - assert "Article Title" in result - assert "Article content with formatting" in result - assert "

    " not in result - assert "" not in result - - def test_process_body_with_truncation(self): - """Test processing body with max length.""" - body = "

    " + "Long content " * 50 + "

    " - result = process_article_body(body, max_length=100) - assert len(result) <= 100 + len(" ... [truncated]") - assert result.endswith(" ... [truncated]") - - @pytest.mark.parametrize( - "body,max_length,expected", - [ - (None, None, None), - ("", None, None), - ("

    Short

    ", 100, "Short"), - ( - "

    ", - None, - "", - ), # Empty paragraph returns empty string after cleaning - ], - ) - def test_process_body_edge_cases(self, body, max_length, expected): - """Test edge cases for body processing.""" - result = process_article_body(body, max_length) - assert result == expected - - -class TestProcessSearchResults: - """Test search results processing.""" - - def test_process_results_with_body(self): - """Test processing results with body content included.""" - results = [ - { - "id": 1, - "title": "Article 1", - "body": "

    Content 1

    ", - "url": "https://example.com/1", - }, - { - "id": 2, - "title": "Article 2", - "body": "

    Content 2

    ", - "url": "https://example.com/2", - }, - ] - - processed = process_search_results(results, include_body=True) - - assert len(processed) == 2 - assert processed[0]["content"] == "Content 1" - assert processed[0]["metadata"]["id"] == 1 - assert processed[0]["metadata"]["title"] == "Article 1" - assert "body" not in processed[0]["metadata"] - - assert processed[1]["content"] == "Content 2" - assert processed[1]["metadata"]["id"] == 2 - - def test_process_results_without_body(self): - """Test processing results without body content.""" - results = [ - { - "id": 1, - "title": "Article 1", - "body": "

    Content 1

    ", - "url": "https://example.com/1", - } - ] - - processed = process_search_results(results, include_body=False) - - assert processed[0]["content"] is None - assert processed[0]["metadata"]["id"] == 1 - assert processed[0]["metadata"]["title"] == "Article 1" - assert "body" not in processed[0]["metadata"] - - def test_process_results_with_max_body_length(self): - """Test processing results with body length limit.""" - results = [ - { - "id": 1, - "title": "Article", - "body": "

    " + "Long content " * 100 + "

    ", - } - ] - - processed = process_search_results(results, include_body=True, max_body_length=50) - - content = processed[0]["content"] - assert len(content) <= 50 + len(" ... [truncated]") - assert content.endswith(" ... [truncated]") - - def test_process_empty_results(self): - """Test processing empty results list.""" - processed = process_search_results([]) - assert processed == [] - - def test_process_results_preserves_all_metadata(self): - """Test that all non-body fields are preserved in metadata.""" - results = [ - { - "id": 1, - "title": "Article", - "body": "

    Content

    ", - "url": "https://example.com/1", - "created_at": "2024-01-01", - "custom_field": "value", - "nested": {"key": "value"}, - } - ] - - processed = process_search_results(results, include_body=True) - - metadata = processed[0]["metadata"] - assert metadata["id"] == 1 - assert metadata["title"] == "Article" - assert metadata["url"] == "https://example.com/1" - assert metadata["created_at"] == "2024-01-01" - assert metadata["custom_field"] == "value" - assert metadata["nested"] == {"key": "value"} - assert "body" not in metadata - - -class TestValidateDateFormat: - """Test date format validation.""" - - @pytest.mark.parametrize( - "date_string", - [ - "2024-01-15", - "2024-12-31", - "2000-01-01", - "1999-12-31", - "2030-06-15", - ], - ) - def test_valid_date_formats(self, date_string): - """Test valid YYYY-MM-DD date formats.""" - assert validate_date_format(date_string) is True - - @pytest.mark.parametrize( - "date_string", - [ - "2024/01/15", - "01-15-2024", - "2024-1-15", - "2024-01-1", - "24-01-15", - "2024.01.15", - "20240115", - "January 15, 2024", - "15/01/2024", - "2024", - "2024-01", - "", - "not-a-date", - # Note: These have valid format but invalid values - regex only checks format - ], - ) - def test_invalid_date_formats(self, date_string): - """Test invalid date formats.""" - assert validate_date_format(date_string) is False diff --git a/toolkits/zoom/.pre-commit-config.yaml b/toolkits/zoom/.pre-commit-config.yaml deleted file mode 100644 index 68794e1e..00000000 --- a/toolkits/zoom/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -files: ^.*/zoom/.* -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.4.0" - hooks: - - id: check-case-conflict - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format diff --git a/toolkits/zoom/.ruff.toml b/toolkits/zoom/.ruff.toml deleted file mode 100644 index f1aed90f..00000000 --- a/toolkits/zoom/.ruff.toml +++ /dev/null @@ -1,46 +0,0 @@ -target-version = "py310" -line-length = 100 -fix = true - -[lint] -select = [ - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-bugbear - "B", - # flake8-builtins - "A", - # flake8-comprehensions - "C4", - # flake8-debugger - "T10", - # flake8-simplify - "SIM", - # isort - "I", - # mccabe - "C90", - # pycodestyle - "E", "W", - # pyflakes - "F", - # pygrep-hooks - "PGH", - # pyupgrade - "UP", - # ruff - "RUF", - # tryceratops - "TRY", -] - -[lint.per-file-ignores] -"*" = ["TRY003", "B904"] -"**/tests/*" = ["S101", "E501"] -"**/evals/*" = ["S101", "E501"] - -[format] -preview = true -skip-magic-trailing-comma = false diff --git a/toolkits/zoom/LICENSE b/toolkits/zoom/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/toolkits/zoom/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/toolkits/zoom/Makefile b/toolkits/zoom/Makefile deleted file mode 100644 index 0a8969be..00000000 --- a/toolkits/zoom/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: install-local -install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras - @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using poetry - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - @uv run --no-sources coverage report - @echo "Generating coverage report" - @uv run --no-sources coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --no-sources --bump patch - -.PHONY: check -check: ## Run code quality tools. - @if [ -f .pre-commit-config.yaml ]; then\ - echo "🚀 Linting code: Running pre-commit";\ - uv run --no-sources pre-commit run -a;\ - fi - @echo "🚀 Static type checking: Running mypy" - @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/zoom/arcade_zoom/__init__.py b/toolkits/zoom/arcade_zoom/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/zoom/arcade_zoom/tools/__init__.py b/toolkits/zoom/arcade_zoom/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/zoom/arcade_zoom/tools/constants.py b/toolkits/zoom/arcade_zoom/tools/constants.py deleted file mode 100644 index 1eab3cc2..00000000 --- a/toolkits/zoom/arcade_zoom/tools/constants.py +++ /dev/null @@ -1 +0,0 @@ -ZOOM_BASE_URL = "https://api.zoom.us/v2" diff --git a/toolkits/zoom/arcade_zoom/tools/meetings.py b/toolkits/zoom/arcade_zoom/tools/meetings.py deleted file mode 100644 index d2dbdac2..00000000 --- a/toolkits/zoom/arcade_zoom/tools/meetings.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Annotated - -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import Zoom - -from arcade_zoom.tools.utils import _handle_zoom_api_error, _send_zoom_request - - -@tool( - requires_auth=Zoom( - scopes=["meeting:read:list_upcoming_meetings"], - ) -) -async def list_upcoming_meetings( - context: ToolContext, - user_id: Annotated[ - str | None, - "The user's user ID or email address. Defaults to 'me' for the current user.", - ] = "me", -) -> Annotated[dict, "List of upcoming meetings within the next 24 hours"]: - """List a Zoom user's upcoming meetings within the next 24 hours.""" - endpoint = f"/users/{user_id}/upcoming_meetings" - - response = await _send_zoom_request(context, "GET", endpoint) - - if not (200 <= response.status_code < 300): - _handle_zoom_api_error(response) - - response_json = response.json() - return dict(response_json) - - -@tool( - requires_auth=Zoom( - scopes=["meeting:read:invitation"], - ) -) -async def get_meeting_invitation( - context: ToolContext, - meeting_id: Annotated[ - str, - "The meeting's numeric ID (as a string).", - ], -) -> Annotated[dict, "Meeting invitation string"]: - """Retrieve the invitation note for a specific Zoom meeting.""" - endpoint = f"/meetings/{meeting_id}/invitation" - - response = await _send_zoom_request(context, "GET", endpoint) - - if not (200 <= response.status_code < 300): - _handle_zoom_api_error(response) - - response_json = response.json() - return dict(response_json) diff --git a/toolkits/zoom/arcade_zoom/tools/utils.py b/toolkits/zoom/arcade_zoom/tools/utils.py deleted file mode 100644 index 73041f53..00000000 --- a/toolkits/zoom/arcade_zoom/tools/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -import httpx -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError - -from arcade_zoom.tools.constants import ZOOM_BASE_URL - - -async def _send_zoom_request( - context: ToolContext, - method: str, - endpoint: str, - params: dict | None = None, - json_data: dict | None = None, -) -> httpx.Response: - """ - Send an asynchronous request to the Zoom API. - - Args: - context: The tool context containing the authorization token. - method: The HTTP method (GET, POST, PUT, DELETE, etc.). - endpoint: The API endpoint path (e.g., "/users/me/upcoming_meetings"). - params: Query parameters to include in the request. - json_data: JSON data to include in the request body. - - Returns: - The response object from the API request. - - Raises: - ToolExecutionError: If the request fails for any reason. - """ - url = f"{ZOOM_BASE_URL}{endpoint}" - token = ( - context.authorization.token if context.authorization and context.authorization.token else "" - ) - headers = {"Authorization": f"Bearer {token}"} - - async with httpx.AsyncClient() as client: - try: - response = await client.request( - method, url, headers=headers, params=params, json=json_data - ) - response.raise_for_status() - except httpx.RequestError as e: - raise ToolExecutionError(f"Failed to send request to Zoom API: {e}") - - return response - - -def _handle_zoom_api_error(response: httpx.Response) -> None: - """ - Handle errors from the Zoom API by mapping common status codes to ToolExecutionErrors. - - Args: - response: The response object from the API request. - - Raises: - ToolExecutionError: If the response contains an error status code. - """ - status_code_map = { - 401: ToolExecutionError("Unauthorized: Invalid or expired token"), - 403: ToolExecutionError("Forbidden: Access denied"), - 429: ToolExecutionError("Too Many Requests: Rate limit exceeded"), - } - - if response.status_code in status_code_map: - raise status_code_map[response.status_code] - elif response.status_code >= 400: - raise ToolExecutionError(f"Error: {response.status_code} - {response.text}") diff --git a/toolkits/zoom/pyproject.toml b/toolkits/zoom/pyproject.toml deleted file mode 100644 index 47f5095a..00000000 --- a/toolkits/zoom/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_zoom" -version = "0.1.13" -description = "Arcade.dev LLM tools for Zoom" -requires-python = ">=3.10" -dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "httpx>=0.27.2,<1.0.0",] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-ai[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_zoom/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_zoom",] diff --git a/toolkits/zoom/tests/__init__.py b/toolkits/zoom/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/toolkits/zoom/tests/test_meetings.py b/toolkits/zoom/tests/test_meetings.py deleted file mode 100644 index be5e73ec..00000000 --- a/toolkits/zoom/tests/test_meetings.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_zoom.tools.meetings import _handle_zoom_api_error - - -@pytest.mark.asyncio -async def test_handle_zoom_api_error(): - # Create a mock response object - class MockResponse: - def __init__(self, status_code, text): - self.status_code = status_code - self.text = text - - # Test for 401 Unauthorized - with pytest.raises(ToolExecutionError, match="Unauthorized: Invalid or expired token"): - _handle_zoom_api_error(MockResponse(401, "Unauthorized")) - - # Test for 403 Forbidden - with pytest.raises(ToolExecutionError, match="Forbidden: Access denied"): - _handle_zoom_api_error(MockResponse(403, "Forbidden")) - - # Test for 429 Too Many Requests - with pytest.raises(ToolExecutionError, match="Too Many Requests: Rate limit exceeded"): - _handle_zoom_api_error(MockResponse(429, "Too Many Requests")) - - # Test for other error status codes - with pytest.raises(ToolExecutionError, match="Error: 500 - Internal Server Error"): - _handle_zoom_api_error(MockResponse(500, "Internal Server Error")) - - # Test for a successful response (should not raise an error) - try: - _handle_zoom_api_error(MockResponse(200, "OK")) - except ToolExecutionError: - pytest.fail("ToolExecutionError raised unexpectedly for a successful response.")