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.
This commit is contained in:
Eric Gustin 2024-12-20 09:49:45 -08:00 committed by GitHub
parent 950a8600f8
commit ab889f9f1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 2958 additions and 516 deletions

12
.github/scripts/get_toolkits.sh vendored Executable file
View file

@ -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 ']'

View file

@ -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

54
.github/workflows/test-toolkits.yml vendored Normal file
View file

@ -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

View file

@ -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."""

View file

@ -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

View file

@ -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),

View file

@ -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() == ""

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -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 <dev@arcade-ai.com>"]
@ -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

View file

View file

@ -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"}',
}

View file

@ -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

View file

@ -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

21
toolkits/github/LICENSE Normal file
View file

@ -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.

53
toolkits/github/Makefile Normal file
View file

@ -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

View file

@ -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(

View file

@ -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",
}

View file

@ -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> repo owned by <OWNER> titled 'Found a bug' with the body 'I'm having a problem with this.' Assign it to <USER> and label it 'bug'"
# Example `arcade chat` usage:
# "create an issue in the <REPO> repo owned by <OWNER> titled
# 'Found a bug' with the body 'I'm having a problem with this.'
# Assign it to <USER> 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)

View file

@ -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"

View file

@ -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 <USER> has that are in the <OWNER>/<REPO> 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 <USER> has that are in the <OWNER>/<REPO> 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 <OWNER>/<REPO> repo. Include diff content in your response."
# Example `arcade chat` usage:
# "get the PR #72 in the <OWNER>/<REPO> 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 <OWNER>/<REPO> 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 <OWNER>/<REPO> 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 <OWNER>/<REPO> 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 <OWNER>/<REPO> 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)

View file

@ -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 <OWNER>/<REPO> 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 <ORG> organization. Sort by creation date in descending order."
# Example arcade chat usage:
# "List all repositories for the <ORG> 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 <OWNER>/<REPO> repo in the last week by <USER>"
# Example arcade chat usage:
# "List all merges into main for the <OWNER>/<REPO> repo in the last week by <USER>"
@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 <OWNER>/<REPO> 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 <OWNER>/<REPO> 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)

View file

@ -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.

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 <dev@arcade-ai.com>"]
@ -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

View file

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

21
toolkits/google/LICENSE Normal file
View file

@ -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.

53
toolkits/google/Makefile Normal file
View file

@ -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

View file

@ -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}"
)

View file

@ -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 = [
{

View file

@ -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:

View file

@ -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)

View file

@ -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"
)

View file

@ -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))

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 <dev@arcade-ai.com>"]
@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

21
toolkits/linkedin/LICENSE Normal file
View file

@ -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.

View file

@ -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

View file

@ -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 ""

View file

@ -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

View file

@ -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

View file

@ -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 <dev@arcade-ai.com>"]
@ -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

View file

View file

@ -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)

View file

@ -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

47
toolkits/math/.ruff.toml Normal file
View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

21
toolkits/search/LICENSE Normal file
View file

@ -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.

53
toolkits/search/Makefile Normal file
View file

@ -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

View file

@ -3,7 +3,6 @@ import os
from typing import Annotated, Any, Optional
import serpapi
from arcade.sdk import tool

View file

@ -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,

View file

@ -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 <dev@arcade-ai.com>"]
@ -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

View file

View file

@ -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

View file

@ -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

47
toolkits/slack/.ruff.toml Normal file
View file

@ -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

21
toolkits/slack/LICENSE Normal file
View file

@ -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.

53
toolkits/slack/Makefile Normal file
View file

@ -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

View file

@ -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 {}

View file

@ -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()

View file

@ -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,

View file

@ -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 <dev@arcade-ai.com>"]
@ -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

View file

View file

@ -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!"
)

View file

@ -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

View file

@ -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

21
toolkits/spotify/LICENSE Normal file
View file

@ -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.

53
toolkits/spotify/Makefile Normal file
View file

@ -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

View file

@ -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

View file

@ -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())

View file

@ -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())

View file

@ -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())

View file

@ -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")

View file

@ -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,

View file

@ -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,

Some files were not shown because too many files have changed in this diff Show more