From ab889f9f1d62a60bfc6b0dbf093d5848d4790078 Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Fri, 20 Dec 2024 09:49:45 -0800 Subject: [PATCH] Lint all toolkits (#183) # PR Description * Adds/updates the following files to all toolkits: - `.pre-commit-config.yaml` - `.ruff.toml` - `LICENSE` - `Makefile` - `pyproject.toml` * Lint all toolkits such that they pass `make check` and `make test` (a total doozy). This includes adding some unit tests and evals. * Github workflow for testing toolkits before merge into main (courtesy of @sdreyer) * Added a QOL improvement for tool developers for when they need to get the context's auth token. * Minor updates to `arcade new` template. --- .github/scripts/get_toolkits.sh | 12 ++ .github/workflows/publish-toolkit.yml | 5 +- .github/workflows/test-toolkits.yml | 54 ++++++ arcade/arcade/core/schema.py | 4 + .../templates/{{ toolkit_name }}/Makefile | 17 +- .../evals/eval_{{ toolkit_name }}.py | 8 +- arcade/tests/core/test_schema.py | 24 +++ toolkits/code_sandbox/.pre-commit-config.yaml | 18 ++ toolkits/code_sandbox/.ruff.toml | 47 +++++ toolkits/code_sandbox/LICENSE | 21 +++ toolkits/code_sandbox/Makefile | 53 ++++++ .../arcade_code_sandbox/tools/e2b.py | 10 +- toolkits/code_sandbox/evals/eval_e2b.py | 8 +- toolkits/code_sandbox/pyproject.toml | 27 ++- toolkits/code_sandbox/tests/__init__.py | 0 toolkits/code_sandbox/tests/test_e2b.py | 61 +++++++ toolkits/github/.pre-commit-config.yaml | 18 ++ toolkits/github/.ruff.toml | 46 +++++ toolkits/github/LICENSE | 21 +++ toolkits/github/Makefile | 53 ++++++ .../github/arcade_github/tools/activity.py | 23 ++- .../github/arcade_github/tools/constants.py | 2 +- toolkits/github/arcade_github/tools/issues.py | 42 +++-- toolkits/github/arcade_github/tools/models.py | 3 +- .../arcade_github/tools/pull_requests.py | 166 +++++++++++++----- .../arcade_github/tools/repositories.py | 94 ++++++---- toolkits/github/arcade_github/tools/utils.py | 14 +- toolkits/github/evals/eval_github_activity.py | 6 +- toolkits/github/evals/eval_github_issues.py | 12 +- .../github/evals/eval_github_pull_requests.py | 20 +-- .../github/evals/eval_github_repositories.py | 18 +- toolkits/github/pyproject.toml | 27 ++- toolkits/github/tests/__init__.py | 0 toolkits/github/tests/test_activity.py | 4 +- toolkits/github/tests/test_issues.py | 4 +- toolkits/github/tests/test_pull_requests.py | 6 +- toolkits/github/tests/test_repositories.py | 6 +- toolkits/google/.pre-commit-config.yaml | 18 ++ toolkits/google/.ruff.toml | 46 +++++ toolkits/google/LICENSE | 21 +++ toolkits/google/Makefile | 53 ++++++ .../google/arcade_google/tools/calendar.py | 108 +++++++++--- toolkits/google/arcade_google/tools/docs.py | 24 ++- toolkits/google/arcade_google/tools/drive.py | 23 ++- toolkits/google/arcade_google/tools/gmail.py | 137 ++++++++++++--- toolkits/google/arcade_google/tools/models.py | 100 ++++++++--- toolkits/google/arcade_google/tools/utils.py | 51 +++--- toolkits/google/evals/eval_google_calendar.py | 20 +-- toolkits/google/evals/eval_google_docs.py | 16 +- toolkits/google/evals/eval_google_drive.py | 8 +- toolkits/google/evals/eval_google_gmail.py | 18 +- toolkits/google/pyproject.toml | 27 ++- toolkits/google/tests/test_calendar.py | 8 +- toolkits/google/tests/test_docs.py | 6 +- toolkits/google/tests/test_drive.py | 6 +- toolkits/google/tests/test_gmail.py | 8 +- toolkits/linkedin/.pre-commit-config.yaml | 18 ++ toolkits/linkedin/.ruff.toml | 46 +++++ toolkits/linkedin/LICENSE | 21 +++ toolkits/linkedin/Makefile | 53 ++++++ .../linkedin/arcade_linkedin/tools/share.py | 21 ++- toolkits/linkedin/conftest.py | 20 +++ toolkits/linkedin/evals/eval_linkedin.py | 48 +++++ toolkits/linkedin/pyproject.toml | 27 ++- toolkits/linkedin/tests/__init__.py | 0 toolkits/linkedin/tests/test_share.py | 35 ++++ toolkits/math/.pre-commit-config.yaml | 18 ++ toolkits/math/.ruff.toml | 47 +++++ toolkits/math/evals/eval_math_tools.py | 6 +- toolkits/math/tests/test_arithmetic.py | 4 +- toolkits/search/.pre-commit-config.yaml | 18 ++ toolkits/search/.ruff.toml | 47 +++++ toolkits/search/LICENSE | 21 +++ toolkits/search/Makefile | 53 ++++++ toolkits/search/arcade_search/tools/google.py | 1 - toolkits/search/evals/eval_google_search.py | 6 +- toolkits/search/pyproject.toml | 27 ++- toolkits/search/tests/__init__.py | 0 toolkits/search/tests/test_google.py | 47 +++++ toolkits/slack/.pre-commit-config.yaml | 18 ++ toolkits/slack/.ruff.toml | 47 +++++ toolkits/slack/LICENSE | 21 +++ toolkits/slack/Makefile | 53 ++++++ toolkits/slack/arcade_slack/tools/chat.py | 140 +++++++-------- toolkits/slack/arcade_slack/tools/utils.py | 20 +++ toolkits/slack/evals/eval_slack_messaging.py | 6 +- toolkits/slack/pyproject.toml | 27 ++- toolkits/slack/tests/__init__.py | 0 toolkits/slack/tests/test_chat.py | 44 +++++ toolkits/spotify/.pre-commit-config.yaml | 18 ++ toolkits/spotify/.ruff.toml | 47 +++++ toolkits/spotify/LICENSE | 21 +++ toolkits/spotify/Makefile | 53 ++++++ .../spotify/arcade_spotify/tools/models.py | 2 + .../spotify/arcade_spotify/tools/player.py | 20 ++- .../spotify/arcade_spotify/tools/search.py | 14 +- .../spotify/arcade_spotify/tools/tracks.py | 10 +- .../spotify/arcade_spotify/tools/utils.py | 15 +- toolkits/spotify/evals/eval_player.py | 22 +-- toolkits/spotify/evals/eval_search.py | 6 +- toolkits/spotify/evals/eval_tracks.py | 12 +- toolkits/spotify/pyproject.toml | 27 ++- toolkits/spotify/tests/__init__.py | 0 toolkits/spotify/tests/test_utils.py | 128 ++++++++++++++ toolkits/web/.pre-commit-config.yaml | 18 ++ toolkits/web/.ruff.toml | 47 +++++ toolkits/web/LICENSE | 21 +++ toolkits/web/Makefile | 53 ++++++ toolkits/web/arcade_web/tools/firecrawl.py | 25 +-- toolkits/web/evals/eval_firecrawl.py | 20 +-- toolkits/web/pyproject.toml | 27 ++- toolkits/web/tests/test_firecrawl.py | 4 +- toolkits/x/.ruff.toml | 4 +- toolkits/x/Makefile | 4 +- toolkits/x/arcade_x/tools/tweets.py | 6 +- toolkits/x/arcade_x/tools/users.py | 2 +- toolkits/x/arcade_x/tools/utils.py | 7 +- toolkits/x/evals/eval_x_tools.py | 2 +- toolkits/x/pyproject.toml | 3 +- toolkits/zoom/.pre-commit-config.yaml | 18 ++ toolkits/zoom/.ruff.toml | 46 +++++ toolkits/zoom/LICENSE | 21 +++ toolkits/zoom/Makefile | 53 ++++++ toolkits/zoom/arcade_zoom/tools/meetings.py | 24 ++- toolkits/zoom/pyproject.toml | 27 ++- toolkits/zoom/tests/__init__.py | 0 toolkits/zoom/tests/test_meetings.py | 35 ++++ 127 files changed, 2958 insertions(+), 516 deletions(-) create mode 100755 .github/scripts/get_toolkits.sh create mode 100644 .github/workflows/test-toolkits.yml create mode 100644 arcade/tests/core/test_schema.py create mode 100644 toolkits/code_sandbox/.pre-commit-config.yaml create mode 100644 toolkits/code_sandbox/.ruff.toml create mode 100644 toolkits/code_sandbox/LICENSE create mode 100644 toolkits/code_sandbox/Makefile create mode 100644 toolkits/code_sandbox/tests/__init__.py create mode 100644 toolkits/code_sandbox/tests/test_e2b.py create mode 100644 toolkits/github/.pre-commit-config.yaml create mode 100644 toolkits/github/.ruff.toml create mode 100644 toolkits/github/LICENSE create mode 100644 toolkits/github/Makefile create mode 100644 toolkits/github/tests/__init__.py create mode 100644 toolkits/google/.pre-commit-config.yaml create mode 100644 toolkits/google/.ruff.toml create mode 100644 toolkits/google/LICENSE create mode 100644 toolkits/google/Makefile create mode 100644 toolkits/linkedin/.pre-commit-config.yaml create mode 100644 toolkits/linkedin/.ruff.toml create mode 100644 toolkits/linkedin/LICENSE create mode 100644 toolkits/linkedin/Makefile create mode 100644 toolkits/linkedin/conftest.py create mode 100644 toolkits/linkedin/evals/eval_linkedin.py create mode 100644 toolkits/linkedin/tests/__init__.py create mode 100644 toolkits/linkedin/tests/test_share.py create mode 100644 toolkits/math/.pre-commit-config.yaml create mode 100644 toolkits/math/.ruff.toml create mode 100644 toolkits/search/.pre-commit-config.yaml create mode 100644 toolkits/search/.ruff.toml create mode 100644 toolkits/search/LICENSE create mode 100644 toolkits/search/Makefile create mode 100644 toolkits/search/tests/__init__.py create mode 100644 toolkits/search/tests/test_google.py create mode 100644 toolkits/slack/.pre-commit-config.yaml create mode 100644 toolkits/slack/.ruff.toml create mode 100644 toolkits/slack/LICENSE create mode 100644 toolkits/slack/Makefile create mode 100644 toolkits/slack/arcade_slack/tools/utils.py create mode 100644 toolkits/slack/tests/__init__.py create mode 100644 toolkits/slack/tests/test_chat.py create mode 100644 toolkits/spotify/.pre-commit-config.yaml create mode 100644 toolkits/spotify/.ruff.toml create mode 100644 toolkits/spotify/LICENSE create mode 100644 toolkits/spotify/Makefile create mode 100644 toolkits/spotify/tests/__init__.py create mode 100644 toolkits/spotify/tests/test_utils.py create mode 100644 toolkits/web/.pre-commit-config.yaml create mode 100644 toolkits/web/.ruff.toml create mode 100644 toolkits/web/LICENSE create mode 100644 toolkits/web/Makefile create mode 100644 toolkits/zoom/.pre-commit-config.yaml create mode 100644 toolkits/zoom/.ruff.toml create mode 100644 toolkits/zoom/LICENSE create mode 100644 toolkits/zoom/Makefile create mode 100644 toolkits/zoom/tests/__init__.py create mode 100644 toolkits/zoom/tests/test_meetings.py diff --git a/.github/scripts/get_toolkits.sh b/.github/scripts/get_toolkits.sh new file mode 100755 index 00000000..fdec575d --- /dev/null +++ b/.github/scripts/get_toolkits.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Get all directories and format as JSON array +echo -n '[' +ls -d toolkits/*/ | cut -d'/' -f2 | sort -u | awk '{ + if (NR==1) { + printf "\"%s\"", $0 + } else { + printf ", \"%s\"", $0 + } +}' +echo ']' diff --git a/.github/workflows/publish-toolkit.yml b/.github/workflows/publish-toolkit.yml index acbd7024..f1aa0e7f 100644 --- a/.github/workflows/publish-toolkit.yml +++ b/.github/workflows/publish-toolkit.yml @@ -39,15 +39,12 @@ jobs: python-version: '3.12' cache: 'pip' - - name: Install Python Dependencies - run: pip install ./arcade - - name: Test Toolkit id: Test_Toolkit working-directory: toolkits/${{ steps.set-toolkit.outputs.toolkit }} run: | - make check make install + make check make test - name: Publish Toolkit diff --git a/.github/workflows/test-toolkits.yml b/.github/workflows/test-toolkits.yml new file mode 100644 index 00000000..b13fea6c --- /dev/null +++ b/.github/workflows/test-toolkits.yml @@ -0,0 +1,54 @@ +name: Test Toolkits + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + tool_matrix: ${{ steps.dataStep.outputs.tools }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Get Toolkits + id: dataStep + run: | + TARGETS=$(./.github/scripts/get_toolkits.sh) + echo "tools=$(jq -cn --argjson environments "$TARGETS" '{target: $environments}')" >> $GITHUB_OUTPUT + + test-toolkits: + needs: setup + runs-on: ubuntu-latest + strategy: + matrix: ${{ fromJson(needs.setup.outputs.tool_matrix) }} + steps: + - run: echo ${{ matrix.target }} + + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Test Toolkit + id: Test_Toolkit + working-directory: toolkits/${{ matrix.target }} + run: | + make install + make check + make test diff --git a/arcade/arcade/core/schema.py b/arcade/arcade/core/schema.py index 7fa3772c..5c615043 100644 --- a/arcade/arcade/core/schema.py +++ b/arcade/arcade/core/schema.py @@ -234,6 +234,10 @@ class ToolContext(BaseModel): user_id: str | None = None """The user ID for the tool invocation (if any).""" + def get_auth_token_or_empty(self) -> str: + """Retrieve the authorization token, or return an empty string if not available.""" + return self.authorization.token if self.authorization and self.authorization.token else "" + class ToolCallRequest(BaseModel): """The request to call (invoke) a tool.""" diff --git a/arcade/arcade/templates/{{ toolkit_name }}/Makefile b/arcade/arcade/templates/{{ toolkit_name }}/Makefile index c8f552fe..dfcc004d 100644 --- a/arcade/arcade/templates/{{ toolkit_name }}/Makefile +++ b/arcade/arcade/templates/{{ toolkit_name }}/Makefile @@ -13,11 +13,6 @@ install: ## Install the poetry environment and install the pre-commit hooks else \ echo "πŸ“¦ Poetry is already installed"; \ fi - @echo "πŸ“¦ Checking for poetry.lock file" - @if [ ! -f poetry.lock ]; then \ - echo "πŸ“¦ Creating poetry.lock file"; \ - poetry lock; \ - fi @echo "πŸš€ Installing package in development mode with all extras" poetry install --all-extras @@ -28,12 +23,8 @@ build: clean-build ## Build wheel file using poetry .PHONY: clean-build clean-build: ## clean build artifacts - rm -rf dist - -.PHONY: clean-dist -clean-dist: ## Clean all built distributions @echo "πŸ—‘οΈ Cleaning dist directory" - @rm -rf dist + rm -rf dist .PHONY: test test: ## Test the code with pytest @@ -54,9 +45,9 @@ bump-version: ## Bump the version in the pyproject.toml file .PHONY: check check: ## Run code quality tools. - @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock" - @poetry check --lock + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check @echo "πŸš€ Linting code: Running pre-commit" @poetry run pre-commit run -a @echo "πŸš€ Static type checking: Running mypy" - @poetry run mypy $(git ls-files '*.py') + @poetry run mypy --config-file=pyproject.toml diff --git a/arcade/arcade/templates/{{ toolkit_name }}/evals/eval_{{ toolkit_name }}.py b/arcade/arcade/templates/{{ toolkit_name }}/evals/eval_{{ toolkit_name }}.py index a0b2ae92..8d768d3a 100644 --- a/arcade/arcade/templates/{{ toolkit_name }}/evals/eval_{{ toolkit_name }}.py +++ b/arcade/arcade/templates/{{ toolkit_name }}/evals/eval_{{ toolkit_name }}.py @@ -2,6 +2,7 @@ from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( EvalRubric, EvalSuite, + ExpectedToolCall, SimilarityCritic, tool_eval, ) @@ -35,7 +36,12 @@ def {{ toolkit_name }}_eval_suite() -> EvalSuite: suite.add_case( name="Saying hello", user_message="He's actually right here, say hi to him!", - expected_tool_calls=[(say_hello, {"name": "John Doe"})], + expected_tool_calls=[ + ExpectedToolCall( + func=say_hello, + args={"name": "John Doe"} + ) + ], rubric=rubric, critics=[ SimilarityCritic(critic_field="name", weight=0.5), diff --git a/arcade/tests/core/test_schema.py b/arcade/tests/core/test_schema.py new file mode 100644 index 00000000..5fd7de53 --- /dev/null +++ b/arcade/tests/core/test_schema.py @@ -0,0 +1,24 @@ +from arcade.core.schema import ToolAuthorizationContext, ToolContext + + +def test_get_auth_token_or_empty_with_token(): + expected_token = "test_token" # noqa: S105 + auth_context = ToolAuthorizationContext(token=expected_token) + tool_context = ToolContext(authorization=auth_context) + + actual_token = tool_context.get_auth_token_or_empty() + + assert actual_token == expected_token + + +def test_get_auth_token_or_empty_without_token(): + auth_context = ToolAuthorizationContext(token=None) + tool_context = ToolContext(authorization=auth_context) + + assert tool_context.get_auth_token_or_empty() == "" + + +def test_get_auth_token_or_empty_no_authorization(): + tool_context = ToolContext(authorization=None) + + assert tool_context.get_auth_token_or_empty() == "" diff --git a/toolkits/code_sandbox/.pre-commit-config.yaml b/toolkits/code_sandbox/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/code_sandbox/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bb64590d --- /dev/null +++ b/toolkits/code_sandbox/.ruff.toml @@ -0,0 +1,47 @@ +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/code_sandbox/LICENSE b/toolkits/code_sandbox/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/code_sandbox/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..68003ca4 --- /dev/null +++ b/toolkits/code_sandbox/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ code_sandbox 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py b/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py index be48cc8d..51ba6fe2 100644 --- a/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py +++ b/toolkits/code_sandbox/arcade_code_sandbox/tools/e2b.py @@ -1,8 +1,8 @@ from typing import Annotated +from arcade.sdk import tool from e2b_code_interpreter import Sandbox -from arcade.sdk import tool from arcade_code_sandbox.tools.models import E2BSupportedLanguage from arcade_code_sandbox.tools.utils import get_secret @@ -24,16 +24,18 @@ def run_code( with Sandbox(api_key=api_key) as sbx: execution = sbx.run_code(code=code, language=language) - return execution.to_json() + return str(execution.to_json()) -# Note: Not recommended to use tool_choice='generate' with this tool since it contains base64 encoded image. +# Note: Not recommended to use tool_choice='generate' with this tool +# since it contains base64 encoded image. @tool def create_static_matplotlib_chart( 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. + Run the provided Python code to generate a static matplotlib chart. + The resulting chart is returned as a base64 encoded image. """ api_key = get_secret("E2B_API_KEY") diff --git a/toolkits/code_sandbox/evals/eval_e2b.py b/toolkits/code_sandbox/evals/eval_e2b.py index 9b50a321..75eb7d13 100644 --- a/toolkits/code_sandbox/evals/eval_e2b.py +++ b/toolkits/code_sandbox/evals/eval_e2b.py @@ -1,7 +1,3 @@ -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 - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -12,6 +8,10 @@ from arcade.sdk.eval import ( tool_eval, ) +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: diff --git a/toolkits/code_sandbox/pyproject.toml b/toolkits/code_sandbox/pyproject.toml index 59263563..d1395971 100644 --- a/toolkits/code_sandbox/pyproject.toml +++ b/toolkits/code_sandbox/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_code_sandbox" -version = "0.1.0" +version = "0.1.7" description = "LLM tools for running code in a sandbox" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ e2b-code-interpreter = "^1.0.1" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/code_sandbox/tests/__init__.py b/toolkits/code_sandbox/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/code_sandbox/tests/test_e2b.py b/toolkits/code_sandbox/tests/test_e2b.py new file mode 100644 index 00000000..2f5c2a76 --- /dev/null +++ b/toolkits/code_sandbox/tests/test_e2b.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock, patch + +import pytest +from arcade.sdk.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 + + +def test_run_code_success(mock_sandbox): + mock_execution = MagicMock() + mock_execution.to_json.return_value = '{"result": "success"}' + mock_sandbox.run_code.return_value = mock_execution + + result = run_code("print('Hello, World!')", E2BSupportedLanguage.PYTHON) + assert result == '{"result": "success"}' + + +def test_run_code_error(mock_sandbox): + 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("print('Hello, World!')", E2BSupportedLanguage.PYTHON) + + +def test_create_static_matplotlib_chart_success(mock_sandbox): + 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("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_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("import matplotlib.pyplot as plt") + assert result == { + "base64_image": None, + "logs": '{"logs": "log data"}', + "error": '{"error": "some error"}', + } diff --git a/toolkits/github/.pre-commit-config.yaml b/toolkits/github/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/github/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bacd9161 --- /dev/null +++ b/toolkits/github/.ruff.toml @@ -0,0 +1,46 @@ +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/github/LICENSE b/toolkits/github/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/github/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..ecc0d79e --- /dev/null +++ b/toolkits/github/Makefile @@ -0,0 +1,53 @@ +.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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/github/arcade_github/tools/activity.py b/toolkits/github/arcade_github/tools/activity.py index 62bc7030..9f22a58a 100644 --- a/toolkits/github/arcade_github/tools/activity.py +++ b/toolkits/github/arcade_github/tools/activity.py @@ -1,13 +1,13 @@ from typing import Annotated, Optional import httpx - from arcade.sdk import ToolContext, tool from arcade.sdk.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 +# 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( @@ -26,7 +26,9 @@ async def set_starred( ``` """ url = get_url("user_starred", owner=owner, repo=name) - headers = get_github_json_headers(context.authorization.token) + headers = get_github_json_headers( + context.authorization.token if context.authorization and context.authorization.token else "" + ) async with httpx.AsyncClient() as client: if starred: @@ -49,20 +51,23 @@ async def list_stargazers( repo: Annotated[str, "The name of the repository"], limit: Annotated[ Optional[int], - "The maximum number of stargazers to return. If not provided, all stargazers will be returned.", + "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) - - per_page = min(limit, 100) - page = 1 - stargazers = [] + 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( diff --git a/toolkits/github/arcade_github/tools/constants.py b/toolkits/github/arcade_github/tools/constants.py index 0b41583a..ee7e4eb6 100644 --- a/toolkits/github/arcade_github/tools/constants.py +++ b/toolkits/github/arcade_github/tools/constants.py @@ -13,7 +13,7 @@ ENDPOINTS = { "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", + "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 index 0b954f82..090f58d9 100644 --- a/toolkits/github/arcade_github/tools/issues.py +++ b/toolkits/github/arcade_github/tools/issues.py @@ -2,9 +2,9 @@ import json from typing import Annotated, Optional import httpx - from arcade.sdk import ToolContext, tool from arcade.sdk.auth import GitHub + from arcade_github.tools.utils import ( get_github_json_headers, get_url, @@ -14,7 +14,10 @@ from arcade_github.tools.utils import ( # 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'" +# 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, @@ -32,18 +35,29 @@ async def create_issue( labels: Annotated[Optional[list[str]], "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.", + "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.", + "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"]) + 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) @@ -55,7 +69,9 @@ async def create_issue( "assignees": assignees, } data = remove_none_values(data) - headers = get_github_json_headers(context.authorization.token) + 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) @@ -83,7 +99,8 @@ async def create_issue( # 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'" +# 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, @@ -96,11 +113,14 @@ async def create_issue_comment( 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.", + "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.", + "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. @@ -114,7 +134,9 @@ async def create_issue_comment( data = { "body": body, } - headers = get_github_json_headers(context.authorization.token) + 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) diff --git a/toolkits/github/arcade_github/tools/models.py b/toolkits/github/arcade_github/tools/models.py index d22d4f41..cc996d7b 100644 --- a/toolkits/github/arcade_github/tools/models.py +++ b/toolkits/github/arcade_github/tools/models.py @@ -29,7 +29,8 @@ 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 + Use RIGHT for additions that appear in green or unchanged + lines that appear in white and are shown for context """ LEFT = "LEFT" diff --git a/toolkits/github/arcade_github/tools/pull_requests.py b/toolkits/github/arcade_github/tools/pull_requests.py index 5629d8bf..e35d718e 100644 --- a/toolkits/github/arcade_github/tools/pull_requests.py +++ b/toolkits/github/arcade_github/tools/pull_requests.py @@ -2,10 +2,10 @@ import json from typing import Annotated, Optional import httpx - from arcade.sdk import ToolContext, tool from arcade.sdk.auth import GitHub from arcade.sdk.errors import RetryableToolError + from arcade_github.tools.models import ( DiffSide, PRSortProperty, @@ -24,9 +24,12 @@ from arcade_github.tools.utils import ( # 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. +# 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, @@ -38,18 +41,20 @@ async def list_pull_requests( state: Annotated[Optional[PRState], "The state of the pull requests to return."] = PRState.OPEN, head: Annotated[ Optional[str], - "Filter pulls by head user or head organization and branch name in the format of user:ref-name or organization:ref-name.", + "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[Optional[str], "Filter pulls by base branch name."] = "main", sort: Annotated[ Optional[PRSortProperty], "The property to sort the results by." ] = PRSortProperty.CREATED, direction: Annotated[Optional[SortDirection], "The direction of the sort."] = None, - per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30, - page: Annotated[Optional[int], "The page number of the results to fetch."] = 1, + 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.", + "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"]: """ @@ -63,15 +68,17 @@ async def list_pull_requests( url = get_url("repo_pulls", owner=owner, repo=repo) params = { "base": base, - "state": state.value, - "sort": sort.value, + "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, # Note: Github defaults to desc when sort is 'created' or not specified, otherwise defaults to asc + "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) + 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) @@ -101,7 +108,8 @@ async def list_pull_requests( # 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." +# 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, @@ -117,11 +125,13 @@ async def get_pull_request( ] = False, include_extra_data: Annotated[ Optional[bool], - "If true, return all the data available about the pull requests. This is a large payload and may impact performance - use with caution.", + "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", + "JSON string containing details of the specified pull request, " + "optionally including diff content", ]: """ Get details of a pull request in a GitHub repository. @@ -132,8 +142,12 @@ async def get_pull_request( ``` """ url = get_url("repo_pull", owner=owner, repo=repo, pull_number=pull_number) - headers = get_github_json_headers(context.authorization.token) - diff_headers = get_github_diff_headers(context.authorization.token) + 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) @@ -174,7 +188,9 @@ async def get_pull_request( # 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!'." +# 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( @@ -202,7 +218,13 @@ async def update_pull_request( Example: ``` - update_pull_request(owner="octocat", repo="Hello-World", pull_number=1347, title="new title", body="updated body") + 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) @@ -216,7 +238,9 @@ async def update_pull_request( } data = remove_none_values(data) - headers = get_github_json_headers(context.authorization.token) + 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) @@ -250,11 +274,12 @@ async def list_pull_request_commits( "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[Optional[int], "The number of results per page (max 100)."] = 30, - page: Annotated[Optional[int], "The page number of the results to fetch."] = 1, + 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.", + "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"]: """ @@ -272,7 +297,9 @@ async def list_pull_request_commits( "page": page, } - headers = get_github_json_headers(context.authorization.token) + 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) @@ -304,9 +331,13 @@ async def list_pull_request_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. +# 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, @@ -324,7 +355,13 @@ async def create_reply_for_review_comment( Example: ``` - create_reply_for_review_comment(owner="octocat", repo="Hello-World", pull_number=1347, comment_id=42, body="Looks good to me!") + create_reply_for_review_comment( + owner="octocat", + repo="Hello-World", + pull_number=1347, + comment_id=42, + body="Looks good to me!", + ) ``` """ url = get_url( @@ -335,7 +372,9 @@ async def create_reply_for_review_comment( comment_id=comment_id, ) - headers = get_github_json_headers(context.authorization.token) + headers = get_github_json_headers( + context.authorization.token if context.authorization and context.authorization.token else "" + ) data = {"body": body} @@ -367,13 +406,15 @@ async def list_review_comments_on_pull_request( ] = SortDirection.DESC, since: Annotated[ Optional[str], - "Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.", + "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[Optional[int], "The number of results per page (max 100)."] = 30, - page: Annotated[Optional[int], "The page number of the results to fetch."] = 1, + 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.", + "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" @@ -397,7 +438,9 @@ async def list_review_comments_on_pull_request( } params = remove_none_values(params) - headers = get_github_json_headers(context.authorization.token) + 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) @@ -434,8 +477,11 @@ async def list_review_comments_on_pull_request( # 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. +# 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, @@ -449,19 +495,24 @@ async def create_review_comment( path: Annotated[str, "The relative path to the file that necessitates a comment."], commit_id: Annotated[ Optional[str], - "The SHA of the commit needing a comment. If not provided, the latest commit SHA of the PR's base branch will be used.", + "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[ Optional[int], - "The start line of the range of lines in the pull request diff that the comment applies to. Required unless 'subject_type' is 'file'.", + "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[ Optional[int], - "The end line of the range of lines in the pull request diff that the comment applies to. Required unless 'subject_type' is 'file'.", + "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[ Optional[DiffSide], - "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", + "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[ Optional[str], "The starting side of the diff that the comment applies to." @@ -472,7 +523,8 @@ async def create_review_comment( ] = 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.", + "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"]: """ @@ -484,7 +536,16 @@ async def create_review_comment( 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") + 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 @@ -492,8 +553,15 @@ async def create_review_comment( 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( - "'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'." + message=message, + developer_message=message, + additional_prompt_content=message, ) # Ensure the line range goes from lowest to highest @@ -509,8 +577,14 @@ async def create_review_comment( 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( - f"Failed to get the latest commit SHA of PR {pull_number} in repo {repo} owned by {owner}. Does the PR exist?" + message=message, + developer_message=message, + additional_prompt_content=message, ) url = get_url("repo_pull_comments", owner=owner, repo=repo, pull_number=pull_number) @@ -526,7 +600,9 @@ async def create_review_comment( "start_side": start_side, } data = remove_none_values(data) - headers = get_github_json_headers(context.authorization.token) + 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) diff --git a/toolkits/github/arcade_github/tools/repositories.py b/toolkits/github/arcade_github/tools/repositories.py index 11af8587..8e879f79 100644 --- a/toolkits/github/arcade_github/tools/repositories.py +++ b/toolkits/github/arcade_github/tools/repositories.py @@ -2,9 +2,9 @@ import json from typing import Annotated, Optional import httpx - from arcade.sdk import ToolContext, tool from arcade.sdk.auth import GitHub + from arcade_github.tools.models import ( ActivityType, RepoSortProperty, @@ -21,7 +21,8 @@ from arcade_github.tools.utils import ( ) -# Implements https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository and returns only the stargazers_count field. +# 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( @@ -36,7 +37,9 @@ async def count_stargazers( ``` """ - headers = get_github_json_headers(context.authorization.token) + 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: @@ -46,11 +49,12 @@ async def count_stargazers( data = response.json() stargazers_count = data.get("stargazers_count", 0) - return stargazers_count + 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." +# 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, @@ -60,15 +64,18 @@ async def list_org_repositories( RepoSortProperty, "The property to sort the results by" ] = RepoSortProperty.CREATED, sort_direction: Annotated[SortDirection, "The order to sort by"] = SortDirection.ASC, - per_page: Annotated[Optional[int], "The number of results per page"] = 30, - page: Annotated[Optional[int], "The page number of the results to fetch"] = 1, + 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 pull requests. This is a large payload and may impact performance - use with caution.", + "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", + "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) @@ -80,7 +87,9 @@ async def list_org_repositories( "page": page, } - headers = get_github_json_headers(context.authorization.token) + 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) @@ -123,11 +132,13 @@ async def get_repository( ], 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.", + "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", + "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. @@ -139,7 +150,9 @@ async def get_repository( ``` """ url = get_url("repo", owner=owner, repo=repo) - headers = get_github_json_headers(context.authorization.token) + 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) @@ -148,7 +161,7 @@ async def get_repository( repo_data = response.json() if include_extra_data: - return json.dumps(repo_data) + return dict(repo_data) return { "name": repo_data["name"], @@ -167,7 +180,8 @@ async def get_repository( # 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 " +# 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, @@ -179,18 +193,20 @@ async def list_repository_activities( direction: Annotated[ Optional[SortDirection], "The direction to sort the results by." ] = SortDirection.DESC, - per_page: Annotated[Optional[int], "The number of results per page (max 100)."] = 30, + per_page: Annotated[int, "The number of results per page (max 100)."] = 30, before: Annotated[ Optional[str], - "A cursor (unique identifier, e.g., a SHA of a commit) to search for results before this cursor.", + "A cursor (unique ID, e.g., a SHA of a commit) to search for results before this cursor.", ] = None, after: Annotated[ Optional[str], - "A cursor (unique identifier, e.g., a SHA of a commit) to search for results after this cursor.", + "A cursor (unique ID, e.g., a SHA of a commit) to search for results after this cursor.", ] = None, ref: Annotated[ Optional[str], - "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.", + "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[ Optional[str], "The GitHub username to filter by the actor who performed the activity." @@ -199,16 +215,19 @@ async def list_repository_activities( activity_type: Annotated[Optional[ActivityType], "The activity type to filter by."] = 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.", + "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", + "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. + 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: ``` @@ -222,7 +241,7 @@ async def list_repository_activities( """ url = get_url("repo_activity", owner=owner, repo=repo) params = { - "direction": direction.value, + "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, @@ -233,7 +252,9 @@ async def list_repository_activities( } params = remove_none_values(params) - headers = get_github_json_headers(context.authorization.token) + 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) @@ -260,8 +281,9 @@ async def list_repository_activities( # 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 parameter such that language model can more easily specify a valid date/time. +# 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, @@ -279,17 +301,21 @@ async def list_review_comments_in_a_repository( ] = SortDirection.DESC, since: Annotated[ Optional[str], - "Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.", + "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[Optional[int], "The number of results per page (max 100)."] = 30, - page: Annotated[Optional[int], "The page number of the results to fetch."] = 1, + 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.", + "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", + "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. @@ -309,7 +335,9 @@ async def list_review_comments_in_a_repository( "since": since, } params = remove_none_values(params) - headers = get_github_json_headers(context.authorization.token) + 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) diff --git a/toolkits/github/arcade_github/tools/utils.py b/toolkits/github/arcade_github/tools/utils.py index b93e6d66..47983972 100644 --- a/toolkits/github/arcade_github/tools/utils.py +++ b/toolkits/github/arcade_github/tools/utils.py @@ -1,8 +1,12 @@ +from typing import Any + +import httpx from arcade.sdk.errors import ToolExecutionError + from arcade_github.tools.constants import ENDPOINTS, GITHUB_API_BASE_URL -def handle_github_response(response: dict, url: str) -> None: +def handle_github_response(response: httpx.Response, url: str) -> None: """ Handle GitHub API response and raise appropriate exceptions for non-200 status codes. @@ -30,13 +34,14 @@ def handle_github_response(response: dict, url: str) -> None: raise ToolExecutionError(f"Error accessing '{url}': {error_message}") -def get_github_json_headers(token: str) -> dict: +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}", @@ -44,13 +49,14 @@ def get_github_json_headers(token: str) -> dict: } -def get_github_diff_headers(token: str) -> dict: +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}", @@ -68,7 +74,7 @@ def remove_none_values(params: dict) -> dict: return {k: v for k, v in params.items() if v is not None} -def get_url(endpoint: str, **kwargs) -> str: +def get_url(endpoint: str, **kwargs: Any) -> str: """ Get the full URL for a given endpoint. diff --git a/toolkits/github/evals/eval_github_activity.py b/toolkits/github/evals/eval_github_activity.py index 4473d718..e892a9af 100644 --- a/toolkits/github/evals/eval_github_activity.py +++ b/toolkits/github/evals/eval_github_activity.py @@ -1,6 +1,3 @@ -import arcade_github -from arcade_github.tools.activity import list_stargazers, set_starred - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -10,6 +7,9 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_github +from arcade_github.tools.activity import list_stargazers, set_starred + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/github/evals/eval_github_issues.py b/toolkits/github/evals/eval_github_issues.py index d0a32434..36bd15ba 100644 --- a/toolkits/github/evals/eval_github_issues.py +++ b/toolkits/github/evals/eval_github_issues.py @@ -1,9 +1,3 @@ -import arcade_github -from arcade_github.tools.issues import ( - create_issue, - create_issue_comment, -) - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -14,6 +8,12 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_github +from arcade_github.tools.issues import ( + create_issue, + create_issue_comment, +) + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/github/evals/eval_github_pull_requests.py b/toolkits/github/evals/eval_github_pull_requests.py index d4e96f33..a6a5104a 100644 --- a/toolkits/github/evals/eval_github_pull_requests.py +++ b/toolkits/github/evals/eval_github_pull_requests.py @@ -1,3 +1,13 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + BinaryCritic, + EvalRubric, + EvalSuite, + ExpectedToolCall, + SimilarityCritic, + tool_eval, +) + import arcade_github from arcade_github.tools.models import ( DiffSide, @@ -14,16 +24,6 @@ from arcade_github.tools.pull_requests import ( update_pull_request, ) -from arcade.sdk import ToolCatalog -from arcade.sdk.eval import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - SimilarityCritic, - tool_eval, -) - # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/github/evals/eval_github_repositories.py b/toolkits/github/evals/eval_github_repositories.py index fa2cd3d5..67a06365 100644 --- a/toolkits/github/evals/eval_github_repositories.py +++ b/toolkits/github/evals/eval_github_repositories.py @@ -1,3 +1,12 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + BinaryCritic, + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) + import arcade_github from arcade_github.tools.models import SortDirection from arcade_github.tools.repositories import ( @@ -8,15 +17,6 @@ from arcade_github.tools.repositories import ( list_review_comments_in_a_repository, ) -from arcade.sdk import ToolCatalog -from arcade.sdk.eval import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - tool_eval, -) - # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/github/pyproject.toml b/toolkits/github/pyproject.toml index d3ff5688..7dec2dd6 100644 --- a/toolkits/github/pyproject.toml +++ b/toolkits/github/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_github" -version = "0.1.0" +version = "0.1.7" description = "LLM tools for interacting with Github" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ httpx = "^0.27.2" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/github/tests/__init__.py b/toolkits/github/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/github/tests/test_activity.py b/toolkits/github/tests/test_activity.py index 2c5f90c6..3ad0e724 100644 --- a/toolkits/github/tests/test_activity.py +++ b/toolkits/github/tests/test_activity.py @@ -1,10 +1,10 @@ from unittest.mock import AsyncMock, patch import pytest -from arcade_github.tools.activity import list_stargazers, set_starred +from arcade.sdk.errors import ToolExecutionError from httpx import Response -from arcade.sdk.errors import ToolExecutionError +from arcade_github.tools.activity import list_stargazers, set_starred @pytest.fixture diff --git a/toolkits/github/tests/test_issues.py b/toolkits/github/tests/test_issues.py index 864b0d84..f33f29e4 100644 --- a/toolkits/github/tests/test_issues.py +++ b/toolkits/github/tests/test_issues.py @@ -1,10 +1,10 @@ from unittest.mock import AsyncMock, patch import pytest -from arcade_github.tools.issues import create_issue, create_issue_comment +from arcade.sdk.errors import ToolExecutionError from httpx import Response -from arcade.sdk.errors import ToolExecutionError +from arcade_github.tools.issues import create_issue, create_issue_comment @pytest.fixture diff --git a/toolkits/github/tests/test_pull_requests.py b/toolkits/github/tests/test_pull_requests.py index cc0a99cf..10f73e17 100644 --- a/toolkits/github/tests/test_pull_requests.py +++ b/toolkits/github/tests/test_pull_requests.py @@ -1,6 +1,9 @@ from unittest.mock import AsyncMock, patch import pytest +from arcade.sdk.errors import RetryableToolError, ToolExecutionError +from httpx import Response + from arcade_github.tools.models import ( DiffSide, ReviewCommentSubjectType, @@ -14,9 +17,6 @@ from arcade_github.tools.pull_requests import ( list_review_comments_on_pull_request, update_pull_request, ) -from httpx import Response - -from arcade.sdk.errors import RetryableToolError, ToolExecutionError @pytest.fixture diff --git a/toolkits/github/tests/test_repositories.py b/toolkits/github/tests/test_repositories.py index 5a78a620..5b66559f 100644 --- a/toolkits/github/tests/test_repositories.py +++ b/toolkits/github/tests/test_repositories.py @@ -1,6 +1,9 @@ from unittest.mock import AsyncMock, patch import pytest +from arcade.sdk.errors import ToolExecutionError +from httpx import Response + from arcade_github.tools.models import RepoType from arcade_github.tools.repositories import ( count_stargazers, @@ -9,9 +12,6 @@ from arcade_github.tools.repositories import ( list_repository_activities, list_review_comments_in_a_repository, ) -from httpx import Response - -from arcade.sdk.errors import ToolExecutionError @pytest.fixture diff --git a/toolkits/google/.pre-commit-config.yaml b/toolkits/google/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/google/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bacd9161 --- /dev/null +++ b/toolkits/google/.ruff.toml @@ -0,0 +1,46 @@ +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/google/LICENSE b/toolkits/google/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/google/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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/Makefile b/toolkits/google/Makefile new file mode 100644 index 00000000..56ed319e --- /dev/null +++ b/toolkits/google/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ google 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/google/arcade_google/tools/calendar.py b/toolkits/google/arcade_google/tools/calendar.py index 296c954e..c1188cfb 100644 --- a/toolkits/google/arcade_google/tools/calendar.py +++ b/toolkits/google/arcade_google/tools/calendar.py @@ -1,13 +1,13 @@ from datetime import datetime, timedelta -from typing import Annotated - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build -from googleapiclient.errors import HttpError +from typing import Annotated, Any from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Google from arcade.sdk.errors import RetryableToolError +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + from arcade_google.tools.models import EventVisibility, SendUpdatesOptions from arcade_google.tools.utils import parse_datetime @@ -44,7 +44,15 @@ async def create_event( ) -> Annotated[dict, "A dictionary containing the created event details"]: """Create a new event/meeting/sync/meetup in the specified calendar.""" - service = build("calendar", "v3", credentials=Credentials(context.authorization.token)) + service = build( + "calendar", + "v3", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) # Get the calendar's time zone calendar = service.calendars().get(calendarId=calendar_id).execute() @@ -54,7 +62,7 @@ async def create_event( start_dt = parse_datetime(start_datetime, time_zone) end_dt = parse_datetime(end_datetime, time_zone) - event = { + event: dict[str, Any] = { "summary": summary, "description": description, "location": location, @@ -82,11 +90,13 @@ 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'.", + "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'.", + "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, @@ -98,14 +108,23 @@ async def list_events( 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: + 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. + 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", "v3", credentials=Credentials(context.authorization.token)) + service = build( + "calendar", + "v3", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) # Get the calendar's time zone calendar = service.calendars().get(calendarId=calendar_id).execute() @@ -165,7 +184,8 @@ async def update_event( 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'.", + "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, @@ -180,26 +200,39 @@ async def update_event( 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.", + "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.", + "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, + "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.", + "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. + `updated_start_datetime` and `updated_end_datetime` are + independent and can be provided separately. """ - service = build("calendar", "v3", credentials=Credentials(context.authorization.token)) + service = build( + "calendar", + "v3", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) calendar = service.calendars().get(calendarId="primary").execute() time_zone = calendar["timeZone"] @@ -221,16 +254,21 @@ async def update_event( ) 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: {valid_events_with_id}", + 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.", + 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.isoformat(), "timeZone": time_zone} + "start": {"dateTime": updated_start_datetime, "timeZone": time_zone} if updated_start_datetime else None, - "end": {"dateTime": updated_end_datetime.isoformat(), "timeZone": time_zone} + "end": {"dateTime": updated_end_datetime, "timeZone": time_zone} if updated_end_datetime else None, "calendarId": updated_calendar_id, @@ -272,7 +310,10 @@ async def update_event( ) .execute() ) - return f"Event with ID {event_id} successfully updated at {updated_event['updated']}. View updated event at {updated_event['htmlLink']}" + return ( + f"Event with ID {event_id} successfully updated at {updated_event['updated']}. " + f"View updated event at {updated_event['htmlLink']}" + ) @tool( @@ -289,7 +330,15 @@ async def delete_event( ] = SendUpdatesOptions.ALL, ) -> Annotated[str, "A string containing the deletion confirmation message"]: """Delete an event from Google Calendar.""" - service = build("calendar", "v3", credentials=Credentials(context.authorization.token)) + service = build( + "calendar", + "v3", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) service.events().delete( calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value @@ -303,4 +352,7 @@ async def delete_event( 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}'. {notification_message}" + return ( + f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. " + f"{notification_message}" + ) diff --git a/toolkits/google/arcade_google/tools/docs.py b/toolkits/google/arcade_google/tools/docs.py index af298b0c..a99788aa 100644 --- a/toolkits/google/arcade_google/tools/docs.py +++ b/toolkits/google/arcade_google/tools/docs.py @@ -2,6 +2,7 @@ from typing import Annotated from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Google + from arcade_google.tools.utils import build_docs_service @@ -22,13 +23,15 @@ async def get_document_by_id( """ Get the latest version of the specified Google Docs document. """ - service = build_docs_service(context.authorization.token) + 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 response + return dict(response) # Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate @@ -52,7 +55,9 @@ async def insert_text_at_end_of_document( end_index = document["body"]["content"][-1]["endIndex"] - service = build_docs_service(context.authorization.token) + service = build_docs_service( + context.authorization.token if context.authorization and context.authorization.token else "" + ) requests = [ { @@ -72,7 +77,7 @@ async def insert_text_at_end_of_document( .execute() ) - return response + return dict(response) # Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create @@ -90,7 +95,9 @@ async def create_blank_document( """ Create a blank Google Docs document with the specified title. """ - service = build_docs_service(context.authorization.token) + service = build_docs_service( + context.authorization.token if context.authorization and context.authorization.token else "" + ) body = {"title": title} @@ -106,7 +113,8 @@ async def create_blank_document( # 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!"` +# Example `arcade chat` query: +# `create document with title "My New Document" and text content "Hello, World!"` @tool( requires_auth=Google( scopes=[ @@ -125,7 +133,9 @@ async def create_document_from_text( # First, create a blank document document = await create_blank_document(context, title) - service = build_docs_service(context.authorization.token) + service = build_docs_service( + context.authorization.token if context.authorization and context.authorization.token else "" + ) requests = [ { diff --git a/toolkits/google/arcade_google/tools/drive.py b/toolkits/google/arcade_google/tools/drive.py index 41ff89a0..bd02f6cd 100644 --- a/toolkits/google/arcade_google/tools/drive.py +++ b/toolkits/google/arcade_google/tools/drive.py @@ -1,7 +1,8 @@ -from typing import Annotated, Optional +from typing import Annotated, Any, Optional from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Google + from arcade_google.tools.utils import build_drive_service, remove_none_values from .models import Corpora, OrderBy @@ -9,7 +10,8 @@ from .models import Corpora, OrderBy # 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 +# 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.readonly"], @@ -17,31 +19,34 @@ from .models import Corpora, OrderBy ) async def list_documents( context: ToolContext, - corpora: Annotated[Optional[Corpora], "The source of files to list"] = Corpora.USER, + corpora: Annotated[Corpora, "The source of files to list"] = Corpora.USER, title_keywords: Annotated[ Optional[list[str]], "Keywords or phrases that must be in the document title" ] = None, order_by: Annotated[ - Optional[OrderBy], + OrderBy, "Sort order. Defaults to listing the most recently modified documents first", ] = OrderBy.MODIFIED_TIME_DESC, supports_all_drives: Annotated[ - Optional[bool], + bool, "Whether the requesting application supports both My Drives and shared drives", ] = False, - limit: Annotated[Optional[int], "The number of documents to list"] = 50, + limit: Annotated[int, "The number of documents to list"] = 50, ) -> 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)", + "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)", ]: """ List documents in the user's Google Drive. Excludes documents that are in the trash. """ page_size = min(10, limit) page_token = None # The page token is used for continuing a previous request on the next page - files = [] + files: list[dict[str, Any]] = [] - service = build_drive_service(context.authorization.token) + service = build_drive_service( + context.authorization.token if context.authorization and context.authorization.token else "" + ) query = "mimeType = 'application/vnd.google-apps.document' and trashed = false" if title_keywords: diff --git a/toolkits/google/arcade_google/tools/gmail.py b/toolkits/google/arcade_google/tools/gmail.py index 53bd74c3..9a43a254 100644 --- a/toolkits/google/arcade_google/tools/gmail.py +++ b/toolkits/google/arcade_google/tools/gmail.py @@ -1,15 +1,15 @@ import base64 from email.message import EmailMessage from email.mime.text import MIMEText -from typing import Annotated, Optional - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build -from googleapiclient.errors import HttpError +from typing import Annotated, Any, Optional from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Google from arcade.sdk.errors import RetryableToolError +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + from arcade_google.tools.utils import ( DateRange, build_query_string, @@ -42,7 +42,15 @@ async def send_email( """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) message = EmailMessage() message.set_content(body) @@ -80,7 +88,15 @@ async def send_draft_email( """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) # Send the draft email sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute() @@ -108,7 +124,15 @@ async def write_draft_email( Compose a new email draft using the Gmail API. """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) message = MIMEText(body) message["to"] = recipient @@ -149,7 +173,15 @@ async def update_draft_email( """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) message = MIMEText(body) message["to"] = recipient @@ -188,7 +220,15 @@ async def delete_draft_email( """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) # Delete the draft service.users().drafts().delete(userId="me", id=draft_email_id).execute() @@ -209,7 +249,15 @@ async def trash_email( """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) # Trash the email trashed_email = service.users().messages().trash(userId="me", id=email_id).execute() @@ -233,7 +281,15 @@ async def list_draft_emails( Lists draft emails in the user's draft mailbox using the Gmail API. """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) listed_drafts = service.users().drafts().list(userId="me").execute() @@ -268,7 +324,7 @@ async def list_emails_by_header( subject: Annotated[Optional[str], "Words to find in the subject of the email"] = None, body: Annotated[Optional[str], "Words to find in the body of the email"] = None, date_range: Annotated[Optional[DateRange], "The date range of the email"] = None, - limit: Annotated[Optional[int], "The maximum number of emails to return"] = 25, + limit: Annotated[int, "The maximum number of emails to return"] = 25, ) -> Annotated[ dict, "A dictionary containing a list of email details matching the search criteria" ]: @@ -279,12 +335,22 @@ async def list_emails_by_header( if not any([sender, recipient, subject, body]): raise RetryableToolError( message="At least one of sender, recipient, subject, or body must be provided.", - developer_message="At least one of sender, recipient, subject, or body must be provided.", + developer_message=( + "At least one of sender, recipient, subject, or body must be provided." + ), ) query = build_query_string(sender, recipient, subject, body, date_range) - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) messages = fetch_messages(service, query, limit) if not messages: @@ -294,7 +360,7 @@ async def list_emails_by_header( return {"emails": emails} -def process_messages(service, messages): +def process_messages(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: emails = [] for msg in messages: try: @@ -319,7 +385,15 @@ async def list_emails( Read emails from a Gmail account and extract plain text content. """ # Set up the Gmail API client - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) messages = service.users().messages().list(userId="me").execute().get("messages", []) @@ -359,7 +433,15 @@ async def search_threads( date_range: Annotated[Optional[DateRange], "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", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) query = ( build_query_string(sender, recipient, subject, body, date_range) @@ -377,7 +459,7 @@ async def search_threads( } params = remove_none_values(params) - threads = [] + 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: @@ -413,7 +495,10 @@ async def list_threads( 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.""" - return await search_threads(context, page_token, max_results, include_spam_trash) + threads: dict[str, Any] = await search_threads( + context, page_token, max_results, include_spam_trash + ) + return threads @tool( @@ -437,8 +522,16 @@ async def get_thread( } params = remove_none_values(params) - service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) + service = build( + "gmail", + "v1", + credentials=Credentials( + context.authorization.token + if context.authorization and context.authorization.token + else "" + ), + ) thread = service.users().threads().get(**params).execute() thread["messages"] = [parse_email(message) for message in thread.get("messages", [])] - return thread + return dict(thread) diff --git a/toolkits/google/arcade_google/tools/models.py b/toolkits/google/arcade_google/tools/models.py index feb6101c..11540e2d 100644 --- a/toolkits/google/arcade_google/tools/models.py +++ b/toolkits/google/arcade_google/tools/models.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import date, datetime, time, timedelta from enum import Enum from zoneinfo import ZoneInfo @@ -14,7 +14,7 @@ class DateRange(Enum): THIS_MONTH = "this_month" NEXT_MONTH = "next_month" - def to_date_range(self): + def to_date_range(self) -> tuple[date, date]: today = datetime.now().date() if self == DateRange.TODAY: return today, today + timedelta(days=1) @@ -67,7 +67,7 @@ class Day(Enum): NEXT_FRIDAY = "next_friday" NEXT_SATURDAY = "next_saturday" - def to_date(self, time_zone_name: str): + def to_date(self, time_zone_name: str) -> date: time_zone = ZoneInfo(time_zone_name) today = datetime.now(time_zone).date() weekday = today.weekday() @@ -206,7 +206,7 @@ class TimeSlot(Enum): _2330 = "23:30" _2345 = "23:45" - def to_time(self): + def to_time(self) -> time: return datetime.strptime(self.value, "%H:%M").time() @@ -254,43 +254,91 @@ class OrderBy(str, Enum): Each key has both ascending and descending options. """ - CREATED_TIME = "createdTime" # When the file was created (ascending) - CREATED_TIME_DESC = "createdTime desc" # When the file was created (descending) - FOLDER = "folder" # The folder ID, sorted using alphabetical ordering (ascending) - FOLDER_DESC = "folder desc" # The folder ID, sorted using alphabetical ordering (descending) + 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 = ( - "modifiedByMeTime" # The last time the file was modified by the user (ascending) + # The last time the file was modified by the user (ascending) + "modifiedByMeTime" ) MODIFIED_BY_ME_TIME_DESC = ( - "modifiedByMeTime desc" # The last time the file was modified by the user (descending) + # 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 = "modifiedTime" # The last time the file was modified by anyone (ascending) MODIFIED_TIME_DESC = ( - "modifiedTime desc" # The last time the file was modified by anyone (descending) + # 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" ) - NAME = "name" # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending) - NAME_DESC = "name desc" # The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending) - NAME_NATURAL = "name_natural" # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending) - NAME_NATURAL_DESC = "name_natural desc" # The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending) QUOTA_BYTES_USED = ( - "quotaBytesUsed" # The number of storage quota bytes used by the file (ascending) + # The number of storage quota bytes used by the file (ascending) + "quotaBytesUsed" ) QUOTA_BYTES_USED_DESC = ( - "quotaBytesUsed desc" # The number of storage quota bytes used by the file (descending) + # 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 = "recency" # The most recent timestamp from the file's date-time fields (ascending) RECENCY_DESC = ( - "recency desc" # The most recent timestamp from the file's date-time fields (descending) + # The most recent timestamp from the file's date-time fields (descending) + "recency desc" ) SHARED_WITH_ME_TIME = ( - "sharedWithMeTime" # When the file was shared with the user, if applicable (ascending) + # 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" ) - SHARED_WITH_ME_TIME_DESC = "sharedWithMeTime desc" # When the file was shared with the user, if applicable (descending) - STARRED = "starred" # Whether the user has starred the file (ascending) - STARRED_DESC = "starred desc" # Whether the user has starred the file (descending) VIEWED_BY_ME_TIME = ( - "viewedByMeTime" # The last time the file was viewed by the user (ascending) + # The last time the file was viewed by the user (ascending) + "viewedByMeTime" ) VIEWED_BY_ME_TIME_DESC = ( - "viewedByMeTime desc" # The last time the file was viewed by the user (descending) + # The last time the file was viewed by the user (descending) + "viewedByMeTime desc" ) diff --git a/toolkits/google/arcade_google/tools/utils.py b/toolkits/google/arcade_google/tools/utils.py index 1bc52b44..e0076fda 100644 --- a/toolkits/google/arcade_google/tools/utils.py +++ b/toolkits/google/arcade_google/tools/utils.py @@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo from bs4 import BeautifulSoup from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build +from googleapiclient.discovery import Resource, build from arcade_google.tools.models import Day, TimeSlot @@ -32,7 +32,8 @@ def parse_datetime(datetime_str: str, time_zone: str) -> datetime: 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'." + f"Invalid datetime format: '{datetime_str}'. " + "Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'." ) from e return dt @@ -46,7 +47,7 @@ class DateRange(Enum): LAST_MONTH = "last_month" THIS_YEAR = "this_year" - def to_date_query(self): + def to_date_query(self) -> str: today = datetime.now() result = "after:" comparison_date = today @@ -71,7 +72,7 @@ class DateRange(Enum): return result + comparison_date.strftime("%Y/%m/%d") -def parse_email(email_data: dict[str, Any]) -> Optional[dict[str, str]]: +def parse_email(email_data: dict[str, Any]) -> dict[str, Any]: """ Parse email data and extract relevant information. @@ -97,10 +98,10 @@ def parse_email(email_data: dict[str, Any]) -> Optional[dict[str, str]]: } except Exception as e: print(f"Error parsing email {email_data.get('id', 'unknown')}: {e}") - return None + return email_data -def parse_draft_email(draft_email_data: dict[str, Any]) -> Optional[dict[str, str]]: +def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]: """ Parse draft email data and extract relevant information. @@ -127,18 +128,18 @@ def parse_draft_email(draft_email_data: dict[str, Any]) -> Optional[dict[str, st } except Exception as e: print(f"Error parsing draft email {draft_email_data.get('id', 'unknown')}: {e}") - return None + return draft_email_data -def get_draft_url(draft_id): +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): +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_in_trash_url(email_id): +def get_email_in_trash_url(email_id: str) -> str: return f"https://mail.google.com/mail/u/0/#trash/{email_id}" @@ -178,9 +179,9 @@ def _clean_email_body(body: str) -> str: text = soup.get_text(separator=" ") # Clean up the text - text = _clean_text(text) + cleaned_text = _clean_text(text) - return text.strip() + return cleaned_text.strip() except Exception as e: print(f"Error cleaning email body: {e}") return body @@ -226,9 +227,15 @@ def _update_datetime(day: Day | None, time: TimeSlot | None, time_zone: str) -> return None -def build_query_string(sender, recipient, subject, body, date_range): - """ - Helper function to build a query string for Gmail list_emails_by_header and search_threads tools. +def build_query_string( + sender: str | None, + recipient: str | None, + subject: str | None, + body: str | None, + date_range: DateRange | None, +) -> str: + """Helper function to build a query string + for Gmail list_emails_by_header and search_threads tools. """ query = [] if sender: @@ -244,7 +251,7 @@ def build_query_string(sender, recipient, subject, body, date_range): return " ".join(query) -def fetch_messages(service, query_string, limit): +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. """ @@ -254,7 +261,7 @@ def fetch_messages(service, query_string, limit): .list(userId="me", q=query_string, maxResults=limit or 100) .execute() ) - return response.get("messages", []) + return response.get("messages", []) # type: ignore[no-any-return] def remove_none_values(params: dict) -> dict: @@ -267,16 +274,18 @@ def remove_none_values(params: dict) -> dict: # Drive utils -def build_drive_service(token: str): +def build_drive_service(auth_token: Optional[str]) -> Resource: # type: ignore[no-any-unimported] """ Build a Drive service object. """ - return build("drive", "v3", credentials=Credentials(token)) + auth_token = auth_token or "" + return build("drive", "v3", credentials=Credentials(auth_token)) # Docs utils -def build_docs_service(token: str): +def build_docs_service(auth_token: Optional[str]) -> Resource: # type: ignore[no-any-unimported] """ Build a Drive service object. """ - return build("docs", "v1", credentials=Credentials(token)) + auth_token = auth_token or "" + return build("docs", "v1", credentials=Credentials(auth_token)) diff --git a/toolkits/google/evals/eval_google_calendar.py b/toolkits/google/evals/eval_google_calendar.py index b0975a97..b7cb5b92 100644 --- a/toolkits/google/evals/eval_google_calendar.py +++ b/toolkits/google/evals/eval_google_calendar.py @@ -1,15 +1,5 @@ from datetime import timedelta -import arcade_google -from arcade_google.tools.calendar import ( - EventVisibility, - SendUpdatesOptions, - create_event, - delete_event, - list_events, - update_event, -) - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -20,6 +10,16 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_google +from arcade_google.tools.calendar import ( + EventVisibility, + SendUpdatesOptions, + create_event, + delete_event, + list_events, + update_event, +) + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/google/evals/eval_google_docs.py b/toolkits/google/evals/eval_google_docs.py index dc7aa5fe..1939da68 100644 --- a/toolkits/google/evals/eval_google_docs.py +++ b/toolkits/google/evals/eval_google_docs.py @@ -1,11 +1,3 @@ -import arcade_google -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.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -16,6 +8,14 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_google +from arcade_google.tools.docs 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, diff --git a/toolkits/google/evals/eval_google_drive.py b/toolkits/google/evals/eval_google_drive.py index 72d44857..c5eebf1e 100644 --- a/toolkits/google/evals/eval_google_drive.py +++ b/toolkits/google/evals/eval_google_drive.py @@ -1,7 +1,3 @@ -import arcade_google -from arcade_google.tools.drive import list_documents -from arcade_google.tools.models import Corpora, OrderBy - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -11,6 +7,10 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_google +from arcade_google.tools.drive import list_documents +from arcade_google.tools.models import Corpora, OrderBy + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/google/evals/eval_google_gmail.py b/toolkits/google/evals/eval_google_gmail.py index a2180292..93f62c58 100644 --- a/toolkits/google/evals/eval_google_gmail.py +++ b/toolkits/google/evals/eval_google_gmail.py @@ -1,12 +1,3 @@ -import arcade_google -from arcade_google.tools.gmail import ( - get_thread, - list_threads, - search_threads, - send_email, -) -from arcade_google.tools.utils import DateRange - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -17,6 +8,15 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_google +from arcade_google.tools.gmail import ( + get_thread, + list_threads, + search_threads, + send_email, +) +from arcade_google.tools.utils import DateRange + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/google/pyproject.toml b/toolkits/google/pyproject.toml index 6433e147..afaef9b7 100644 --- a/toolkits/google/pyproject.toml +++ b/toolkits/google/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_google" -version = "0.1.0" +version = "0.1.7" description = "Arcade tools for the entire google suite" authors = ["Arcade AI "] @@ -17,7 +17,32 @@ beautifulsoup4 = "^4.10.0" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/google/tests/test_calendar.py b/toolkits/google/tests/test_calendar.py index bdceb65d..19377b26 100644 --- a/toolkits/google/tests/test_calendar.py +++ b/toolkits/google/tests/test_calendar.py @@ -1,12 +1,12 @@ from unittest.mock import MagicMock, patch import pytest -from arcade_google.tools.calendar import create_event, delete_event, list_events, update_event -from arcade_google.tools.models import EventVisibility, SendUpdatesOptions -from googleapiclient.errors import HttpError - from arcade.sdk import ToolAuthorizationContext, ToolContext from arcade.sdk.errors import ToolExecutionError +from googleapiclient.errors import HttpError + +from arcade_google.tools.calendar import create_event, delete_event, list_events, update_event +from arcade_google.tools.models import EventVisibility, SendUpdatesOptions @pytest.fixture diff --git a/toolkits/google/tests/test_docs.py b/toolkits/google/tests/test_docs.py index 80801942..6bc48af1 100644 --- a/toolkits/google/tests/test_docs.py +++ b/toolkits/google/tests/test_docs.py @@ -1,6 +1,9 @@ from unittest.mock import AsyncMock, patch import pytest +from arcade.sdk.errors import ToolExecutionError +from googleapiclient.errors import HttpError + from arcade_google.tools.docs import ( create_blank_document, create_document_from_text, @@ -8,9 +11,6 @@ from arcade_google.tools.docs import ( insert_text_at_end_of_document, ) from arcade_google.tools.utils import build_docs_service -from googleapiclient.errors import HttpError - -from arcade.sdk.errors import ToolExecutionError @pytest.fixture diff --git a/toolkits/google/tests/test_drive.py b/toolkits/google/tests/test_drive.py index 7199802f..08fee118 100644 --- a/toolkits/google/tests/test_drive.py +++ b/toolkits/google/tests/test_drive.py @@ -1,12 +1,12 @@ from unittest.mock import AsyncMock, patch import pytest +from arcade.sdk.errors import ToolExecutionError +from googleapiclient.errors import HttpError + from arcade_google.tools.drive import list_documents from arcade_google.tools.models import Corpora, OrderBy from arcade_google.tools.utils import build_drive_service -from googleapiclient.errors import HttpError - -from arcade.sdk.errors import ToolExecutionError @pytest.fixture diff --git a/toolkits/google/tests/test_gmail.py b/toolkits/google/tests/test_gmail.py index df6e68e6..85f64a6d 100644 --- a/toolkits/google/tests/test_gmail.py +++ b/toolkits/google/tests/test_gmail.py @@ -1,6 +1,10 @@ from unittest.mock import MagicMock, patch import pytest +from arcade.sdk import ToolAuthorizationContext, ToolContext +from arcade.sdk.errors import ToolExecutionError +from googleapiclient.errors import HttpError + from arcade_google.tools.gmail import ( delete_draft_email, get_thread, @@ -16,10 +20,6 @@ from arcade_google.tools.gmail import ( write_draft_email, ) from arcade_google.tools.utils import parse_draft_email, parse_email -from googleapiclient.errors import HttpError - -from arcade.sdk import ToolAuthorizationContext, ToolContext -from arcade.sdk.errors import ToolExecutionError @pytest.fixture diff --git a/toolkits/linkedin/.pre-commit-config.yaml b/toolkits/linkedin/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/linkedin/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bacd9161 --- /dev/null +++ b/toolkits/linkedin/.ruff.toml @@ -0,0 +1,46 @@ +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/linkedin/LICENSE b/toolkits/linkedin/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/linkedin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..4657b42c --- /dev/null +++ b/toolkits/linkedin/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ linkedin 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/linkedin/arcade_linkedin/tools/share.py b/toolkits/linkedin/arcade_linkedin/tools/share.py index babe3c01..4005e7bb 100644 --- a/toolkits/linkedin/arcade_linkedin/tools/share.py +++ b/toolkits/linkedin/arcade_linkedin/tools/share.py @@ -1,7 +1,6 @@ from typing import Annotated import httpx - from arcade.sdk import ToolContext, tool from arcade.sdk.auth import LinkedIn from arcade.sdk.errors import ToolExecutionError @@ -33,7 +32,10 @@ async def _send_linkedin_request( ToolExecutionError: If the request fails for any reason. """ url = f"{LINKEDIN_BASE_URL}{endpoint}" - headers = {"Authorization": f"Bearer {context.authorization.token}"} + token = ( + context.authorization.token if context.authorization and context.authorization.token else "" + ) + headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient() as client: try: @@ -47,7 +49,7 @@ async def _send_linkedin_request( return response -def _handle_linkedin_api_error(response: httpx.Response): +def _handle_linkedin_api_error(response: httpx.Response) -> None: """ Handle errors from the LinkedIn API by mapping common status codes to ToolExecutionErrors. @@ -81,11 +83,13 @@ async def create_text_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. + # 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") + user_id = context.authorization.user_info.get("sub") if context.authorization else None if not user_id: raise ToolExecutionError( "User ID not found.", @@ -109,5 +113,6 @@ async def create_text_post( 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}/" - else: - _handle_linkedin_api_error(response) + + _handle_linkedin_api_error(response) + return "" diff --git a/toolkits/linkedin/conftest.py b/toolkits/linkedin/conftest.py new file mode 100644 index 00000000..d44668ec --- /dev/null +++ b/toolkits/linkedin/conftest.py @@ -0,0 +1,20 @@ +import pytest +from arcade.sdk 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 new file mode 100644 index 00000000..d916bc46 --- /dev/null +++ b/toolkits/linkedin/evals/eval_linkedin.py @@ -0,0 +1,48 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + SimilarityCritic, + tool_eval, +) + +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 index d5da1652..82333886 100644 --- a/toolkits/linkedin/pyproject.toml +++ b/toolkits/linkedin/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_linkedin" -version = "0.1.0" +version = "0.1.7" description = "Arcade tools for LinkedIn" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ httpx = "^0.27.2" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/linkedin/tests/__init__.py b/toolkits/linkedin/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/linkedin/tests/test_share.py b/toolkits/linkedin/tests/test_share.py new file mode 100644 index 00000000..efc0f24a --- /dev/null +++ b/toolkits/linkedin/tests/test_share.py @@ -0,0 +1,35 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from arcade.sdk.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 new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/math/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bb64590d --- /dev/null +++ b/toolkits/math/.ruff.toml @@ -0,0 +1,47 @@ +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/math/evals/eval_math_tools.py b/toolkits/math/evals/eval_math_tools.py index 6c6038a3..ebb57fe1 100644 --- a/toolkits/math/evals/eval_math_tools.py +++ b/toolkits/math/evals/eval_math_tools.py @@ -1,6 +1,3 @@ -import arcade_math -from arcade_math.tools.arithmetic import add, sqrt - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -10,6 +7,9 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_math +from arcade_math.tools.arithmetic import add, sqrt + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.85, diff --git a/toolkits/math/tests/test_arithmetic.py b/toolkits/math/tests/test_arithmetic.py index 5ec57d29..5f9b8807 100644 --- a/toolkits/math/tests/test_arithmetic.py +++ b/toolkits/math/tests/test_arithmetic.py @@ -1,4 +1,6 @@ import pytest +from arcade.sdk.errors import ToolExecutionError + from arcade_math.tools.arithmetic import ( add, divide, @@ -9,8 +11,6 @@ from arcade_math.tools.arithmetic import ( sum_range, ) -from arcade.sdk.errors import ToolExecutionError - def test_add(): assert add(1, 2) == 3 diff --git a/toolkits/search/.pre-commit-config.yaml b/toolkits/search/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/search/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bb64590d --- /dev/null +++ b/toolkits/search/.ruff.toml @@ -0,0 +1,47 @@ +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/search/LICENSE b/toolkits/search/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/search/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..a0626464 --- /dev/null +++ b/toolkits/search/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ search 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/search/arcade_search/tools/google.py b/toolkits/search/arcade_search/tools/google.py index c4e05145..5e8af541 100644 --- a/toolkits/search/arcade_search/tools/google.py +++ b/toolkits/search/arcade_search/tools/google.py @@ -3,7 +3,6 @@ import os from typing import Annotated, Any, Optional import serpapi - from arcade.sdk import tool diff --git a/toolkits/search/evals/eval_google_search.py b/toolkits/search/evals/eval_google_search.py index 54d821fa..0e95799e 100644 --- a/toolkits/search/evals/eval_google_search.py +++ b/toolkits/search/evals/eval_google_search.py @@ -1,6 +1,3 @@ -import arcade_search -from arcade_search.tools.google import search_google - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( EvalRubric, @@ -11,6 +8,9 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_search +from arcade_search.tools.google import search_google + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.8, diff --git a/toolkits/search/pyproject.toml b/toolkits/search/pyproject.toml index 65d17c96..0b6c7798 100644 --- a/toolkits/search/pyproject.toml +++ b/toolkits/search/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_search" -version = "0.1.0" +version = "0.1.7" description = "Tools for searching the web" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ serpapi = "^0.1.5" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/search/tests/__init__.py b/toolkits/search/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/search/tests/test_google.py b/toolkits/search/tests/test_google.py new file mode 100644 index 00000000..1734a1cb --- /dev/null +++ b/toolkits/search/tests/test_google.py @@ -0,0 +1,47 @@ +import json +from unittest.mock import patch + +import pytest + +from arcade_search.tools.google import search_google + +GET_SECRET_PATCH_TARGET = "arcade_search.tools.google.get_secret" # noqa: S105 + + +@pytest.mark.asyncio +async def test_search_google_success(): + with ( + patch(GET_SECRET_PATCH_TARGET, return_value="fake_api_key"), + patch("serpapi.Client") 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("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(): + with ( + patch(GET_SECRET_PATCH_TARGET, return_value="fake_api_key"), + patch("serpapi.Client") as MockClient, + ): + mock_client_instance = MockClient.return_value + mock_client_instance.search.return_value.as_dict.return_value = {"organic_results": []} + + result = await search_google("test query", 2) + + expected_result = json.dumps([]) + assert result == expected_result diff --git a/toolkits/slack/.pre-commit-config.yaml b/toolkits/slack/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/slack/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bb64590d --- /dev/null +++ b/toolkits/slack/.ruff.toml @@ -0,0 +1,47 @@ +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/slack/LICENSE b/toolkits/slack/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/slack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..f0a8a0a1 --- /dev/null +++ b/toolkits/slack/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ slack 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/slack/arcade_slack/tools/chat.py b/toolkits/slack/arcade_slack/tools/chat.py index 86a3d133..d53783d4 100644 --- a/toolkits/slack/arcade_slack/tools/chat.py +++ b/toolkits/slack/arcade_slack/tools/chat.py @@ -1,11 +1,11 @@ from typing import Annotated -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Slack -from arcade.sdk.errors import RetryableToolError, ToolExecutionError +from arcade.sdk.errors import RetryableToolError +from slack_sdk import WebClient + +from arcade_slack.tools.utils import format_channels, format_users @tool( @@ -22,54 +22,45 @@ 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.", + "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.""" + slackClient = WebClient( + token=context.authorization.token + if context.authorization and context.authorization.token + else "" + ) - slackClient = WebClient(token=context.authorization.token) + # Step 1: Retrieve the user's Slack ID based on their username + userListResponse = slackClient.users_list() + user_id = None + for user in userListResponse["members"]: + if user["name"].lower() == user_name.lower(): + user_id = user["id"] + break - try: - # Step 1: Retrieve the user's Slack ID based on their username - userListResponse = slackClient.users_list() - user_id = None - for user in userListResponse["members"]: - if user["name"].lower() == user_name.lower(): - user_id = user["id"] - break - - if not user_id: - raise RetryableToolError( - "User not found", - developer_message=f"User with username '{user_name}' not found.", - additional_prompt_content=format_users(userListResponse), - retry_after_ms=500, # Play nice with Slack API rate limits - ) - - # Step 2: Retrieve the DM channel ID with the user - im_response = slackClient.conversations_open(users=[user_id]) - dm_channel_id = im_response["channel"]["id"] - - # Step 3: Send the message as if it's from you (because we're using a user token) - slackClient.chat_postMessage(channel=dm_channel_id, text=message) - - except SlackApiError as e: - error_message = e.response["error"] if "error" in e.response else str(e) - raise ToolExecutionError( - "Error sending message", - developer_message=f"Slack API Error: {error_message}", + if not user_id: + raise RetryableToolError( + "User not found", + developer_message=f"User with username '{user_name}' not found.", + additional_prompt_content=format_users(userListResponse), + retry_after_ms=500, # Play nice with Slack API rate limits ) + # Step 2: Retrieve the DM channel ID with the user + im_response = slackClient.conversations_open(users=[user_id]) + dm_channel_id = im_response["channel"]["id"] -def format_users(userListResponse: dict) -> str: - csv_string = "All active Slack users:\n\nname,real_name\n" - for user in userListResponse["members"]: - if not user.get("deleted", False): - name = user.get("name", "") - real_name = user.get("profile", {}).get("real_name", "") - csv_string += f"{name},{real_name}\n" - return csv_string.strip() + # Step 3: Send the message as if it's from you (because we're using a user token) + response = slackClient.chat_postMessage(channel=dm_channel_id, text=message) + response.validate() + + if isinstance(response.data, dict): + return response.data + return {} @tool( @@ -85,46 +76,39 @@ def send_message_to_channel( context: ToolContext, channel_name: Annotated[ str, - "The Slack channel name where you want to send the message. Slack channel names are ALWAYS lowercase.", + "The Slack channel name where you want to send the message. " + "Slack channel names are ALWAYS lowercase.", ], 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.""" - slackClient = WebClient(token=context.authorization.token) + slackClient = WebClient( + token=context.authorization.token + if context.authorization and context.authorization.token + else "" + ) - try: - # Step 1: Retrieve the list of channels - channels_response = slackClient.conversations_list() - channel_id = None - for channel in channels_response["channels"]: - if channel["name"].lower() == channel_name.lower(): - channel_id = channel["id"] - break + # Step 1: Retrieve the list of channels + channels_response = slackClient.conversations_list() + channel_id = None + for channel in channels_response["channels"]: + if channel["name"].lower() == channel_name.lower(): + channel_id = channel["id"] + break - if not channel_id: - raise RetryableToolError( - "Channel not found", - developer_message=f"Channel with name '{channel_name}' not found.", - additional_prompt_content=format_channels(channels_response), - retry_after_ms=500, # Play nice with Slack API rate limits - ) - - # Step 2: Send the message to the channel - slackClient.chat_postMessage(channel=channel_id, text=message) - - except SlackApiError as e: - error_message = e.response["error"] if "error" in e.response else str(e) - raise ToolExecutionError( - "Error sending message", - developer_message=f"Slack API Error: {error_message}", + if not channel_id: + raise RetryableToolError( + "Channel not found", + developer_message=f"Channel with name '{channel_name}' not found.", + additional_prompt_content=format_channels(channels_response), + retry_after_ms=500, # Play nice with Slack API rate limits ) + # Step 2: Send the message to the channel + response = slackClient.chat_postMessage(channel=channel_id, text=message) + response.validate() -def format_channels(channels_response: dict) -> str: - csv_string = "All active Slack channels:\n\nname\n" - for channel in channels_response["channels"]: - if not channel.get("is_archived", False): - name = channel.get("name", "") - csv_string += f"{name}\n" - return csv_string.strip() + if isinstance(response.data, dict): + return response.data + return {} diff --git a/toolkits/slack/arcade_slack/tools/utils.py b/toolkits/slack/arcade_slack/tools/utils.py new file mode 100644 index 00000000..e72571bb --- /dev/null +++ b/toolkits/slack/arcade_slack/tools/utils.py @@ -0,0 +1,20 @@ +from slack_sdk.web import SlackResponse + + +def format_channels(channels_response: SlackResponse) -> str: + csv_string = "All active Slack channels:\n\nname\n" + for channel in channels_response["channels"]: + if not channel.get("is_archived", False): + name = channel.get("name", "") + csv_string += f"{name}\n" + return csv_string.strip() + + +def format_users(userListResponse: SlackResponse) -> str: + csv_string = "All active Slack users:\n\nname,real_name\n" + for user in userListResponse["members"]: + if not user.get("deleted", False): + name = user.get("name", "") + real_name = user.get("profile", {}).get("real_name", "") + csv_string += f"{name},{real_name}\n" + return csv_string.strip() diff --git a/toolkits/slack/evals/eval_slack_messaging.py b/toolkits/slack/evals/eval_slack_messaging.py index c07ada61..5ef899f0 100644 --- a/toolkits/slack/evals/eval_slack_messaging.py +++ b/toolkits/slack/evals/eval_slack_messaging.py @@ -1,6 +1,3 @@ -import arcade_slack -from arcade_slack.tools.chat import send_dm_to_user, send_message_to_channel - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -11,6 +8,9 @@ from arcade.sdk.eval import ( tool_eval, ) +import arcade_slack +from arcade_slack.tools.chat import send_dm_to_user, send_message_to_channel + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.8, diff --git a/toolkits/slack/pyproject.toml b/toolkits/slack/pyproject.toml index 979ee6d4..f39babe6 100644 --- a/toolkits/slack/pyproject.toml +++ b/toolkits/slack/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_slack" -version = "0.1.0" +version = "0.1.7" description = "Slack tools for LLMs" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ slack-sdk = "^3.31.0" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/slack/tests/__init__.py b/toolkits/slack/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/slack/tests/test_chat.py b/toolkits/slack/tests/test_chat.py new file mode 100644 index 00000000..78172dac --- /dev/null +++ b/toolkits/slack/tests/test_chat.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + +import pytest +from arcade.sdk import ToolAuthorizationContext, ToolContext + +from arcade_slack.tools.chat import send_dm_to_user, send_message_to_channel + + +@pytest.fixture +def mock_context(): + mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 + return ToolContext(authorization=mock_auth) + + +def test_send_dm_to_user(mock_context): + with patch("arcade_slack.tools.chat.WebClient") as MockWebClient: + mock_client = MockWebClient.return_value + mock_client.users_list.return_value = {"members": [{"name": "testuser", "id": "U12345"}]} + mock_client.conversations_open.return_value = {"channel": {"id": "D12345"}} + mock_client.chat_postMessage.return_value = MagicMock(data={"ok": True}) + + response = send_dm_to_user(mock_context, "testuser", "Hello!") + + assert response["ok"] is True + mock_client.users_list.assert_called_once() + mock_client.conversations_open.assert_called_once_with(users=["U12345"]) + mock_client.chat_postMessage.assert_called_once_with(channel="D12345", text="Hello!") + + +def test_send_message_to_channel(mock_context): + with patch("arcade_slack.tools.chat.WebClient") as MockWebClient: + mock_client = MockWebClient.return_value + mock_client.conversations_list.return_value = { + "channels": [{"name": "general", "id": "C12345"}] + } + mock_client.chat_postMessage.return_value = MagicMock(data={"ok": True}) + + response = send_message_to_channel(mock_context, "general", "Hello, channel!") + + assert response["ok"] is True + mock_client.conversations_list.assert_called_once() + mock_client.chat_postMessage.assert_called_once_with( + channel="C12345", text="Hello, channel!" + ) diff --git a/toolkits/spotify/.pre-commit-config.yaml b/toolkits/spotify/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/spotify/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bb64590d --- /dev/null +++ b/toolkits/spotify/.ruff.toml @@ -0,0 +1,47 @@ +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/spotify/LICENSE b/toolkits/spotify/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/spotify/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..5ed46930 --- /dev/null +++ b/toolkits/spotify/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ spotify 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/spotify/arcade_spotify/tools/models.py b/toolkits/spotify/arcade_spotify/tools/models.py index 50f93832..b2d48d43 100644 --- a/toolkits/spotify/arcade_spotify/tools/models.py +++ b/toolkits/spotify/arcade_spotify/tools/models.py @@ -21,9 +21,11 @@ class PlaybackState: track_spotify_url: Optional[str] = None track_artists: list[str] = field(default_factory=list) track_artists_ids: list[str] = field(default_factory=list) + show_name: Optional[str] = None show_id: Optional[str] = None show_spotify_url: Optional[str] = None episode_name: Optional[str] = None + episode_id: Optional[str] = None episode_spotify_url: Optional[str] = None message: Optional[str] = None diff --git a/toolkits/spotify/arcade_spotify/tools/player.py b/toolkits/spotify/arcade_spotify/tools/player.py index 2e2a929d..23990ab7 100644 --- a/toolkits/spotify/arcade_spotify/tools/player.py +++ b/toolkits/spotify/arcade_spotify/tools/player.py @@ -3,6 +3,7 @@ from typing import Annotated, Optional from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Spotify from arcade.sdk.errors import RetryableToolError + from arcade_spotify.tools.models import Device, SearchType from arcade_spotify.tools.search import search from arcade_spotify.tools.utils import ( @@ -39,7 +40,10 @@ async def adjust_playback_position( 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.", + additional_prompt_content=( + "Provide a value for either absolute_position_ms or " + "relative_position_ms, but not both." + ), retry_after_ms=500, ) @@ -50,7 +54,7 @@ async def adjust_playback_position( absolute_position_ms = playback_state["progress_ms"] + relative_position_ms - absolute_position_ms = max(0, absolute_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} @@ -174,7 +178,8 @@ async def start_tracks_playback_by_id( 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. + # 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 @@ -202,7 +207,8 @@ 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. + 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") @@ -247,7 +253,7 @@ async def play_artist_by_name( track_ids = [item["id"] for item in search_results["tracks"]["items"]] response = await start_tracks_playback_by_id(context, track_ids) - return response + return str(response) # NOTE: This tool only works for Spotify Premium users @@ -280,7 +286,7 @@ async def play_track_by_name( track_id = search_results["tracks"]["items"][0]["id"] response = await start_tracks_playback_by_id(context, [track_id]) - return response + return str(response) # NOTE: This tool only works for Spotify Premium users @@ -292,4 +298,4 @@ async def get_available_devices( url = get_url("player_get_available_devices") response = await send_spotify_request(context, "GET", url) response.raise_for_status() - return response.json() + return dict(response.json()) diff --git a/toolkits/spotify/arcade_spotify/tools/search.py b/toolkits/spotify/arcade_spotify/tools/search.py index bddb7b33..f2c11440 100644 --- a/toolkits/spotify/arcade_spotify/tools/search.py +++ b/toolkits/spotify/arcade_spotify/tools/search.py @@ -2,6 +2,7 @@ from typing import Annotated from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Spotify + from arcade_spotify.tools.models import SearchType from arcade_spotify.tools.utils import ( get_url, @@ -19,13 +20,18 @@ async def search( """Search Spotify catalog information Explanation of the q parameter: - You can narrow down your search using field filters. The available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. Each field filter only applies to certain result types. + 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 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. + 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" """ @@ -36,4 +42,4 @@ async def search( context, "GET", url, params={"q": q, "type": ",".join(types), "limit": limit} ) response.raise_for_status() - return response.json() + return dict(response.json()) diff --git a/toolkits/spotify/arcade_spotify/tools/tracks.py b/toolkits/spotify/arcade_spotify/tools/tracks.py index 6fd84056..d8fdef79 100644 --- a/toolkits/spotify/arcade_spotify/tools/tracks.py +++ b/toolkits/spotify/arcade_spotify/tools/tracks.py @@ -2,6 +2,7 @@ from typing import Annotated, Optional from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Spotify + from arcade_spotify.tools.utils import ( get_url, send_spotify_request, @@ -18,7 +19,7 @@ async def get_track_from_id( response = await send_spotify_request(context, "GET", url) response.raise_for_status() - return response.json() + return dict(response.json()) @tool(requires_auth=Spotify()) @@ -92,7 +93,8 @@ async def get_recommendations( ] = None, ) -> Annotated[dict, "A list of recommended tracks"]: """Get track (song) recommendations based on seed artists, genres, and tracks - If a provided target value is outside of the expected range, it will clamp to the nearest valid value. + If a provided target value is outside of the expected range, + it will clamp to the nearest valid value. """ url = get_url("tracks_get_recommendations") params = { @@ -119,7 +121,7 @@ async def get_recommendations( response = await send_spotify_request(context, "GET", url, params=params) response.raise_for_status() - return response.json() + return dict(response.json()) @tool(requires_auth=Spotify()) @@ -133,4 +135,4 @@ async def get_tracks_audio_features( response = await send_spotify_request(context, "GET", url, params=params) response.raise_for_status() - return response.json() + return dict(response.json()) diff --git a/toolkits/spotify/arcade_spotify/tools/utils.py b/toolkits/spotify/arcade_spotify/tools/utils.py index c2741355..5a8a32e1 100644 --- a/toolkits/spotify/arcade_spotify/tools/utils.py +++ b/toolkits/spotify/arcade_spotify/tools/utils.py @@ -1,6 +1,6 @@ import httpx - from arcade.sdk import ToolContext + from arcade_spotify.tools.constants import ENDPOINTS, SPOTIFY_BASE_URL from arcade_spotify.tools.models import PlaybackState @@ -28,7 +28,10 @@ async def send_spotify_request( Raises: ToolExecutionError: If the request fails for any reason. """ - headers = {"Authorization": f"Bearer {context.authorization.token}"} + 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) @@ -36,7 +39,7 @@ async def send_spotify_request( return response -def get_url(endpoint: str, **kwargs) -> str: +def get_url(endpoint: str, **kwargs: object) -> str: """ Get the full Spotify URL for a given endpoint. @@ -67,7 +70,7 @@ def convert_to_playback_state(data: dict) -> PlaybackState: ) if data.get("currently_playing_type") == "track": - item = data.get("item", {}) + item = data.get("item") or {} album = item.get("album", {}) playback_state.album_name = album.get("name") playback_state.album_id = album.get("id") @@ -75,11 +78,11 @@ def convert_to_playback_state(data: dict) -> PlaybackState: 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_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", {}) + item = data.get("item") or {} show = item.get("show", {}) playback_state.show_name = show.get("name") playback_state.show_id = show.get("id") diff --git a/toolkits/spotify/evals/eval_player.py b/toolkits/spotify/evals/eval_player.py index 20b52ef8..3f65ec93 100644 --- a/toolkits/spotify/evals/eval_player.py +++ b/toolkits/spotify/evals/eval_player.py @@ -1,3 +1,14 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + BinaryCritic, + EvalRubric, + EvalSuite, + ExpectedToolCall, + NumericCritic, + SimilarityCritic, + tool_eval, +) + from arcade_spotify.tools.player import ( adjust_playback_position, get_currently_playing, @@ -11,17 +22,6 @@ from arcade_spotify.tools.player import ( start_tracks_playback_by_id, ) -from arcade.sdk import ToolCatalog -from arcade.sdk.eval import ( - BinaryCritic, - EvalRubric, - EvalSuite, - ExpectedToolCall, - NumericCritic, - SimilarityCritic, - tool_eval, -) - # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/spotify/evals/eval_search.py b/toolkits/spotify/evals/eval_search.py index d0305f89..979cb50d 100644 --- a/toolkits/spotify/evals/eval_search.py +++ b/toolkits/spotify/evals/eval_search.py @@ -1,6 +1,3 @@ -from arcade_spotify.tools.models import SearchType -from arcade_spotify.tools.search import search - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -11,6 +8,9 @@ from arcade.sdk.eval import ( tool_eval, ) +from arcade_spotify.tools.models import SearchType +from arcade_spotify.tools.search import search + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/spotify/evals/eval_tracks.py b/toolkits/spotify/evals/eval_tracks.py index cf52cb12..3ff7f825 100644 --- a/toolkits/spotify/evals/eval_tracks.py +++ b/toolkits/spotify/evals/eval_tracks.py @@ -1,9 +1,3 @@ -from arcade_spotify.tools.tracks import ( - get_recommendations, - get_track_from_id, - get_tracks_audio_features, -) - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -13,6 +7,12 @@ from arcade.sdk.eval import ( tool_eval, ) +from arcade_spotify.tools.tracks import ( + get_recommendations, + get_track_from_id, + get_tracks_audio_features, +) + # Evaluation rubric rubric = EvalRubric( fail_threshold=0.9, diff --git a/toolkits/spotify/pyproject.toml b/toolkits/spotify/pyproject.toml index 8f99837e..15b2066e 100644 --- a/toolkits/spotify/pyproject.toml +++ b/toolkits/spotify/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_spotify" -version = "0.1.0" +version = "0.1.7" description = "Arcade tools for Spotify" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ httpx = "^0.27.2" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/spotify/tests/__init__.py b/toolkits/spotify/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/spotify/tests/test_utils.py b/toolkits/spotify/tests/test_utils.py new file mode 100644 index 00000000..0e04ef17 --- /dev/null +++ b/toolkits/spotify/tests/test_utils.py @@ -0,0 +1,128 @@ +from arcade_spotify.tools.models import PlaybackState +from arcade_spotify.tools.utils import convert_to_playback_state + + +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/web/.pre-commit-config.yaml b/toolkits/web/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/web/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bb64590d --- /dev/null +++ b/toolkits/web/.ruff.toml @@ -0,0 +1,47 @@ +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/web/LICENSE b/toolkits/web/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..78bc47bf --- /dev/null +++ b/toolkits/web/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ web 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/web/arcade_web/tools/firecrawl.py b/toolkits/web/arcade_web/tools/firecrawl.py index ada11335..fc245bd6 100644 --- a/toolkits/web/arcade_web/tools/firecrawl.py +++ b/toolkits/web/arcade_web/tools/firecrawl.py @@ -1,8 +1,8 @@ from typing import Annotated, Any, Optional +from arcade.sdk import tool from firecrawl import FirecrawlApp -from arcade.sdk import tool from arcade_web.tools.models import Formats from arcade_web.tools.utils import get_secret @@ -24,7 +24,8 @@ async def scrape_url( exclude_tags: Annotated[list[str] | None, "List of tags to exclude from the output"] = None, wait_for: Annotated[ Optional[int], - "Specify a delay in milliseconds before fetching the content, allowing the page sufficient time to load.", + "Specify a delay in milliseconds before fetching the content, allowing the page " + "sufficient time to load.", ] = 10, timeout: Annotated[Optional[int], "Timeout in milliseconds for the request"] = 30000, ) -> Annotated[dict[str, Any], "Scraped data in specified formats"]: @@ -45,7 +46,7 @@ async def scrape_url( } response = app.scrape_url(url, params=params) - return response + return dict(response) # TODO: Support scrapeOptions. @@ -59,7 +60,8 @@ async def crawl_website( 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.", + "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[ @@ -97,7 +99,7 @@ async def crawl_website( else: response = app.crawl_url(url, params=params) - return response + return dict(response) @tool @@ -116,10 +118,11 @@ async def get_crawl_status( if "data" in crawl_status: del crawl_status["data"] - return crawl_status + 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. +# 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 async def get_crawl_data( crawl_id: Annotated[str, "The ID of the crawl job"], @@ -133,7 +136,7 @@ async def get_crawl_data( app = FirecrawlApp(api_key=api_key) crawl_data = app.check_crawl_status(crawl_id) - return crawl_data + return dict(crawl_data) @tool @@ -149,7 +152,7 @@ async def cancel_crawl( app = FirecrawlApp(api_key=api_key) cancellation_status = app.cancel_crawl(crawl_id) - return cancellation_status + return dict(cancellation_status) @tool @@ -167,7 +170,7 @@ async def map_website( api_key = get_secret("FIRECRAWL_API_KEY") app = FirecrawlApp(api_key=api_key) - params = { + params: dict[str, Any] = { "ignoreSitemap": ignore_sitemap, "includeSubdomains": include_subdomains, "limit": limit, @@ -177,4 +180,4 @@ async def map_website( map_result = app.map_url(url, params=params) - return map_result + return dict(map_result) diff --git a/toolkits/web/evals/eval_firecrawl.py b/toolkits/web/evals/eval_firecrawl.py index 97533391..9e30e840 100644 --- a/toolkits/web/evals/eval_firecrawl.py +++ b/toolkits/web/evals/eval_firecrawl.py @@ -1,13 +1,3 @@ -import arcade_web -from arcade_web.tools.firecrawl import ( - cancel_crawl, - crawl_website, - get_crawl_data, - get_crawl_status, - map_website, - scrape_url, -) - from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( BinaryCritic, @@ -19,6 +9,16 @@ from arcade.sdk.eval import ( tool_eval, ) +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, diff --git a/toolkits/web/pyproject.toml b/toolkits/web/pyproject.toml index cec3c3f1..7e949fe6 100644 --- a/toolkits/web/pyproject.toml +++ b/toolkits/web/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_web" -version = "0.1.0" +version = "0.1.7" description = "LLM tools for web-related tasks" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ firecrawl-py = "^1.3.1" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/web/tests/test_firecrawl.py b/toolkits/web/tests/test_firecrawl.py index 1eefe462..e31d7302 100644 --- a/toolkits/web/tests/test_firecrawl.py +++ b/toolkits/web/tests/test_firecrawl.py @@ -1,6 +1,8 @@ from unittest.mock import AsyncMock, patch import pytest +from arcade.sdk.errors import ToolExecutionError + from arcade_web.tools.firecrawl import ( cancel_crawl, crawl_website, @@ -10,8 +12,6 @@ from arcade_web.tools.firecrawl import ( scrape_url, ) -from arcade.sdk.errors import ToolExecutionError - @pytest.fixture def mock_context(): diff --git a/toolkits/x/.ruff.toml b/toolkits/x/.ruff.toml index 36a7a4ed..bacd9161 100644 --- a/toolkits/x/.ruff.toml +++ b/toolkits/x/.ruff.toml @@ -37,7 +37,9 @@ select = [ ] [lint.per-file-ignores] -"**/tests/*" = ["S101"] +"*" = ["TRY003", "B904"] +"**/tests/*" = ["S101", "E501"] +"**/evals/*" = ["S101", "E501"] [format] preview = true diff --git a/toolkits/x/Makefile b/toolkits/x/Makefile index 8602ccaf..64b07b37 100644 --- a/toolkits/x/Makefile +++ b/toolkits/x/Makefile @@ -45,8 +45,8 @@ bump-version: ## Bump the version in the pyproject.toml file .PHONY: check check: ## Run code quality tools. - @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock" - @poetry check --lock + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check @echo "πŸš€ Linting code: Running pre-commit" @poetry run pre-commit run -a @echo "πŸš€ Static type checking: Running mypy" diff --git a/toolkits/x/arcade_x/tools/tweets.py b/toolkits/x/arcade_x/tools/tweets.py index e5cc1cc3..9aa71034 100644 --- a/toolkits/x/arcade_x/tools/tweets.py +++ b/toolkits/x/arcade_x/tools/tweets.py @@ -75,7 +75,7 @@ async def search_recent_tweets_by_username( Includes replies and reposts.""" headers = get_headers_with_token(context) - params: dict[str, int | str] = { + params: dict[str, Any] = { "query": f"from:{username}", "max_results": min( max(max_results, 10), 100 @@ -132,7 +132,7 @@ async def search_recent_tweets_by_keywords( """ if not any([keywords, phrases]): - raise RetryableToolError( # noqa: TRY003 + 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", @@ -145,7 +145,7 @@ async def search_recent_tweets_by_keywords( if keywords: query += " ".join(keywords or []) - params: dict[str, int | str] = { + params: dict[str, Any] = { "query": query.strip(), "max_results": min( max(max_results, 10), 100 diff --git a/toolkits/x/arcade_x/tools/users.py b/toolkits/x/arcade_x/tools/users.py index 82071d72..17e0148f 100644 --- a/toolkits/x/arcade_x/tools/users.py +++ b/toolkits/x/arcade_x/tools/users.py @@ -48,7 +48,7 @@ async def lookup_single_user_by_username( response = await client.get(url, headers=headers, timeout=10) if response.status_code == 404: # User not found - raise RetryableToolError( # noqa: TRY003 + raise RetryableToolError( "User not found", developer_message=f"User with username '{username}' not found.", additional_prompt_content="Please check the username and try again.", diff --git a/toolkits/x/arcade_x/tools/utils.py b/toolkits/x/arcade_x/tools/utils.py index f1e4b48f..e7f4c6ca 100644 --- a/toolkits/x/arcade_x/tools/utils.py +++ b/toolkits/x/arcade_x/tools/utils.py @@ -12,12 +12,15 @@ def get_tweet_url(tweet_id: str) -> str: 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( # noqa: TRY003 + 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 {context.authorization.token}", + "Authorization": f"Bearer {token}", "Content-Type": "application/json", } diff --git a/toolkits/x/evals/eval_x_tools.py b/toolkits/x/evals/eval_x_tools.py index b3ab047f..b344d4a5 100644 --- a/toolkits/x/evals/eval_x_tools.py +++ b/toolkits/x/evals/eval_x_tools.py @@ -45,7 +45,7 @@ search_recent_tweets_by_username_history = [ }, { "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: E501, RUF001 + "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", }, diff --git a/toolkits/x/pyproject.toml b/toolkits/x/pyproject.toml index 5263024c..69eb0e81 100644 --- a/toolkits/x/pyproject.toml +++ b/toolkits/x/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_x" -version = "0.1.3" +version = "0.1.7" description = "LLM tools for interacting with X (Twitter)" authors = ["Arcade AI "] @@ -8,6 +8,7 @@ authors = ["Arcade AI "] python = "^3.10" arcade-ai = "0.1.*" httpx = "^0.27.2" + [tool.poetry.dev-dependencies] pytest = "^8.3.0" pytest-cov = "^4.0.0" diff --git a/toolkits/zoom/.pre-commit-config.yaml b/toolkits/zoom/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/zoom/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +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 new file mode 100644 index 00000000..bacd9161 --- /dev/null +++ b/toolkits/zoom/.ruff.toml @@ -0,0 +1,46 @@ +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/zoom/LICENSE b/toolkits/zoom/LICENSE new file mode 100644 index 00000000..76cd1385 --- /dev/null +++ b/toolkits/zoom/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024, 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 new file mode 100644 index 00000000..68003ca4 --- /dev/null +++ b/toolkits/zoom/Makefile @@ -0,0 +1,53 @@ +.PHONY: help + +help: + @echo "πŸ› οΈ code_sandbox 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 poetry environment and install the pre-commit hooks + @echo "πŸ“¦ Checking if Poetry is installed" + @if ! command -v poetry &> /dev/null; then \ + echo "πŸ“¦ Installing Poetry with pip"; \ + pip install poetry; \ + else \ + echo "πŸ“¦ Poetry is already installed"; \ + fi + @echo "πŸš€ Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "πŸš€ Creating wheel file" + poetry 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" + @poetry 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 + @echo "πŸš€ Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "πŸš€ Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "πŸš€ Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "πŸš€ Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/zoom/arcade_zoom/tools/meetings.py b/toolkits/zoom/arcade_zoom/tools/meetings.py index 76290c95..03ea1ff1 100644 --- a/toolkits/zoom/arcade_zoom/tools/meetings.py +++ b/toolkits/zoom/arcade_zoom/tools/meetings.py @@ -1,7 +1,6 @@ from typing import Annotated, Optional import httpx - from arcade.sdk import ToolContext, tool from arcade.sdk.auth import Zoom from arcade.sdk.errors import ToolExecutionError @@ -33,7 +32,10 @@ async def _send_zoom_request( ToolExecutionError: If the request fails for any reason. """ url = f"{ZOOM_BASE_URL}{endpoint}" - headers = {"Authorization": f"Bearer {context.authorization.token}"} + token = ( + context.authorization.token if context.authorization and context.authorization.token else "" + ) + headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient() as client: try: @@ -47,7 +49,7 @@ async def _send_zoom_request( return response -def _handle_zoom_api_error(response: httpx.Response): +def _handle_zoom_api_error(response: httpx.Response) -> None: """ Handle errors from the Zoom API by mapping common status codes to ToolExecutionErrors. @@ -85,11 +87,13 @@ async def list_upcoming_meetings( endpoint = f"/users/{user_id}/upcoming_meetings" response = await _send_zoom_request(context, "GET", endpoint) - if response.status_code >= 200 and response.status_code < 300: - return response.json() - else: + + if not (200 <= response.status_code < 300): _handle_zoom_api_error(response) + response_json = response.json() + return dict(response_json) + @tool( requires_auth=Zoom( @@ -107,7 +111,9 @@ async def get_meeting_invitation( endpoint = f"/meetings/{meeting_id}/invitation" response = await _send_zoom_request(context, "GET", endpoint) - if response.status_code >= 200 and response.status_code < 300: - return response.json() - else: + + 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/pyproject.toml b/toolkits/zoom/pyproject.toml index 1db702fe..ce8400fe 100644 --- a/toolkits/zoom/pyproject.toml +++ b/toolkits/zoom/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_zoom" -version = "0.1.0" +version = "0.1.7" description = "Arcade tools for Zoom" authors = ["Arcade AI "] @@ -11,7 +11,32 @@ httpx = "^0.27.2" [tool.poetry.dev-dependencies] pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[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 diff --git a/toolkits/zoom/tests/__init__.py b/toolkits/zoom/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/zoom/tests/test_meetings.py b/toolkits/zoom/tests/test_meetings.py new file mode 100644 index 00000000..e8352852 --- /dev/null +++ b/toolkits/zoom/tests/test_meetings.py @@ -0,0 +1,35 @@ +import pytest +from arcade.sdk.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.")